Compare commits
8
Commits
v1.2.1-beta.22
...
v1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
206e97780d | ||
|
|
2b79ba4ae0 | ||
|
|
60b6c60c0a | ||
|
|
8140831236 | ||
|
|
9ed8916075 | ||
|
|
2ab2d913ce | ||
|
|
30ad89e458 | ||
|
|
3e4bfca2df |
@@ -48,6 +48,7 @@ struct DiagnosticsView: View {
|
|||||||
DiagRow("Signed in", vm.isSignedIn ? "Yes" : "No")
|
DiagRow("Signed in", vm.isSignedIn ? "Yes" : "No")
|
||||||
DiagRow("Email", vm.userEmail.isEmpty ? "(none)" : vm.userEmail)
|
DiagRow("Email", vm.userEmail.isEmpty ? "(none)" : vm.userEmail)
|
||||||
DiagRow("Org ID", UserDefaults.standard.string(forKey: "claude_org_id") ?? "(none)")
|
DiagRow("Org ID", UserDefaults.standard.string(forKey: "claude_org_id") ?? "(none)")
|
||||||
|
DiagRow("lastActiveOrg", vm.diagLastActiveOrg.isEmpty ? "(not read)" : vm.diagLastActiveOrg)
|
||||||
DiagRow("Error", vm.errorMessage ?? "(none)")
|
DiagRow("Error", vm.errorMessage ?? "(none)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,9 +66,24 @@ struct DiagnosticsView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
// Bootstrap body
|
||||||
|
if !vm.diagBootstrapBody.isEmpty {
|
||||||
|
DiagSection(title: "Bootstrap Response (first 500 chars)") {
|
||||||
|
Text(vm.diagBootstrapBody)
|
||||||
|
.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()
|
||||||
|
}
|
||||||
|
|
||||||
// Response body
|
// Response body
|
||||||
if !vm.diagLastBody.isEmpty {
|
if !vm.diagLastBody.isEmpty {
|
||||||
DiagSection(title: "Response Body (first 500 chars)") {
|
DiagSection(title: "Last API Response (first 500 chars)") {
|
||||||
Text(vm.diagLastBody)
|
Text(vm.diagLastBody)
|
||||||
.font(.system(size: 10, design: .monospaced))
|
.font(.system(size: 10, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
@@ -164,6 +180,7 @@ struct DiagnosticsView: View {
|
|||||||
lines.append("Signed in: \(vm.isSignedIn ? "Yes" : "No")")
|
lines.append("Signed in: \(vm.isSignedIn ? "Yes" : "No")")
|
||||||
lines.append("Email: \(vm.userEmail.isEmpty ? "(none)" : vm.userEmail)")
|
lines.append("Email: \(vm.userEmail.isEmpty ? "(none)" : vm.userEmail)")
|
||||||
lines.append("Org ID: \(UserDefaults.standard.string(forKey: "claude_org_id") ?? "(none)")")
|
lines.append("Org ID: \(UserDefaults.standard.string(forKey: "claude_org_id") ?? "(none)")")
|
||||||
|
lines.append("lastActiveOrg: \(vm.diagLastActiveOrg.isEmpty ? "(not read)" : vm.diagLastActiveOrg)")
|
||||||
lines.append("Error: \(vm.errorMessage ?? "(none)")")
|
lines.append("Error: \(vm.errorMessage ?? "(none)")")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("Last path: \(vm.diagLastPath)")
|
lines.append("Last path: \(vm.diagLastPath)")
|
||||||
@@ -173,7 +190,10 @@ struct DiagnosticsView: View {
|
|||||||
lines.append("Claude cookies: \(vm.diagClaudeCookieCount)")
|
lines.append("Claude cookies: \(vm.diagClaudeCookieCount)")
|
||||||
lines.append("Domains: \(vm.diagCookieDomains.joined(separator: ", "))")
|
lines.append("Domains: \(vm.diagCookieDomains.joined(separator: ", "))")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("Response body:")
|
lines.append("Bootstrap body:")
|
||||||
|
lines.append(vm.diagBootstrapBody)
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Last API response:")
|
||||||
lines.append(vm.diagLastBody)
|
lines.append(vm.diagLastBody)
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("Live cookies:")
|
lines.append("Live cookies:")
|
||||||
|
|||||||
@@ -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.22</string>
|
<string>1.3.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>70</string>
|
<string>74</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>13.0</string>
|
<string>13.0</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ 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>] = [:]
|
||||||
|
|
||||||
// Diagnostics — populated on every urlFetch call
|
// WebView used for JS-based API calls (avoids URLSession 403 fingerprinting issues)
|
||||||
|
private var apiWebView: WKWebView?
|
||||||
|
private var navWaiter: WKNavWaiter?
|
||||||
|
|
||||||
|
// Diagnostics
|
||||||
@Published var diagCookieCount: Int = 0
|
@Published var diagCookieCount: Int = 0
|
||||||
@Published var diagClaudeCookieCount: Int = 0
|
@Published var diagClaudeCookieCount: Int = 0
|
||||||
@Published var diagCookieDomains: [String] = []
|
@Published var diagCookieDomains: [String] = []
|
||||||
@@ -38,6 +42,8 @@ class UsageViewModel: ObservableObject {
|
|||||||
@Published var diagLastStatus: Int = 0
|
@Published var diagLastStatus: Int = 0
|
||||||
@Published var diagLastBody: String = ""
|
@Published var diagLastBody: String = ""
|
||||||
@Published var diagLastError: String = ""
|
@Published var diagLastError: String = ""
|
||||||
|
@Published var diagLastActiveOrg: String = "" // lastActiveOrg cookie value from JS
|
||||||
|
@Published var diagBootstrapBody: String = "" // raw bootstrap response (first 500 chars)
|
||||||
@Published var diagLastFetch: Date? = nil
|
@Published var diagLastFetch: Date? = nil
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@@ -51,17 +57,79 @@ class UsageViewModel: ObservableObject {
|
|||||||
Task { await checkInitialSignInState() }
|
Task { await checkInitialSignInState() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called after login: poll until session cookies appear (they may commit slightly
|
// Called after login: store the webview (already on claude.ai) so jsFetch can use it.
|
||||||
// after the auth redirect fires), then refresh.
|
|
||||||
func adoptAndRefresh(_ loginWebView: WKWebView) async {
|
func adoptAndRefresh(_ loginWebView: WKWebView) async {
|
||||||
for _ in 0..<30 {
|
for _ in 0..<30 {
|
||||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||||
if cookies.contains(where: { $0.domain.contains("claude.ai") }) { break }
|
if cookies.contains(where: { $0.domain.contains("claude.ai") }) { break }
|
||||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||||
}
|
}
|
||||||
|
if loginWebView.url?.host?.hasSuffix("claude.ai") == true {
|
||||||
|
apiWebView = loginWebView
|
||||||
|
} else {
|
||||||
|
await prepareApiWebView()
|
||||||
|
}
|
||||||
await refresh()
|
await refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Creates (or reuses) a background WKWebView navigated to claude.ai for JS fetch.
|
||||||
|
private func prepareApiWebView() async {
|
||||||
|
if let wv = apiWebView, wv.url?.host?.hasSuffix("claude.ai") == true { return }
|
||||||
|
let config = WKWebViewConfiguration()
|
||||||
|
config.websiteDataStore = WKWebsiteDataStore.default()
|
||||||
|
let wv = WKWebView(frame: CGRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
|
||||||
|
apiWebView = wv
|
||||||
|
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
||||||
|
let waiter = WKNavWaiter { cont.resume() }
|
||||||
|
self.navWaiter = waiter
|
||||||
|
wv.navigationDelegate = waiter
|
||||||
|
wv.load(URLRequest(url: URL(string: "https://claude.ai/")!))
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.
|
// Fetches an API path via URLSession with browser-like headers.
|
||||||
// Claude.ai's API requires sec-fetch-*, Origin, Referer, and a browser User-Agent
|
// 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.
|
// to avoid 401 — matching how the Windows version (HttpClient) handles this.
|
||||||
@@ -128,7 +196,10 @@ class UsageViewModel: ObservableObject {
|
|||||||
|
|
||||||
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 {
|
||||||
@@ -138,6 +209,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
|
||||||
@@ -155,10 +227,31 @@ class UsageViewModel: ObservableObject {
|
|||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let (fetchedOrgId, fetchedEmail, fetchedPlan) = try await fetchBootstrap()
|
// Read lastActiveOrg cookie from JS — most reliable org source since it's
|
||||||
|
// set by the claude.ai frontend to whichever org is currently active.
|
||||||
|
var cookieOrgId: String? = nil
|
||||||
|
if let wv = apiWebView, wv.url?.host?.hasSuffix("claude.ai") == true {
|
||||||
|
let js = """
|
||||||
|
return document.cookie.split(';')
|
||||||
|
.map(c => c.trim().split('='))
|
||||||
|
.filter(p => p[0] === 'lastActiveOrg')
|
||||||
|
.map(p => p.slice(1).join('='))[0] || null;
|
||||||
|
"""
|
||||||
|
let raw: Any? = try? await withCheckedThrowingContinuation { cont in
|
||||||
|
wv.callAsyncJavaScript(js, arguments: [:], in: nil, in: .defaultClient) { r in
|
||||||
|
cont.resume(returning: (try? r.get()) ?? nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let v = raw as? String, !v.isEmpty {
|
||||||
|
cookieOrgId = v
|
||||||
|
diagLastActiveOrg = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (fetchedOrgId, fetchedEmail, fetchedPlan) = try await fetchBootstrap(preferredOrgId: cookieOrgId)
|
||||||
|
|
||||||
let orgId: String
|
let orgId: String
|
||||||
if let id = fetchedOrgId {
|
if let id = cookieOrgId ?? fetchedOrgId {
|
||||||
UserDefaults.standard.set(id, forKey: "claude_org_id")
|
UserDefaults.standard.set(id, forKey: "claude_org_id")
|
||||||
orgId = id
|
orgId = id
|
||||||
} else if let cached = UserDefaults.standard.string(forKey: "claude_org_id") {
|
} else if let cached = UserDefaults.standard.string(forKey: "claude_org_id") {
|
||||||
@@ -213,8 +306,8 @@ class UsageViewModel: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Bootstrap
|
// MARK: - Bootstrap
|
||||||
|
|
||||||
private func fetchBootstrap() async throws -> (orgId: String?, email: String?, planLabel: String?) {
|
private func fetchBootstrap(preferredOrgId: String? = nil) async throws -> (orgId: String?, email: String?, planLabel: String?) {
|
||||||
let (status, body) = try await urlFetch("/api/bootstrap")
|
let (status, body) = try await apiFetch("/api/bootstrap")
|
||||||
if status == 401 || status == 403 {
|
if status == 401 || status == 403 {
|
||||||
throw AppError.detail("HTTP \(status) — \(body.prefix(120))")
|
throw AppError.detail("HTTP \(status) — \(body.prefix(120))")
|
||||||
}
|
}
|
||||||
@@ -222,6 +315,7 @@ class UsageViewModel: ObservableObject {
|
|||||||
guard body.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") else {
|
guard body.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") else {
|
||||||
throw AppError.detail("Non-JSON response (HTML?): \(body.prefix(80))")
|
throw AppError.detail("Non-JSON response (HTML?): \(body.prefix(80))")
|
||||||
}
|
}
|
||||||
|
diagBootstrapBody = String(body.prefix(500))
|
||||||
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)
|
||||||
@@ -229,14 +323,15 @@ class UsageViewModel: ObservableObject {
|
|||||||
|
|
||||||
let account = json["account"] as? [String: Any]
|
let account = json["account"] as? [String: Any]
|
||||||
let memberships = (account?["memberships"] ?? json["memberships"]) as? [[String: Any]]
|
let memberships = (account?["memberships"] ?? json["memberships"]) as? [[String: Any]]
|
||||||
let firstOrg = memberships?.first?["organization"] as? [String: Any]
|
let allOrgs = memberships?.compactMap { $0["organization"] as? [String: Any] } ?? []
|
||||||
|
let firstOrg = allOrgs.first
|
||||||
|
|
||||||
var orgId: String? = firstOrg?["uuid"] as? String
|
var orgId: String? = firstOrg?["uuid"] as? String
|
||||||
if orgId == nil {
|
if orgId == nil {
|
||||||
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 urlFetch("/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]] {
|
||||||
@@ -246,8 +341,10 @@ class UsageViewModel: ObservableObject {
|
|||||||
|
|
||||||
let email = account?["email_address"] as? String
|
let email = account?["email_address"] as? String
|
||||||
|
|
||||||
|
// Read capabilities from the preferred (active) org, falling back to first org.
|
||||||
|
let capOrg = preferredOrgId.flatMap { id in allOrgs.first(where: { $0["uuid"] as? String == id }) } ?? firstOrg
|
||||||
var planLabel: String? = nil
|
var planLabel: String? = nil
|
||||||
if let caps = firstOrg?["capabilities"] as? [String],
|
if let caps = capOrg?["capabilities"] as? [String],
|
||||||
let cap = caps.first(where: { $0.hasPrefix("claude_") }) {
|
let cap = caps.first(where: { $0.hasPrefix("claude_") }) {
|
||||||
let name = String(cap.dropFirst("claude_".count))
|
let name = String(cap.dropFirst("claude_".count))
|
||||||
planLabel = name.prefix(1).uppercased() + name.dropFirst().lowercased()
|
planLabel = name.prefix(1).uppercased() + name.dropFirst().lowercased()
|
||||||
@@ -259,7 +356,7 @@ 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 urlFetch("/api/organizations/\(orgId)/usage")
|
let (status, body) = try await apiFetch("/api/organizations/\(orgId)/usage")
|
||||||
if status == 401 || status == 403 { throw AppError.detail("usage \(status): \(body.prefix(200))") }
|
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 }
|
||||||
@@ -267,14 +364,14 @@ class UsageViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
|
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
|
||||||
guard let (status, body) = try? await urlFetch("/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 urlFetch("/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)
|
||||||
@@ -394,6 +491,17 @@ class UsageViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Navigation delegate that resolves a continuation on first finish/error (idempotent).
|
||||||
|
private class WKNavWaiter: NSObject, WKNavigationDelegate {
|
||||||
|
private var done = false
|
||||||
|
private let onDone: () -> Void
|
||||||
|
init(_ onDone: @escaping () -> Void) { self.onDone = onDone }
|
||||||
|
private func finish() { guard !done else { return }; done = true; onDone() }
|
||||||
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { finish() }
|
||||||
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { finish() }
|
||||||
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { finish() }
|
||||||
|
}
|
||||||
|
|
||||||
enum AppError: LocalizedError {
|
enum AppError: LocalizedError {
|
||||||
case notAuthenticated
|
case notAuthenticated
|
||||||
case networkError
|
case networkError
|
||||||
|
|||||||
@@ -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.21) <!-- BETA_BADGE -->
|
[](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.25) <!-- BETA_BADGE -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+9
-17
@@ -1,20 +1,12 @@
|
|||||||
## What's new in v1.2.1-beta.18
|
## What's new in v1.3.0
|
||||||
|
|
||||||
### Bug fixes
|
### Multi-org support
|
||||||
- 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.
|
- App now uses the `lastActiveOrg` cookie to identify which organisation is active, matching exactly what the claude.ai frontend does. Previously the app always picked the first org returned by bootstrap, which is the personal "Individual Org" for users with multiple organisations — causing persistent 403 errors on all usage endpoints.
|
||||||
- API calls are now sequential to share a single WebView for all navigations
|
- Plan label (e.g. **Max**, **Pro**, **Team**) now reads capabilities from the active org, not the first membership.
|
||||||
- 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
|
### Reliable API access via WebView
|
||||||
|
- All API calls now run through `WKWebView.callAsyncJavaScript`, making real same-origin browser requests instead of URLSession. Claude.ai's org-specific endpoints reject native HTTP clients (403) even with correct cookies and headers; running inside the browser context passes all server-side checks.
|
||||||
|
- A background WebView is created at startup when session cookies are detected, so usage data loads immediately without requiring a manual sign-in.
|
||||||
|
|
||||||
### Bug fixes
|
### Diagnostics
|
||||||
- Fixed "Not signed in" after login by adopting the login WebView directly for API calls
|
- New **Diagnostics** panel in Settings shows cookie store, last API request/response, bootstrap body, and the `lastActiveOrg` cookie value — making it easy to report issues.
|
||||||
- Added diagnostic error messages for JS errors and unexpected responses
|
|
||||||
|
|
||||||
## What's new in v1.2.1
|
|
||||||
|
|
||||||
### Bug fixes
|
|
||||||
- Fixed WebKit suspending the background WKWebView: anchored in a transparent 1×1 NSWindow
|
|
||||||
- Fixed login window auto-closing before sign-in completes
|
|
||||||
- Added /api/organizations as a final fallback for org ID resolution
|
|
||||||
- Fixed Settings incorrectly showing "Signed in" after a failed refresh
|
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.2.1-beta.21",
|
"version": "1.2.1-beta.25",
|
||||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.21/ClaudeChecker.zip",
|
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.25/ClaudeChecker.zip",
|
||||||
"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"
|
"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