release 1.3.2: anchor primer WebView in hidden window for reliable startup fetch
Every launch now loads claude.ai in a real (off-screen, invisible) NSWindow so WebKit doesn't throttle JS execution. The nav delegate fires adoptPrimerWebView once the page loads, replacing the old 1.5 s sleep-then-refresh approach.
This commit is contained in:
@@ -18,16 +18,43 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
var updateManager = UpdateManager()
|
var updateManager = UpdateManager()
|
||||||
var refreshTimer: Timer?
|
var refreshTimer: Timer?
|
||||||
var cookiePrimerView: WKWebView?
|
var cookiePrimerView: WKWebView?
|
||||||
|
var cookiePrimerWindow: NSWindow?
|
||||||
|
var primerNavDelegate: PrimerNavDelegate?
|
||||||
var cancellables = Set<AnyCancellable>()
|
var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
NSApp.setActivationPolicy(.accessory)
|
NSApp.setActivationPolicy(.accessory)
|
||||||
|
|
||||||
// Hidden WKWebView to prime the shared cookie store
|
// Background WebView that loads claude.ai on every launch.
|
||||||
|
// Anchored in a real (off-screen, invisible) NSWindow so WebKit doesn't throttle
|
||||||
|
// JS execution. When the page finishes loading the nav delegate calls
|
||||||
|
// adoptPrimerWebView, which sets it as the API WebView and runs the first fetch.
|
||||||
let config = WKWebViewConfiguration()
|
let config = WKWebViewConfiguration()
|
||||||
config.websiteDataStore = WKWebsiteDataStore.default()
|
config.websiteDataStore = WKWebsiteDataStore.default()
|
||||||
cookiePrimerView = WKWebView(frame: .zero, configuration: config)
|
let wv = WKWebView(frame: CGRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
|
||||||
cookiePrimerView?.load(URLRequest(url: URL(string: "https://claude.ai")!))
|
let win = NSWindow(
|
||||||
|
contentRect: NSRect(x: -9999, y: -9999, width: 1, height: 1),
|
||||||
|
styleMask: [],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
win.contentView = wv
|
||||||
|
win.alphaValue = 0
|
||||||
|
win.orderFront(nil)
|
||||||
|
cookiePrimerWindow = win
|
||||||
|
cookiePrimerView = wv
|
||||||
|
|
||||||
|
let delegate = PrimerNavDelegate { [weak self] in
|
||||||
|
guard let self, let wv = self.cookiePrimerView else { return }
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
await self.usageViewModel.adoptPrimerWebView(wv)
|
||||||
|
await self.updateManager.checkForUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
primerNavDelegate = delegate
|
||||||
|
wv.navigationDelegate = delegate
|
||||||
|
wv.load(URLRequest(url: URL(string: "https://claude.ai")!))
|
||||||
|
|
||||||
// Status item
|
// Status item
|
||||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||||
@@ -134,12 +161,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial fetch after cookie primer + update check
|
|
||||||
Task {
|
|
||||||
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
|
||||||
await usageViewModel.refresh()
|
|
||||||
await updateManager.checkForUpdates()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setMenubarIcon(button: NSStatusBarButton) {
|
private func setMenubarIcon(button: NSStatusBarButton) {
|
||||||
@@ -240,4 +261,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-shot WKNavigationDelegate: fires onDone on first finish or error, then goes silent.
|
||||||
|
final class PrimerNavDelegate: 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() }
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.3.1</string>
|
<string>1.3.2</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>75</string>
|
<string>76</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>13.0</string>
|
<string>13.0</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ class UsageViewModel: ObservableObject {
|
|||||||
|
|
||||||
// WebView used for JS-based API calls (avoids URLSession 403 fingerprinting issues)
|
// WebView used for JS-based API calls (avoids URLSession 403 fingerprinting issues)
|
||||||
private var apiWebView: WKWebView?
|
private var apiWebView: WKWebView?
|
||||||
private var navWaiter: WKNavWaiter?
|
|
||||||
|
|
||||||
// Diagnostics
|
// Diagnostics
|
||||||
@Published var diagCookieCount: Int = 0
|
@Published var diagCookieCount: Int = 0
|
||||||
@@ -55,38 +54,24 @@ class UsageViewModel: ObservableObject {
|
|||||||
Task { await checkInitialSignInState() }
|
Task { await checkInitialSignInState() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called after login: store the webview (already on claude.ai) so jsFetch can use it.
|
// Called by AppDelegate once the primer WebView has loaded claude.ai.
|
||||||
|
// Sets it as the API WebView and triggers the first data fetch.
|
||||||
|
func adoptPrimerWebView(_ wv: WKWebView) async {
|
||||||
|
apiWebView = wv
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called after login: store the login WebView (already on claude.ai) so jsFetch can use it.
|
||||||
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
|
||||||
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,
|
// Runs a fetch() inside the live claude.ai WebView — same browser context as the frontend,
|
||||||
// so no CORS/fingerprinting issues that URLSession hits.
|
// so no CORS/fingerprinting issues that URLSession hits.
|
||||||
private func jsFetch(_ path: String) async throws -> (Int, String) {
|
private func jsFetch(_ path: String) async throws -> (Int, String) {
|
||||||
@@ -194,8 +179,8 @@ class UsageViewModel: ObservableObject {
|
|||||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||||
guard cookies.contains(where: { $0.domain.contains("claude.ai") }) else { return }
|
guard cookies.contains(where: { $0.domain.contains("claude.ai") }) else { return }
|
||||||
isSignedIn = true
|
isSignedIn = true
|
||||||
await prepareApiWebView()
|
// AppDelegate's primer WebView will call adoptPrimerWebView once it loads,
|
||||||
await refresh()
|
// which triggers the first real data fetch.
|
||||||
}
|
}
|
||||||
|
|
||||||
func signOut() async {
|
func signOut() async {
|
||||||
@@ -486,16 +471,6 @@ 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
|
||||||
|
|||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
## What's new in v1.3.1
|
## What's new in v1.3.2
|
||||||
|
|
||||||
### Privacy
|
### Reliability
|
||||||
- Diagnostics panel no longer displays API response bodies, which could contain account and financial data. Status codes, error messages, cookie names, and request paths are still shown.
|
- Data now loads automatically on every app launch without requiring manual re-authentication. The background WebView is anchored in a hidden window so macOS no longer throttles its JavaScript execution.
|
||||||
|
|||||||
Reference in New Issue
Block a user