Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84bdfbd786 | ||
|
|
beba7586f9 | ||
|
|
a4dff4bde9 | ||
|
|
d0b33e8f43 | ||
|
|
6a5a3c9cd1 | ||
|
|
15e225439b | ||
|
|
91829b0ed9 | ||
|
|
caffc996ff | ||
|
|
cd13a6d124 | ||
|
|
566258e9c8 | ||
|
|
dc2a8e2307 | ||
|
|
f48b53fd8e | ||
|
|
ccfee938b7 | ||
|
|
28d952bcbd | ||
|
|
9bc023d239 | ||
|
|
fd82177a7d | ||
|
|
112210a99b | ||
|
|
f4a83940b1 | ||
|
|
377888a427 | ||
|
|
e24113a78b | ||
|
|
3baab91f70 | ||
|
|
0aa8837b2f | ||
|
|
373ca1dc14 | ||
|
|
7a4c21768f | ||
|
|
7b26629dc7 | ||
|
|
e446ea97f9 | ||
|
|
36af325e2d | ||
|
|
e2ef8b6f2a | ||
|
|
7a4975fa3e | ||
|
|
c9912f8463 | ||
|
|
238614a94b | ||
|
|
b11507221f | ||
|
|
b6fef53264 | ||
|
|
ec6ae6621a | ||
|
|
105a7706fa | ||
|
|
a950392a6f | ||
|
|
8087cc029d | ||
|
|
11f9e0c9b6 | ||
|
|
cc835ee2b0 |
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -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.12</string>
|
<string>1.2.1-beta.21</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>60</string>
|
<string>69</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>13.0</string>
|
<string>13.0</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
|
|||||||
@@ -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,20 +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.) —
|
||||||
|
// only consider auth complete when we land back on claude.ai/anthropic.com
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,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 {
|
||||||
@@ -67,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)
|
||||||
@@ -85,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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,15 @@ 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 used for all API calls — runs fetch() in the page's auth context
|
// Diagnostics — populated on every urlFetch call
|
||||||
private var apiWebView: WKWebView?
|
@Published var diagCookieCount: Int = 0
|
||||||
private var apiDelegate: APIWebViewDelegate?
|
@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")
|
||||||
@@ -42,48 +48,79 @@ class UsageViewModel: ObservableObject {
|
|||||||
burnHistoryStore = saved
|
burnHistoryStore = saved
|
||||||
}
|
}
|
||||||
loadPlaceholderData()
|
loadPlaceholderData()
|
||||||
setupAPIWebView()
|
|
||||||
Task { await checkInitialSignInState() }
|
Task { await checkInitialSignInState() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Background API WebView
|
// Called after login: poll until session cookies appear (they may commit slightly
|
||||||
|
// after the auth redirect fires), then refresh.
|
||||||
private func setupAPIWebView() {
|
func adoptAndRefresh(_ loginWebView: WKWebView) async {
|
||||||
let config = WKWebViewConfiguration()
|
for _ in 0..<30 {
|
||||||
config.websiteDataStore = WKWebsiteDataStore.default()
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||||
let wv = WKWebView(frame: CGRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
|
if cookies.contains(where: { $0.domain.contains("claude.ai") }) { break }
|
||||||
let del = APIWebViewDelegate()
|
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||||
wv.navigationDelegate = del
|
|
||||||
apiWebView = wv
|
|
||||||
apiDelegate = del
|
|
||||||
wv.load(URLRequest(url: URL(string: "https://claude.ai")!))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called after login: reloads the background WebView to pick up the new session, then refreshes.
|
|
||||||
func reloadAPIWebViewAndRefresh() async {
|
|
||||||
guard let wv = apiWebView, let del = apiDelegate else {
|
|
||||||
await refresh()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
|
||||||
del.onDidFinish = { cont.resume() }
|
|
||||||
wv.load(URLRequest(url: URL(string: "https://claude.ai")!))
|
|
||||||
}
|
|
||||||
// Brief pause for the page's JS auth state to settle after navigation
|
|
||||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
|
||||||
await refresh()
|
await refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runs a fetch() call inside the background WebView's page context (same-origin, credentials included).
|
// Fetches an API path via URLSession with browser-like headers.
|
||||||
private func webViewFetch(_ path: String) async throws -> (statusCode: Int, body: String) {
|
// Claude.ai's API requires sec-fetch-*, Origin, Referer, and a browser User-Agent
|
||||||
guard let wv = apiWebView else { throw AppError.networkError }
|
// to avoid 401 — matching how the Windows version (HttpClient) handles this.
|
||||||
let js = "const r = await fetch(path, {credentials:'include'}); return {s: r.status, b: await r.text()};"
|
private func urlFetch(_ path: String) async throws -> (Int, String) {
|
||||||
let result = try await wv.callAsyncJavaScript(
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||||
js, arguments: ["path": path], in: nil, in: .page)
|
let claudeCookies = cookies.filter {
|
||||||
guard let d = result as? [String: Any],
|
$0.domain.contains("claude.ai") || $0.domain.contains("anthropic.com")
|
||||||
let s = (d["s"] as? NSNumber)?.intValue,
|
}
|
||||||
let b = d["b"] as? String else { throw AppError.networkError }
|
|
||||||
return (s, b)
|
// 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
|
||||||
|
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")
|
||||||
|
|
||||||
|
do {
|
||||||
|
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) ?? ""
|
||||||
|
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 msg = error.localizedDescription
|
||||||
|
diagLastError = msg
|
||||||
|
throw AppError.detail(msg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func checkInitialSignInState() async {
|
private func checkInitialSignInState() async {
|
||||||
@@ -106,8 +143,6 @@ class UsageViewModel: ObservableObject {
|
|||||||
extraUsage = nil
|
extraUsage = nil
|
||||||
prepaidCredits = nil
|
prepaidCredits = nil
|
||||||
overageSpendLimit = nil
|
overageSpendLimit = nil
|
||||||
// Reload background WebView to clear its session too
|
|
||||||
apiWebView?.load(URLRequest(url: URL(string: "https://claude.ai")!))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func refresh() async {
|
func refresh() async {
|
||||||
@@ -137,8 +172,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
|
||||||
@@ -173,12 +208,17 @@ class UsageViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Bootstrap (org ID + email + plan label in one call)
|
// 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 {
|
||||||
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)
|
||||||
@@ -193,7 +233,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]] {
|
||||||
@@ -216,7 +256,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 }
|
||||||
@@ -224,14 +264,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)
|
||||||
@@ -350,29 +390,16 @@ class UsageViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - API WebView Delegate
|
|
||||||
|
|
||||||
private class APIWebViewDelegate: NSObject, WKNavigationDelegate {
|
|
||||||
var onDidFinish: (() -> Void)?
|
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
||||||
let cb = onDidFinish; onDidFinish = nil; cb?()
|
|
||||||
}
|
|
||||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
|
||||||
let cb = onDidFinish; onDidFinish = nil; cb?()
|
|
||||||
}
|
|
||||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
|
||||||
let cb = onDidFinish; onDidFinish = nil; cb?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.11) <!-- BETA_BADGE -->
|
[](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.20) <!-- BETA_BADGE -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+17
-9
@@ -1,12 +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 usage data not loading after sign-in — API calls now run inside a persistent background WebView using the page's own fetch(), so all credentials (cookies, localStorage tokens, etc.) are included automatically
|
- Fixed WebKit suspending the background WKWebView: anchored in a transparent 1×1 NSWindow
|
||||||
- Fixed "Not signed in" showing after login — the background WebView is now reloaded after sign-in to pick up the new session before the first data refresh
|
- Fixed login window auto-closing before sign-in completes
|
||||||
- Fixed "Not signed in" showing incorrectly on launch when the session was already active
|
- Added /api/organizations as a final fallback for org ID resolution
|
||||||
- Sign-in state is now detected immediately from stored cookies on startup, before the first data refresh completes
|
- Fixed Settings incorrectly showing "Signed in" after a failed refresh
|
||||||
- Fixed login window auto-closing before the user could sign in — the login window now correctly loads the `/login` page so it only detects auth after the actual sign-in redirect
|
|
||||||
- Fixed "No API key configured" showing after signing out — now correctly shows "Not signed in" with a prompt to sign in
|
|
||||||
- Added `/api/organizations` as a final fallback for org ID resolution when the bootstrap API response doesn't include it
|
|
||||||
- Fixed Settings incorrectly showing "Signed in" after a failed refresh — sign-in state now resets when authentication fails
|
|
||||||
- Rewrote login detection to use KVO on the WebView URL — correctly detects auth for Next.js SPA navigation (history.pushState) that doesn't trigger didFinish
|
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.2.1-beta.11",
|
"version": "1.2.1-beta.20",
|
||||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.11/ClaudeChecker.zip",
|
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.20/ClaudeChecker.zip",
|
||||||
"notes": "## What's new in v1.2.1\n\n### Bug fixes\n- Fixed \"Not signed in\" showing incorrectly on launch when the session was already active\n- Sign-in state is now detected immediately from stored cookies on startup, before the first data refresh completes\n- Fixed login window auto-closing before the user could sign in — the login window now correctly loads the `/login` page so it only detects auth after the actual sign-in redirect\n- Fixed \"No API key configured\" showing after signing out — now correctly shows \"Not signed in\" with a prompt to sign in\n- Added `/api/organizations` as a final fallback for org ID resolution when the bootstrap API response doesn't include it\n- Fixed usage data not loading — API requests now include required browser-like headers (Origin, Referer, User-Agent)\n- Fixed Settings incorrectly showing \"Signed in\" after a failed refresh — sign-in state now resets when authentication fails\n- Rewrote login detection to use the WebView's own fetch call instead of inspecting cookie domains — correctly detects auth regardless of which domain the session token is stored on\n- Fixed API requests not including session cookies — now sends all cookies from the app's WebView store rather than filtering by domain"
|
"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