Compare commits

...
Author SHA1 Message Date
SuperDooper 91829b0ed9 beta.20: URLSession with browser headers (sec-fetch-*, Chrome UA, Origin/Referer) 2026-05-11 12:32:34 +02:00
SuperDooper caffc996ff beta.20: URLSession with browser headers (sec-fetch-*, Chrome UA, Origin/Referer) 2026-05-11 12:32:33 +02:00
github-actions[bot] cd13a6d124 Beta release v1.2.1-beta.19 2026-05-11 10:22:52 +00:00
SuperDooper 566258e9c8 beta.19: fix tuple label mismatch in APIFetchDelegate 2026-05-11 12:22:14 +02:00
SuperDooper dc2a8e2307 beta.19: fix tuple label mismatch in APIFetchDelegate 2026-05-11 12:22:13 +02:00
SuperDooper f48b53fd8e beta.18: navigate-and-read API fetch replaces callAsyncJavaScript 2026-05-11 12:20:22 +02:00
SuperDooper ccfee938b7 beta.18: navigate-and-read API fetch replaces callAsyncJavaScript 2026-05-11 12:20:21 +02:00
SuperDooper 28d952bcbd beta.18: navigate-and-read API fetch replaces callAsyncJavaScript 2026-05-11 12:20:19 +02:00
github-actions[bot] 9bc023d239 Beta release v1.2.1-beta.17 2026-05-11 10:07:03 +00:00
SuperDooper fd82177a7d beta.17: adopt login WebView for API calls, add diagnostics 2026-05-11 12:00:30 +02:00
SuperDooper 112210a99b beta.17: adopt login WebView for API calls, add diagnostics 2026-05-11 12:00:29 +02:00
SuperDooper f4a83940b1 beta.17: adopt login WebView for API calls, add diagnostics 2026-05-11 12:00:28 +02:00
SuperDooper 377888a427 beta.17: adopt login WebView for API calls, add diagnostics 2026-05-11 12:00:26 +02:00
SuperDooper e24113a78b beta.17: adopt login WebView for API calls, add diagnostics 2026-05-11 12:00:24 +02:00
github-actions[bot] 3baab91f70 Beta release v1.2.1-beta.16 2026-05-11 09:47:48 +00:00
7 changed files with 93 additions and 134 deletions
+2 -2
View File
@@ -116,8 +116,8 @@ struct ContentView: View {
showUpdateSheet = true showUpdateSheet = true
} }
.sheet(isPresented: $showLogin) { .sheet(isPresented: $showLogin) {
LoginSheetView(isPresented: $showLogin) { LoginSheetView(isPresented: $showLogin) { webView in
Task { await vm.reloadAPIWebViewAndRefresh() } Task { await vm.adoptAndRefresh(webView) }
} }
} }
.sheet(isPresented: $showUpdateSheet) { .sheet(isPresented: $showUpdateSheet) {
+2 -2
View File
@@ -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.16</string> <string>1.2.1-beta.20</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>64</string> <string>68</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>13.0</string> <string>13.0</string>
<key>LSUIElement</key> <key>LSUIElement</key>
+16 -16
View File
@@ -4,7 +4,7 @@ import WebKit
// MARK: - Login Web View // MARK: - Login Web View
struct LoginWebView: NSViewRepresentable { struct LoginWebView: NSViewRepresentable {
let onAuthenticated: () -> Void let onAuthenticated: (WKWebView) -> Void
func makeNSView(context: Context) -> WKWebView { func makeNSView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration() let config = WKWebViewConfiguration()
@@ -12,6 +12,7 @@ struct LoginWebView: NSViewRepresentable {
let webView = WKWebView(frame: .zero, configuration: config) let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator webView.navigationDelegate = context.coordinator
context.coordinator.webView = webView
// KVO on url catches SPA pushState navigations that don't fire didFinish // KVO on url catches SPA pushState navigations that don't fire didFinish
context.coordinator.urlObservation = webView.observe(\.url, options: [.new]) { [weak coordinator = context.coordinator] wv, _ in context.coordinator.urlObservation = webView.observe(\.url, options: [.new]) { [weak coordinator = context.coordinator] wv, _ in
@@ -29,23 +30,24 @@ struct LoginWebView: NSViewRepresentable {
} }
class Coordinator: NSObject, WKNavigationDelegate { class Coordinator: NSObject, WKNavigationDelegate {
let onAuthenticated: () -> Void let onAuthenticated: (WKWebView) -> Void
weak var webView: WKWebView?
var didAuthenticate = false var didAuthenticate = false
var urlObservation: NSKeyValueObservation? var urlObservation: NSKeyValueObservation?
init(onAuthenticated: @escaping () -> Void) { init(onAuthenticated: @escaping (WKWebView) -> Void) {
self.onAuthenticated = onAuthenticated self.onAuthenticated = onAuthenticated
} }
func checkCurrentURL(_ url: String?) { func checkCurrentURL(_ url: String?) {
guard !didAuthenticate, let url else { return } guard !didAuthenticate, let url, let wv = webView else { return }
// Ignore navigations to external OAuth providers (Google, etc.) // Ignore navigations to external OAuth providers (Google, etc.)
// only consider auth complete when we land back on claude.ai/anthropic.com // only consider auth complete when we land back on claude.ai/anthropic.com
guard url.contains("claude.ai") || url.contains("anthropic.com") else { return } guard url.contains("claude.ai") || url.contains("anthropic.com") else { return }
if url.contains("/login") || url.contains("/auth") { return } if url.contains("/login") || url.contains("/auth") { return }
didAuthenticate = true didAuthenticate = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.onAuthenticated() self.onAuthenticated(wv)
} }
} }
@@ -60,7 +62,7 @@ struct LoginWebView: NSViewRepresentable {
struct LoginSheetView: View { struct LoginSheetView: View {
@Binding var isPresented: Bool @Binding var isPresented: Bool
let onDone: () -> Void let onDone: (WKWebView) -> Void
@State private var authenticated = false @State private var authenticated = false
var body: some View { var body: some View {
@@ -70,12 +72,9 @@ struct LoginSheetView: View {
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
Spacer() Spacer()
if authenticated { if authenticated {
Button("Done") { Button("Done") { isPresented = false }
isPresented = false .buttonStyle(.borderedProminent)
onDone() .controlSize(.small)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
} else { } else {
Button("Cancel") { isPresented = false } Button("Cancel") { isPresented = false }
.buttonStyle(.bordered) .buttonStyle(.bordered)
@@ -88,12 +87,13 @@ struct LoginSheetView: View {
Divider() Divider()
LoginWebView { LoginWebView { webView in
authenticated = true authenticated = true
// Auto-dismiss and refresh after brief delay // Adopt the authenticated WebView immediately (before sheet tears it down),
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { // then auto-dismiss after a moment so the user sees confirmation.
onDone(webView)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
isPresented = false isPresented = false
onDone()
} }
} }
} }
+53 -106
View File
@@ -30,15 +30,6 @@ 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.
// 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 apiDelegate: APIWebViewDelegate?
private var apiWindow: NSWindow?
private var apiWebViewLoaded = false
private var apiReadyContinuations: [CheckedContinuation<Void, Never>] = []
init() { init() {
let saved = UserDefaults.standard.double(forKey: "refresh_interval") let saved = UserDefaults.standard.double(forKey: "refresh_interval")
refreshInterval = saved > 0 ? saved : 60 refreshInterval = saved > 0 ? saved : 60
@@ -47,83 +38,50 @@ class UsageViewModel: ObservableObject {
burnHistoryStore = saved burnHistoryStore = saved
} }
loadPlaceholderData() loadPlaceholderData()
setupAPIWebView()
Task { await checkInitialSignInState() } Task { await checkInitialSignInState() }
} }
// MARK: - Background API WebView // Called after login: cookies are already in WKWebsiteDataStore.default()
// brief sleep lets the store commit, then refresh uses URLSession.
private func setupAPIWebView() { func adoptAndRefresh(_ loginWebView: WKWebView) async {
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: reloads the background WebView to pick up the new session.
func reloadAPIWebViewAndRefresh() async {
guard let wv = apiWebView else {
await refresh()
return
}
apiWebViewLoaded = false
wv.load(URLRequest(url: URL(string: "https://claude.ai")!))
await waitForAPIWebViewReady()
try? await Task.sleep(nanoseconds: 500_000_000) try? await Task.sleep(nanoseconds: 500_000_000)
await refresh() await refresh()
} }
// Executes a same-origin fetch inside the background WebView's page context. // Fetches an API path via URLSession with browser-like headers.
// This includes all credentials the page has (cookies, localStorage tokens, etc.), // Claude.ai's API requires sec-fetch-*, Origin, Referer, and a browser User-Agent
// which URLSession cannot access hence using callAsyncJavaScript instead. // to avoid 401 matching how the Windows version (HttpClient) handles this.
private func webViewFetch(_ path: String) async throws -> (statusCode: Int, body: String) { private func urlFetch(_ path: String) async throws -> (Int, String) {
guard let wv = apiWebView else { throw AppError.networkError } let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
await waitForAPIWebViewReady() let claudeCookies = cookies.filter {
let js = "const r = await fetch(path, {credentials:'include'}); return {s: r.status, b: await r.text()};" $0.domain.contains("claude.ai") || $0.domain.contains("anthropic.com")
let result = try await wv.callAsyncJavaScript( }
js, arguments: ["path": path], in: nil, in: .page) guard !claudeCookies.isEmpty else { throw AppError.notAuthenticated }
guard let d = result as? [String: Any],
let s = (d["s"] as? NSNumber)?.intValue, var req = URLRequest(url: URL(string: "https://claude.ai\(path)")!)
let b = d["b"] as? String else { throw AppError.networkError } req.httpShouldHandleCookies = false
return (s, b) if let cookieHeader = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
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")
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
let body = String(data: data, encoding: .utf8) ?? ""
return (http.statusCode, body)
} }
private func checkInitialSignInState() async { private func checkInitialSignInState() async {
@@ -146,8 +104,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 {
@@ -177,8 +133,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
@@ -216,9 +172,13 @@ 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 urlFetch("/api/bootstrap")
if status == 401 || status == 403 { throw AppError.notAuthenticated } if status == 401 || status == 403 { throw AppError.notAuthenticated }
guard status == 200 else { throw AppError.networkError } guard status == 200 else { throw AppError.detail("bootstrap \(status): \(body.prefix(80))") }
// If WebKit followed a redirect to the login page we get HTML instead of JSON.
guard body.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") else {
throw AppError.notAuthenticated
}
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)
@@ -233,7 +193,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 urlFetch("/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]] {
@@ -256,7 +216,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 webViewFetch("/api/organizations/\(orgId)/usage") let (status, body) = try await urlFetch("/api/organizations/\(orgId)/usage")
if status == 401 || status == 403 { throw AppError.notAuthenticated } if status == 401 || status == 403 { throw AppError.notAuthenticated }
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 }
@@ -264,14 +224,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 webViewFetch("/api/organizations/\(orgId)/prepaid/credits"), guard let (status, body) = try? await urlFetch("/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 urlFetch("/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)
@@ -390,29 +350,16 @@ class UsageViewModel: ObservableObject {
} }
} }
// MARK: - API WebView Delegate
private class APIWebViewDelegate: NSObject, WKNavigationDelegate {
var onNavigationEnd: (() -> Void)?
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
onNavigationEnd?()
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
onNavigationEnd?()
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
onNavigationEnd?()
}
}
enum AppError: LocalizedError { enum AppError: LocalizedError {
case notAuthenticated case notAuthenticated
case networkError case networkError
case detail(String)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first." case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
case .networkError: return "Network error fetching usage data." case .networkError: return "Network error fetching usage data."
case .detail(let msg): return msg
} }
} }
} }
+1 -1
View File
@@ -10,7 +10,7 @@
[![Swift](https://img.shields.io/badge/Swift-5.9-F05138?style=flat&logo=swift&logoColor=white)](https://swift.org) [![Swift](https://img.shields.io/badge/Swift-5.9-F05138?style=flat&logo=swift&logoColor=white)](https://swift.org)
[![Version](https://img.shields.io/badge/version-1.2.0-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases) [![Version](https://img.shields.io/badge/version-1.2.0-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases)
[![License](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE) [![License](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE)
[![Beta](https://img.shields.io/badge/beta-1.2.1--beta.15-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.15) <!-- BETA_BADGE --> [![Beta](https://img.shields.io/badge/beta-1.2.1--beta.19-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.19) <!-- BETA_BADGE -->
</div> </div>
+16 -4
View File
@@ -1,8 +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
### Bug fixes
- Fixed "Not signed in" after login by adopting the login WebView directly for API calls
- 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
View File
@@ -1,5 +1,5 @@
{ {
"version": "1.2.1-beta.15", "version": "1.2.1-beta.19",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.15/ClaudeChecker.zip", "url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.19/ClaudeChecker.zip",
"notes": "## What's new in v1.2.1\n\n### Bug fixes\n- Fixed the root cause of \"Not signed in\" errors: URLSession was silently discarding the manually-set Cookie header because `httpShouldHandleCookies` defaults to `true`, which makes URLSession replace it with its own (empty) HTTPCookieStorage — claude.ai session cookies live in WKWebsiteDataStore, not HTTPCookieStorage. Setting `httpShouldHandleCookies = false` ensures the cookies are actually sent.\n- Added browser-like request headers (User-Agent, Origin, Referer) matching what Claude's API expects, consistent with the working Windows implementation\n- Removed background WKWebView complexity added in beta.1213 — reverted to simple URLSession approach with correct cookie handling\n- Fixed login window auto-closing before the user could sign in — login window loads `/login` so auth is only detected after the actual sign-in redirect\n- Fixed login detection for Next.js SPA navigation using KVO on WebView URL (history.pushState doesn't trigger didFinish)\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"
} }