Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30ad89e458 | ||
|
|
3e4bfca2df | ||
|
|
4378463600 | ||
|
|
2cfd7d56a1 | ||
|
|
84bdfbd786 | ||
|
|
beba7586f9 | ||
|
|
a4dff4bde9 | ||
|
|
d0b33e8f43 | ||
|
|
6a5a3c9cd1 | ||
|
|
15e225439b | ||
|
|
91829b0ed9 | ||
|
|
caffc996ff | ||
|
|
cd13a6d124 | ||
|
|
566258e9c8 | ||
|
|
dc2a8e2307 | ||
|
|
f48b53fd8e | ||
|
|
ccfee938b7 | ||
|
|
28d952bcbd | ||
|
|
9bc023d239 |
@@ -10,6 +10,13 @@
|
|||||||
<string>77</string>
|
<string>77</string>
|
||||||
<key>objects</key>
|
<key>objects</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>AAAAAA009</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXBuildFile</string>
|
||||||
|
<key>fileRef</key>
|
||||||
|
<string>AAAAAA109</string>
|
||||||
|
</dict>
|
||||||
<key>AAAAAA007</key>
|
<key>AAAAAA007</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isa</key>
|
<key>isa</key>
|
||||||
@@ -88,6 +95,17 @@
|
|||||||
<key>sourceTree</key>
|
<key>sourceTree</key>
|
||||||
<string><group></string>
|
<string><group></string>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>AAAAAA109</key>
|
||||||
|
<dict>
|
||||||
|
<key>isa</key>
|
||||||
|
<string>PBXFileReference</string>
|
||||||
|
<key>lastKnownFileType</key>
|
||||||
|
<string>sourcecode.swift</string>
|
||||||
|
<key>path</key>
|
||||||
|
<string>DiagnosticsView.swift</string>
|
||||||
|
<key>sourceTree</key>
|
||||||
|
<string><group></string>
|
||||||
|
</dict>
|
||||||
<key>AAAAAA101</key>
|
<key>AAAAAA101</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isa</key>
|
<key>isa</key>
|
||||||
@@ -179,6 +197,7 @@
|
|||||||
<string>AAAAAA106</string>
|
<string>AAAAAA106</string>
|
||||||
<string>AAAAAA107</string>
|
<string>AAAAAA107</string>
|
||||||
<string>AAAAAA108</string>
|
<string>AAAAAA108</string>
|
||||||
|
<string>AAAAAA109</string>
|
||||||
</array>
|
</array>
|
||||||
<key>path</key>
|
<key>path</key>
|
||||||
<string>ClaudeChecker</string>
|
<string>ClaudeChecker</string>
|
||||||
@@ -292,6 +311,7 @@
|
|||||||
<string>AAAAAA005</string>
|
<string>AAAAAA005</string>
|
||||||
<string>AAAAAA007</string>
|
<string>AAAAAA007</string>
|
||||||
<string>AAAAAA008</string>
|
<string>AAAAAA008</string>
|
||||||
|
<string>AAAAAA009</string>
|
||||||
</array>
|
</array>
|
||||||
<key>runOnlyForDeploymentPostprocessing</key>
|
<key>runOnlyForDeploymentPostprocessing</key>
|
||||||
<string>0</string>
|
<string>0</string>
|
||||||
|
|||||||
@@ -584,6 +584,7 @@ struct SettingsView: View {
|
|||||||
@Binding var showSettings: Bool
|
@Binding var showSettings: Bool
|
||||||
@Binding var showUpdateSheet: Bool
|
@Binding var showUpdateSheet: Bool
|
||||||
@State private var checkingForUpdates = false
|
@State private var checkingForUpdates = false
|
||||||
|
@State private var showDiagnostics = false
|
||||||
|
|
||||||
let refreshOptions: [(label: String, seconds: TimeInterval)] = [
|
let refreshOptions: [(label: String, seconds: TimeInterval)] = [
|
||||||
("1 min", 60),
|
("1 min", 60),
|
||||||
@@ -770,6 +771,23 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
// Diagnostics
|
||||||
|
HStack {
|
||||||
|
Label("Diagnostics", systemImage: "stethoscope")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Button("Open") { showDiagnostics = true }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showDiagnostics) {
|
||||||
|
DiagnosticsView(isPresented: $showDiagnostics)
|
||||||
|
.environmentObject(vm)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
// About
|
// About
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
struct DiagnosticsView: View {
|
||||||
|
@EnvironmentObject var vm: UsageViewModel
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
@State private var liveAllCookies: [HTTPCookie] = []
|
||||||
|
@State private var loadingCookies = false
|
||||||
|
@State private var copied = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header
|
||||||
|
HStack {
|
||||||
|
Text("Diagnostics")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
Spacer()
|
||||||
|
Button("Copy All") {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(fullDiagText, forType: .string)
|
||||||
|
copied = true
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false }
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
if copied {
|
||||||
|
Text("Copied!")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.green)
|
||||||
|
}
|
||||||
|
Button("Close") { isPresented = false }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color(nsColor: .windowBackgroundColor))
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
|
||||||
|
// App info
|
||||||
|
DiagSection(title: "App") {
|
||||||
|
DiagRow("Version", Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?")
|
||||||
|
DiagRow("Build", Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?")
|
||||||
|
DiagRow("Signed in", vm.isSignedIn ? "Yes" : "No")
|
||||||
|
DiagRow("Email", vm.userEmail.isEmpty ? "(none)" : vm.userEmail)
|
||||||
|
DiagRow("Org ID", UserDefaults.standard.string(forKey: "claude_org_id") ?? "(none)")
|
||||||
|
DiagRow("Error", vm.errorMessage ?? "(none)")
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Last request
|
||||||
|
DiagSection(title: "Last Request") {
|
||||||
|
DiagRow("Path", vm.diagLastPath.isEmpty ? "(none)" : vm.diagLastPath)
|
||||||
|
DiagRow("Status", vm.diagLastStatus == 0 ? "(none)" : "\(vm.diagLastStatus)")
|
||||||
|
DiagRow("Error", vm.diagLastError.isEmpty ? "(none)" : vm.diagLastError)
|
||||||
|
if let t = vm.diagLastFetch {
|
||||||
|
DiagRow("Time", t.formatted(date: .omitted, time: .standard))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Response body
|
||||||
|
if !vm.diagLastBody.isEmpty {
|
||||||
|
DiagSection(title: "Response Body (first 500 chars)") {
|
||||||
|
Text(vm.diagLastBody)
|
||||||
|
.font(.system(size: 10, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(8)
|
||||||
|
.background(Color.primary.opacity(0.04))
|
||||||
|
.cornerRadius(5)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookie summary
|
||||||
|
DiagSection(title: "Cookie Store") {
|
||||||
|
DiagRow("Total cookies", "\(vm.diagCookieCount)")
|
||||||
|
DiagRow("claude.ai cookies", "\(vm.diagClaudeCookieCount)")
|
||||||
|
DiagRow("All domains", vm.diagCookieDomains.isEmpty
|
||||||
|
? "(none — never fetched)"
|
||||||
|
: vm.diagCookieDomains.joined(separator: ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Live cookie list
|
||||||
|
DiagSection(title: "Live Cookie Names (WKWebsiteDataStore.default)") {
|
||||||
|
if loadingCookies {
|
||||||
|
ProgressView().scaleEffect(0.7)
|
||||||
|
} else if liveAllCookies.isEmpty {
|
||||||
|
Text("No cookies found")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
} else {
|
||||||
|
let claudeCookies = liveAllCookies.filter {
|
||||||
|
$0.domain.contains("claude.ai") || $0.domain.contains("anthropic.com")
|
||||||
|
}
|
||||||
|
let otherCookies = liveAllCookies.filter {
|
||||||
|
!$0.domain.contains("claude.ai") && !$0.domain.contains("anthropic.com")
|
||||||
|
}
|
||||||
|
if !claudeCookies.isEmpty {
|
||||||
|
Text("claude.ai / anthropic.com (\(claudeCookies.count))")
|
||||||
|
.font(.system(size: 10, weight: .medium))
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
ForEach(claudeCookies, id: \.name) { c in
|
||||||
|
Text("• \(c.domain) \(c.name)")
|
||||||
|
.font(.system(size: 10, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !otherCookies.isEmpty {
|
||||||
|
Text("Other domains (\(otherCookies.count))")
|
||||||
|
.font(.system(size: 10, weight: .medium))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top, 4)
|
||||||
|
ForEach(otherCookies.prefix(10), id: \.name) { c in
|
||||||
|
Text("• \(c.domain) \(c.name)")
|
||||||
|
.font(.system(size: 10, design: .monospaced))
|
||||||
|
.foregroundColor(Color.secondary.opacity(0.6))
|
||||||
|
}
|
||||||
|
if otherCookies.count > 10 {
|
||||||
|
Text("… and \(otherCookies.count - 10) more")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(Color.secondary.opacity(0.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Refresh cookies") { loadLiveCookies() }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 480, height: 560)
|
||||||
|
.onAppear { loadLiveCookies() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadLiveCookies() {
|
||||||
|
loadingCookies = true
|
||||||
|
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
liveAllCookies = cookies.sorted { $0.domain < $1.domain }
|
||||||
|
loadingCookies = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fullDiagText: String {
|
||||||
|
var lines: [String] = []
|
||||||
|
lines.append("=== ClaudeChecker Diagnostics ===")
|
||||||
|
lines.append("Version: \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"))")
|
||||||
|
lines.append("Signed in: \(vm.isSignedIn ? "Yes" : "No")")
|
||||||
|
lines.append("Email: \(vm.userEmail.isEmpty ? "(none)" : vm.userEmail)")
|
||||||
|
lines.append("Org ID: \(UserDefaults.standard.string(forKey: "claude_org_id") ?? "(none)")")
|
||||||
|
lines.append("Error: \(vm.errorMessage ?? "(none)")")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Last path: \(vm.diagLastPath)")
|
||||||
|
lines.append("Last status: \(vm.diagLastStatus)")
|
||||||
|
lines.append("Last error: \(vm.diagLastError)")
|
||||||
|
lines.append("Total cookies: \(vm.diagCookieCount)")
|
||||||
|
lines.append("Claude cookies: \(vm.diagClaudeCookieCount)")
|
||||||
|
lines.append("Domains: \(vm.diagCookieDomains.joined(separator: ", "))")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Response body:")
|
||||||
|
lines.append(vm.diagLastBody)
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Live cookies:")
|
||||||
|
for c in liveAllCookies {
|
||||||
|
lines.append(" \(c.domain) \(c.name) httpOnly=\(c.isHTTPOnly) secure=\(c.isSecure)")
|
||||||
|
}
|
||||||
|
return lines.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private struct DiagSection<Content: View>: View {
|
||||||
|
let title: String
|
||||||
|
@ViewBuilder let content: () -> Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DiagRow: View {
|
||||||
|
let label: String
|
||||||
|
let value: String
|
||||||
|
|
||||||
|
init(_ label: String, _ value: String) {
|
||||||
|
self.label = label
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(width: 100, alignment: .leading)
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,9 +15,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.2.1-beta.17</string>
|
<string>1.2.1-beta.23</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>65</string>
|
<string>71</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>13.0</string>
|
<string>13.0</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
|
|||||||
+168
-107
@@ -30,14 +30,19 @@ class UsageViewModel: ObservableObject {
|
|||||||
private var previousPercents: [String: Double] = [:]
|
private var previousPercents: [String: Double] = [:]
|
||||||
private var firedThresholds: [String: Set<Int>] = [:]
|
private var firedThresholds: [String: Set<Int>] = [:]
|
||||||
|
|
||||||
// Background WKWebView for API calls via callAsyncJavaScript.
|
// WebView used for JS-based API calls (avoids URLSession 403 fingerprinting issues)
|
||||||
// Hosted in a hidden NSWindow — without a window WebKit suspends the WebView
|
|
||||||
// and JS execution stops working, breaking callAsyncJavaScript.
|
|
||||||
private var apiWebView: WKWebView?
|
private var apiWebView: WKWebView?
|
||||||
private var apiDelegate: APIWebViewDelegate?
|
private var navWaiter: WKNavWaiter?
|
||||||
private var apiWindow: NSWindow?
|
|
||||||
private var apiWebViewLoaded = false
|
// Diagnostics — populated on every urlFetch call
|
||||||
private var apiReadyContinuations: [CheckedContinuation<Void, Never>] = []
|
@Published var diagCookieCount: Int = 0
|
||||||
|
@Published var diagClaudeCookieCount: Int = 0
|
||||||
|
@Published var diagCookieDomains: [String] = []
|
||||||
|
@Published var diagLastPath: String = ""
|
||||||
|
@Published var diagLastStatus: Int = 0
|
||||||
|
@Published var diagLastBody: String = ""
|
||||||
|
@Published var diagLastError: String = ""
|
||||||
|
@Published var diagLastFetch: Date? = nil
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let saved = UserDefaults.standard.double(forKey: "refresh_interval")
|
let saved = UserDefaults.standard.double(forKey: "refresh_interval")
|
||||||
@@ -47,96 +52,152 @@ class UsageViewModel: ObservableObject {
|
|||||||
burnHistoryStore = saved
|
burnHistoryStore = saved
|
||||||
}
|
}
|
||||||
loadPlaceholderData()
|
loadPlaceholderData()
|
||||||
setupAPIWebView()
|
|
||||||
Task { await checkInitialSignInState() }
|
Task { await checkInitialSignInState() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Background API WebView
|
// Called after login: store the webview (already on claude.ai) so jsFetch can use it.
|
||||||
|
|
||||||
private func setupAPIWebView() {
|
|
||||||
let config = WKWebViewConfiguration()
|
|
||||||
config.websiteDataStore = WKWebsiteDataStore.default()
|
|
||||||
let wv = WKWebView(frame: NSRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
|
|
||||||
let del = APIWebViewDelegate()
|
|
||||||
del.onNavigationEnd = { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
self.apiWebViewLoaded = true
|
|
||||||
self.resumeAPIReadyContinuations()
|
|
||||||
}
|
|
||||||
wv.navigationDelegate = del
|
|
||||||
apiWebView = wv
|
|
||||||
apiDelegate = del
|
|
||||||
|
|
||||||
// A WKWebView with no window is suspended by macOS — JS execution won't run.
|
|
||||||
// Hosting it in a 1×1 transparent window keeps WebKit's process alive.
|
|
||||||
let window = NSWindow(
|
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 1, height: 1),
|
|
||||||
styleMask: .borderless,
|
|
||||||
backing: .buffered,
|
|
||||||
defer: false)
|
|
||||||
window.alphaValue = 0.0
|
|
||||||
window.ignoresMouseEvents = true
|
|
||||||
window.isReleasedWhenClosed = false
|
|
||||||
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
|
|
||||||
window.contentView?.addSubview(wv)
|
|
||||||
window.orderFrontRegardless()
|
|
||||||
apiWindow = window
|
|
||||||
|
|
||||||
wv.load(URLRequest(url: URL(string: "https://claude.ai")!))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resumeAPIReadyContinuations() {
|
|
||||||
let pending = apiReadyContinuations
|
|
||||||
apiReadyContinuations.removeAll()
|
|
||||||
pending.forEach { $0.resume() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func waitForAPIWebViewReady() async {
|
|
||||||
guard !apiWebViewLoaded else { return }
|
|
||||||
await withCheckedContinuation { cont in
|
|
||||||
apiReadyContinuations.append(cont)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called after login: adopts the proven-authenticated login WebView for all API calls.
|
|
||||||
// This avoids the background WebView potentially missing auth tokens that only exist
|
|
||||||
// in the login WebView's JS context (localStorage, Service Workers, etc.).
|
|
||||||
func adoptAndRefresh(_ loginWebView: WKWebView) async {
|
func adoptAndRefresh(_ loginWebView: WKWebView) async {
|
||||||
loginWebView.removeFromSuperview()
|
for _ in 0..<30 {
|
||||||
loginWebView.frame = NSRect(x: 0, y: 0, width: 1, height: 1)
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||||
apiWindow?.contentView?.addSubview(loginWebView)
|
if cookies.contains(where: { $0.domain.contains("claude.ai") }) { break }
|
||||||
apiWebView = loginWebView
|
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||||
apiWebViewLoaded = true
|
}
|
||||||
resumeAPIReadyContinuations()
|
if loginWebView.url?.host?.hasSuffix("claude.ai") == true {
|
||||||
try? await Task.sleep(nanoseconds: 400_000_000)
|
apiWebView = loginWebView
|
||||||
|
} else {
|
||||||
|
await prepareApiWebView()
|
||||||
|
}
|
||||||
await refresh()
|
await refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Executes a same-origin fetch inside the background WebView's page context.
|
// Creates (or reuses) a background WKWebView navigated to claude.ai for JS fetch.
|
||||||
// This includes all credentials the page has (cookies, localStorage tokens, etc.),
|
private func prepareApiWebView() async {
|
||||||
// which URLSession cannot access — hence using callAsyncJavaScript instead.
|
if let wv = apiWebView, wv.url?.host?.hasSuffix("claude.ai") == true { return }
|
||||||
private func webViewFetch(_ path: String) async throws -> (statusCode: Int, body: String) {
|
let config = WKWebViewConfiguration()
|
||||||
guard let wv = apiWebView else { throw AppError.detail("no webview") }
|
config.websiteDataStore = WKWebsiteDataStore.default()
|
||||||
await waitForAPIWebViewReady()
|
let wv = WKWebView(frame: CGRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
|
||||||
let js = "const r = await fetch(path, {credentials:'include'}); return {s: r.status, b: await r.text()};"
|
apiWebView = wv
|
||||||
do {
|
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
||||||
let result = try await wv.callAsyncJavaScript(js, arguments: ["path": path], in: nil, in: .page)
|
let waiter = WKNavWaiter { cont.resume() }
|
||||||
guard let d = result as? [String: Any],
|
self.navWaiter = waiter
|
||||||
let s = (d["s"] as? NSNumber)?.intValue,
|
wv.navigationDelegate = waiter
|
||||||
let b = d["b"] as? String else {
|
wv.load(URLRequest(url: URL(string: "https://claude.ai/")!))
|
||||||
throw AppError.detail("bad result: \(String(describing: result).prefix(120))")
|
}
|
||||||
|
wv.navigationDelegate = nil
|
||||||
|
navWaiter = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs a fetch() inside the live claude.ai WebView — same browser context as the frontend,
|
||||||
|
// so no CORS/fingerprinting issues that URLSession hits.
|
||||||
|
private func jsFetch(_ path: String) async throws -> (Int, String) {
|
||||||
|
guard let wv = apiWebView else { throw AppError.networkError }
|
||||||
|
let js = """
|
||||||
|
const r = await fetch('\(path)', {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Accept': 'application/json' }
|
||||||
|
});
|
||||||
|
const body = await r.text();
|
||||||
|
return {status: r.status, body: body};
|
||||||
|
"""
|
||||||
|
let result: Any? = try await withCheckedThrowingContinuation { cont in
|
||||||
|
wv.callAsyncJavaScript(js, arguments: [:], in: nil, in: .defaultClient) { res in
|
||||||
|
switch res {
|
||||||
|
case .success(let val): cont.resume(returning: val)
|
||||||
|
case .failure(let err): cont.resume(throwing: err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return (s, b)
|
}
|
||||||
|
guard let dict = result as? [String: Any],
|
||||||
|
let status = dict["status"] as? Int,
|
||||||
|
let body = dict["body"] as? String else {
|
||||||
|
throw AppError.networkError
|
||||||
|
}
|
||||||
|
diagLastPath = path
|
||||||
|
diagLastFetch = Date()
|
||||||
|
diagLastStatus = status
|
||||||
|
diagLastBody = String(body.prefix(500))
|
||||||
|
diagLastError = status != 200 ? "JS HTTP \(status)" : ""
|
||||||
|
return (status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use JS fetch when apiWebView is ready on claude.ai, otherwise fall back to URLSession.
|
||||||
|
private func apiFetch(_ path: String) async throws -> (Int, String) {
|
||||||
|
if let wv = apiWebView, wv.url?.host?.hasSuffix("claude.ai") == true {
|
||||||
|
return try await jsFetch(path)
|
||||||
|
}
|
||||||
|
return try await urlFetch(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetches an API path via URLSession with browser-like headers.
|
||||||
|
// Claude.ai's API requires sec-fetch-*, Origin, Referer, and a browser User-Agent
|
||||||
|
// to avoid 401 — matching how the Windows version (HttpClient) handles this.
|
||||||
|
private func urlFetch(_ path: String) async throws -> (Int, String) {
|
||||||
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||||
|
let claudeCookies = cookies.filter {
|
||||||
|
$0.domain.contains("claude.ai") || $0.domain.contains("anthropic.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record cookie diagnostics
|
||||||
|
diagCookieCount = cookies.count
|
||||||
|
diagClaudeCookieCount = claudeCookies.count
|
||||||
|
diagCookieDomains = Array(Set(cookies.map { $0.domain })).sorted()
|
||||||
|
diagLastPath = path
|
||||||
|
diagLastFetch = Date()
|
||||||
|
diagLastError = ""
|
||||||
|
|
||||||
|
guard !claudeCookies.isEmpty else {
|
||||||
|
let msg = "No claude.ai cookies (\(cookies.count) total in store)"
|
||||||
|
diagLastError = msg
|
||||||
|
throw AppError.detail(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
var req = URLRequest(url: URL(string: "https://claude.ai\(path)")!)
|
||||||
|
req.httpShouldHandleCookies = false
|
||||||
|
// Build cookie header manually (same format as Windows HttpClient) to avoid
|
||||||
|
// any encoding differences from HTTPCookie.requestHeaderFields(with:)
|
||||||
|
let cookieHeader = claudeCookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
|
||||||
|
req.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
req.setValue("https://claude.ai", forHTTPHeaderField: "Origin")
|
||||||
|
req.setValue("https://claude.ai/", forHTTPHeaderField: "Referer")
|
||||||
|
req.setValue("empty", forHTTPHeaderField: "sec-fetch-dest")
|
||||||
|
req.setValue("cors", forHTTPHeaderField: "sec-fetch-mode")
|
||||||
|
req.setValue("same-origin", forHTTPHeaderField: "sec-fetch-site")
|
||||||
|
req.setValue(
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||||
|
forHTTPHeaderField: "User-Agent")
|
||||||
|
req.setValue(
|
||||||
|
"\"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\", \"Not-A.Brand\";v=\"99\"",
|
||||||
|
forHTTPHeaderField: "sec-ch-ua")
|
||||||
|
req.setValue("?0", forHTTPHeaderField: "sec-ch-ua-mobile")
|
||||||
|
req.setValue("\"macOS\"", forHTTPHeaderField: "sec-ch-ua-platform")
|
||||||
|
req.setValue("web", forHTTPHeaderField: "anthropic-client-platform")
|
||||||
|
|
||||||
|
let session = URLSession(configuration: .ephemeral)
|
||||||
|
do {
|
||||||
|
let (data, response) = try await session.data(for: req)
|
||||||
|
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
|
||||||
|
let body = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
diagLastStatus = http.statusCode
|
||||||
|
diagLastBody = String(body.prefix(500))
|
||||||
|
if http.statusCode != 200 {
|
||||||
|
diagLastError = "HTTP \(http.statusCode)"
|
||||||
|
}
|
||||||
|
return (http.statusCode, body)
|
||||||
} catch let e as AppError { throw e }
|
} catch let e as AppError { throw e }
|
||||||
catch {
|
catch {
|
||||||
let loc = wv.url?.absoluteString ?? "?"
|
let msg = error.localizedDescription
|
||||||
throw AppError.detail("JS err @ \(loc): \(error.localizedDescription.prefix(100))")
|
diagLastError = msg
|
||||||
|
throw AppError.detail(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func checkInitialSignInState() async {
|
private func checkInitialSignInState() async {
|
||||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||||
if !cookies.isEmpty { isSignedIn = true }
|
guard cookies.contains(where: { $0.domain.contains("claude.ai") }) else { return }
|
||||||
|
isSignedIn = true
|
||||||
|
await prepareApiWebView()
|
||||||
|
await refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
func signOut() async {
|
func signOut() async {
|
||||||
@@ -146,6 +207,7 @@ class UsageViewModel: ObservableObject {
|
|||||||
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") || $0.displayName.contains("anthropic.com") }
|
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") || $0.displayName.contains("anthropic.com") }
|
||||||
await store.removeData(ofTypes: types, for: claudeRecords)
|
await store.removeData(ofTypes: types, for: claudeRecords)
|
||||||
UserDefaults.standard.removeObject(forKey: "claude_org_id")
|
UserDefaults.standard.removeObject(forKey: "claude_org_id")
|
||||||
|
apiWebView = nil
|
||||||
isSignedIn = false
|
isSignedIn = false
|
||||||
isNotAuthenticated = true
|
isNotAuthenticated = true
|
||||||
lastUpdated = nil
|
lastUpdated = nil
|
||||||
@@ -154,8 +216,6 @@ class UsageViewModel: ObservableObject {
|
|||||||
extraUsage = nil
|
extraUsage = nil
|
||||||
prepaidCredits = nil
|
prepaidCredits = nil
|
||||||
overageSpendLimit = nil
|
overageSpendLimit = nil
|
||||||
apiWebViewLoaded = false
|
|
||||||
apiWebView?.load(URLRequest(url: URL(string: "https://claude.ai")!))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func refresh() async {
|
func refresh() async {
|
||||||
@@ -185,8 +245,8 @@ class UsageViewModel: ObservableObject {
|
|||||||
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
|
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
|
||||||
let (usage, prepaid, overage) = try await (usageFetch, prepaidFetch, overageFetch)
|
let (usage, prepaid, overage) = try await (usageFetch, prepaidFetch, overageFetch)
|
||||||
limits = buildLimits(from: usage)
|
limits = buildLimits(from: usage)
|
||||||
extraUsage = usage.extraUsage
|
extraUsage = usage.extraUsage
|
||||||
prepaidCredits = prepaid
|
prepaidCredits = prepaid
|
||||||
overageSpendLimit = overage
|
overageSpendLimit = overage
|
||||||
lastUpdated = Date()
|
lastUpdated = Date()
|
||||||
isSignedIn = true
|
isSignedIn = true
|
||||||
@@ -224,9 +284,14 @@ class UsageViewModel: ObservableObject {
|
|||||||
// MARK: - Bootstrap
|
// MARK: - Bootstrap
|
||||||
|
|
||||||
private func fetchBootstrap() async throws -> (orgId: String?, email: String?, planLabel: String?) {
|
private func fetchBootstrap() async throws -> (orgId: String?, email: String?, planLabel: String?) {
|
||||||
let (status, body) = try await webViewFetch("/api/bootstrap")
|
let (status, body) = try await apiFetch("/api/bootstrap")
|
||||||
if status == 401 || status == 403 { throw AppError.notAuthenticated }
|
if status == 401 || status == 403 {
|
||||||
guard status == 200 else { throw AppError.networkError }
|
throw AppError.detail("HTTP \(status) — \(body.prefix(120))")
|
||||||
|
}
|
||||||
|
guard status == 200 else { throw AppError.detail("HTTP \(status): \(body.prefix(80))") }
|
||||||
|
guard body.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") else {
|
||||||
|
throw AppError.detail("Non-JSON response (HTML?): \(body.prefix(80))")
|
||||||
|
}
|
||||||
guard let data = body.data(using: .utf8),
|
guard let data = body.data(using: .utf8),
|
||||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
return (nil, nil, nil)
|
return (nil, nil, nil)
|
||||||
@@ -241,7 +306,7 @@ class UsageViewModel: ObservableObject {
|
|||||||
orgId = (json["organizations"] as? [[String: Any]])?.first?["uuid"] as? String
|
orgId = (json["organizations"] as? [[String: Any]])?.first?["uuid"] as? String
|
||||||
}
|
}
|
||||||
if orgId == nil {
|
if orgId == nil {
|
||||||
if let (orgsStatus, orgsBody) = try? await webViewFetch("/api/organizations"),
|
if let (orgsStatus, orgsBody) = try? await apiFetch("/api/organizations"),
|
||||||
orgsStatus == 200,
|
orgsStatus == 200,
|
||||||
let orgsData = orgsBody.data(using: .utf8),
|
let orgsData = orgsBody.data(using: .utf8),
|
||||||
let orgs = try? JSONSerialization.jsonObject(with: orgsData) as? [[String: Any]] {
|
let orgs = try? JSONSerialization.jsonObject(with: orgsData) as? [[String: Any]] {
|
||||||
@@ -264,22 +329,22 @@ class UsageViewModel: ObservableObject {
|
|||||||
// MARK: - Fetch usage
|
// MARK: - Fetch usage
|
||||||
|
|
||||||
private func fetchUsage(orgId: String) async throws -> UsageResponse {
|
private func fetchUsage(orgId: String) async throws -> UsageResponse {
|
||||||
let (status, body) = try await webViewFetch("/api/organizations/\(orgId)/usage")
|
let (status, body) = try await apiFetch("/api/organizations/\(orgId)/usage")
|
||||||
if status == 401 || status == 403 { throw AppError.notAuthenticated }
|
if status == 401 || status == 403 { throw AppError.detail("usage \(status): \(body.prefix(200))") }
|
||||||
guard status == 200 else { throw AppError.networkError }
|
guard status == 200 else { throw AppError.networkError }
|
||||||
guard let data = body.data(using: .utf8) else { throw AppError.networkError }
|
guard let data = body.data(using: .utf8) else { throw AppError.networkError }
|
||||||
return try JSONDecoder().decode(UsageResponse.self, from: data)
|
return try JSONDecoder().decode(UsageResponse.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
|
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
|
||||||
guard let (status, body) = try? await webViewFetch("/api/organizations/\(orgId)/prepaid/credits"),
|
guard let (status, body) = try? await apiFetch("/api/organizations/\(orgId)/prepaid/credits"),
|
||||||
status == 200,
|
status == 200,
|
||||||
let data = body.data(using: .utf8) else { return nil }
|
let data = body.data(using: .utf8) else { return nil }
|
||||||
return try? JSONDecoder().decode(PrepaidCredits.self, from: data)
|
return try? JSONDecoder().decode(PrepaidCredits.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? {
|
private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? {
|
||||||
guard let (status, body) = try? await webViewFetch("/api/organizations/\(orgId)/overage_spend_limit"),
|
guard let (status, body) = try? await apiFetch("/api/organizations/\(orgId)/overage_spend_limit"),
|
||||||
status == 200,
|
status == 200,
|
||||||
let data = body.data(using: .utf8) else { return nil }
|
let data = body.data(using: .utf8) else { return nil }
|
||||||
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
|
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
|
||||||
@@ -398,20 +463,16 @@ class UsageViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - API WebView Delegate
|
|
||||||
|
|
||||||
private class APIWebViewDelegate: NSObject, WKNavigationDelegate {
|
// Navigation delegate that resolves a continuation on first finish/error (idempotent).
|
||||||
var onNavigationEnd: (() -> Void)?
|
private class WKNavWaiter: NSObject, WKNavigationDelegate {
|
||||||
|
private var done = false
|
||||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
private let onDone: () -> Void
|
||||||
onNavigationEnd?()
|
init(_ onDone: @escaping () -> Void) { self.onDone = onDone }
|
||||||
}
|
private func finish() { guard !done else { return }; done = true; onDone() }
|
||||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { finish() }
|
||||||
onNavigationEnd?()
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { finish() }
|
||||||
}
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { finish() }
|
||||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
|
||||||
onNavigationEnd?()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AppError: LocalizedError {
|
enum AppError: LocalizedError {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
[](https://swift.org)
|
[](https://swift.org)
|
||||||
[](https://github.com/superdooper86/claudechecker/releases)
|
[](https://github.com/superdooper86/claudechecker/releases)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.16) <!-- BETA_BADGE -->
|
[](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.22) <!-- BETA_BADGE -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+12
-6
@@ -1,14 +1,20 @@
|
|||||||
|
## What's new in v1.2.1-beta.18
|
||||||
|
|
||||||
|
### Bug fixes
|
||||||
|
- Replaced callAsyncJavaScript fetch approach with WebKit navigation: instead of running `fetch()` in the page's JS context (which was returning 401 because it bypasses the SPA's auth interceptors), each API call now navigates the WebView to the API URL directly. WebKit sends full browser headers and cookies automatically at the HTTP layer, the same way a real browser navigation works. This is more reliable regardless of what server-side auth mechanism claude.ai uses.
|
||||||
|
- API calls are now sequential to share a single WebView for all navigations
|
||||||
|
- Added redirect detection: if WebKit follows a 302 to /login, the response is treated as an auth failure rather than returning HTML to the JSON parser
|
||||||
|
|
||||||
## What's new in v1.2.1-beta.17
|
## What's new in v1.2.1-beta.17
|
||||||
|
|
||||||
### Bug fixes
|
### Bug fixes
|
||||||
- Fixed "Not signed in" after login by adopting the login WebView directly for API calls — the login WebView is proven-authenticated (user just completed sign-in in it), so reusing it eliminates the problem where a separately-loaded background WebView might not have the full auth context (localStorage tokens, Service Worker state) that claude.ai requires
|
- Fixed "Not signed in" after login by adopting the login WebView directly for API calls
|
||||||
- Added diagnostic error messages: JS errors, WebView URL, and unexpected response types are now surfaced in the error banner to aid future debugging
|
- Added diagnostic error messages for JS errors and unexpected responses
|
||||||
|
|
||||||
## What's new in v1.2.1
|
## What's new in v1.2.1
|
||||||
|
|
||||||
### Bug fixes
|
### Bug fixes
|
||||||
- Fixed the root cause of "Not signed in" after being clearly signed in: claude.ai's auth requires credentials beyond plain HTTP cookies (localStorage tokens, Service Worker state, etc.) that URLSession cannot access. All API calls now run via callAsyncJavaScript inside a background WKWebView, using the same fetch path the page itself uses — credentials are included automatically.
|
- Fixed WebKit suspending the background WKWebView: anchored in a transparent 1×1 NSWindow
|
||||||
- Fixed WebKit suspending the background WKWebView: a WKWebView with no window is throttled/suspended by macOS, preventing JS execution. The background WebView is now anchored in a transparent 1×1 NSWindow, keeping it active.
|
- Fixed login window auto-closing before sign-in completes
|
||||||
- Fixed login window auto-closing before sign-in completes — login window loads `/login` and only detects auth when back on claude.ai (not on OAuth provider redirects)
|
- Added /api/organizations as a final fallback for org ID resolution
|
||||||
- Added `/api/organizations` as a final fallback for org ID resolution
|
|
||||||
- Fixed Settings incorrectly showing "Signed in" after a failed refresh
|
- Fixed Settings incorrectly showing "Signed in" after a failed refresh
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.2.1-beta.16",
|
"version": "1.2.1-beta.22",
|
||||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.16/ClaudeChecker.zip",
|
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.22/ClaudeChecker.zip",
|
||||||
"notes": "## What's new in v1.2.1\n\n### Bug fixes\n- Fixed the root cause of \"Not signed in\" after being clearly signed in: claude.ai's auth requires credentials beyond plain HTTP cookies (localStorage tokens, Service Worker state, etc.) that URLSession cannot access. All API calls now run via callAsyncJavaScript inside a background WKWebView, using the same fetch path the page itself uses — credentials are included automatically.\n- Fixed WebKit suspending the background WKWebView: a WKWebView with no window is throttled/suspended by macOS, preventing JS execution. The background WebView is now anchored in a transparent 1×1 NSWindow, keeping it active.\n- Fixed login window auto-closing before sign-in completes — login window loads `/login` and only detects auth when back on claude.ai (not on OAuth provider redirects)\n- Added `/api/organizations` as a final fallback for org ID resolution\n- Fixed Settings incorrectly showing \"Signed in\" after a failed refresh"
|
"notes": "## What's new in v1.2.1-beta.18\n\n### Bug fixes\n- Replaced callAsyncJavaScript fetch approach with WebKit navigation: instead of running `fetch()` in the page's JS context (which was returning 401 because it bypasses the SPA's auth interceptors), each API call now navigates the WebView to the API URL directly. WebKit sends full browser headers and cookies automatically at the HTTP layer, the same way a real browser navigation works. This is more reliable regardless of what server-side auth mechanism claude.ai uses.\n- API calls are now sequential to share a single WebView for all navigations\n- Added redirect detection: if WebKit follows a 302 to /login, the response is treated as an auth failure rather than returning HTML to the JSON parser\n\n## What's new in v1.2.1-beta.17\n\n### Bug fixes\n- Fixed \"Not signed in\" after login by adopting the login WebView directly for API calls\n- Added diagnostic error messages for JS errors and unexpected responses\n\n## What's new in v1.2.1\n\n### Bug fixes\n- Fixed WebKit suspending the background WKWebView: anchored in a transparent 1×1 NSWindow\n- Fixed login window auto-closing before sign-in completes\n- Added /api/organizations as a final fallback for org ID resolution\n- Fixed Settings incorrectly showing \"Signed in\" after a failed refresh"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user