Compare commits
110
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9af0d6401c | ||
|
|
7cb2159b4c | ||
|
|
206e97780d | ||
|
|
2b79ba4ae0 | ||
|
|
60b6c60c0a | ||
|
|
8140831236 | ||
|
|
9ed8916075 | ||
|
|
2ab2d913ce | ||
|
|
30ad89e458 | ||
|
|
3e4bfca2df | ||
|
|
4378463600 | ||
|
|
2cfd7d56a1 | ||
|
|
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 | ||
|
|
8e7328b2c5 | ||
|
|
709eb12382 | ||
|
|
e866324a48 | ||
|
|
a62584221b | ||
|
|
fc7fd19652 | ||
|
|
58b947e515 | ||
|
|
88252e4d59 | ||
|
|
ad2ff3a7b6 | ||
|
|
1a9d9cd22a | ||
|
|
b8826ace9b | ||
|
|
2a0de269ff | ||
|
|
ac7ffe81af | ||
|
|
f28f7b8a5a | ||
|
|
aaed64484c | ||
|
|
2e3ee350ca | ||
|
|
e939bdb88b | ||
|
|
6749c5f158 | ||
|
|
de71ec2794 | ||
|
|
ac17ce154f | ||
|
|
d2eac62897 | ||
|
|
8c64fc50ad | ||
|
|
b2c138f665 | ||
|
|
aa6c299e2a | ||
|
|
ecbae7d7ca | ||
|
|
37a039e1d3 | ||
|
|
8f3ead2f65 | ||
|
|
734670bb3f | ||
|
|
94430e00ba | ||
|
|
7b0368b001 | ||
|
|
4629aeffbc | ||
|
|
2cb4ddfe97 | ||
|
|
73b251a1e9 | ||
|
|
7bab6a0df0 | ||
|
|
db6380fa66 | ||
|
|
47a148fa67 | ||
|
|
85ac329d36 | ||
|
|
a0fb6eabcf | ||
|
|
b778d03323 | ||
|
|
13387888b2 | ||
|
|
44be1649a1 | ||
|
|
1160712e1b | ||
|
|
e4f55c80ee | ||
|
|
526752dc36 | ||
|
|
a2d983b555 | ||
|
|
0d807c2285 | ||
|
|
1c42433145 | ||
|
|
c0ac2e5a66 | ||
|
|
405e65f9f7 | ||
|
|
1c7433ebea | ||
|
|
a39ef800e8 | ||
|
|
76dec8b85d | ||
|
|
2f04f8581c | ||
|
|
788c066a1e | ||
|
|
31280d4ab2 | ||
|
|
7b1c5bbf8e | ||
|
|
f2ba44bbaa | ||
|
|
de1fffe0e3 | ||
|
|
e80f07d1b9 | ||
|
|
e9ca8a7efc |
@@ -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>
|
||||||
|
|||||||
@@ -40,8 +40,7 @@ struct ContentView: View {
|
|||||||
.environmentObject(updater)
|
.environmentObject(updater)
|
||||||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||||
} else {
|
} else {
|
||||||
ScrollView(.vertical, showsIndicators: false) {
|
VStack(spacing: 0) {
|
||||||
VStack(spacing: 0) {
|
|
||||||
// Cards grid
|
// Cards grid
|
||||||
if vm.limits.isEmpty {
|
if vm.limits.isEmpty {
|
||||||
EmptyStateView()
|
EmptyStateView()
|
||||||
@@ -96,7 +95,6 @@ struct ContentView: View {
|
|||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
|
|
||||||
@@ -118,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.refresh() }
|
Task { await vm.adoptAndRefresh(webView) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showUpdateSheet) {
|
.sheet(isPresented: $showUpdateSheet) {
|
||||||
@@ -398,12 +396,11 @@ struct DiaryRow: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
HStack {
|
HStack {
|
||||||
AgentIconView(agent: .claude, size: 12)
|
Text("\(totalSaved) samples")
|
||||||
Text("Claude")
|
.font(.system(size: 10.5))
|
||||||
.font(.system(size: 12, weight: .medium))
|
.foregroundColor(.secondary)
|
||||||
.foregroundColor(color)
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(totalSaved) samples · Avg Burn Rate: +\(String(format: "%.1f", burnRate))%/h")
|
Text("Avg burn rate: \(String(format: "%.1f", burnRate))%/h")
|
||||||
.font(.system(size: 10.5))
|
.font(.system(size: 10.5))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
@@ -587,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),
|
||||||
@@ -773,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())
|
||||||
@@ -914,12 +929,12 @@ struct ErrorBanner: View {
|
|||||||
struct EmptyStateView: View {
|
struct EmptyStateView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Image(systemName: "key.slash")
|
Image(systemName: "person.crop.circle.badge.questionmark")
|
||||||
.font(.system(size: 28))
|
.font(.system(size: 28))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Text("No API key configured")
|
Text("Not signed in")
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.system(size: 13, weight: .medium))
|
||||||
Text("Open Settings to add your Anthropic API key.")
|
Text("Sign in to claude.ai to see your usage.")
|
||||||
.font(.system(size: 11.5))
|
.font(.system(size: 11.5))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
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("lastActiveOrg", vm.diagLastActiveOrg.isEmpty ? "(not read)" : vm.diagLastActiveOrg)
|
||||||
|
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()
|
||||||
|
|
||||||
|
// 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("lastActiveOrg: \(vm.diagLastActiveOrg.isEmpty ? "(not read)" : vm.diagLastActiveOrg)")
|
||||||
|
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("")
|
||||||
|
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.1.4-beta.5</string>
|
<string>1.3.1</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>45</string>
|
<string>75</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,13 @@ 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
|
||||||
|
context.coordinator.urlObservation = webView.observe(\.url, options: [.new]) { [weak coordinator = context.coordinator] wv, _ in
|
||||||
|
coordinator?.checkCurrentURL(wv.url?.absoluteString)
|
||||||
|
}
|
||||||
|
|
||||||
webView.load(URLRequest(url: URL(string: "https://claude.ai/login")!))
|
webView.load(URLRequest(url: URL(string: "https://claude.ai/login")!))
|
||||||
return webView
|
return webView
|
||||||
}
|
}
|
||||||
@@ -23,31 +30,31 @@ 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?
|
||||||
|
|
||||||
init(onAuthenticated: @escaping () -> Void) {
|
init(onAuthenticated: @escaping (WKWebView) -> Void) {
|
||||||
self.onAuthenticated = onAuthenticated
|
self.onAuthenticated = onAuthenticated
|
||||||
}
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
func checkCurrentURL(_ url: String?) {
|
||||||
guard !didAuthenticate else { return }
|
guard !didAuthenticate, let url, let wv = webView else { return }
|
||||||
// Don't fire on the login/auth pages themselves
|
// Ignore navigations to external OAuth providers (Google, etc.) —
|
||||||
if let url = webView.url?.absoluteString,
|
// only consider auth complete when we land back on claude.ai/anthropic.com
|
||||||
url.contains("/login") || url.contains("/auth") { return }
|
guard url.contains("claude.ai") || url.contains("anthropic.com") else { return }
|
||||||
|
if url.contains("/login") || url.contains("/auth") { return }
|
||||||
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
|
didAuthenticate = true
|
||||||
let hasSession = cookies.contains {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
$0.domain.contains("claude.ai") &&
|
self.onAuthenticated(wv)
|
||||||
($0.name == "sessionKey" || $0.name == "__Secure-next-auth.session-token")
|
|
||||||
}
|
|
||||||
guard hasSession, !self.didAuthenticate else { return }
|
|
||||||
self.didAuthenticate = true
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
||||||
self.onAuthenticated()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Covers full cross-document navigations
|
||||||
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
checkCurrentURL(webView.url?.absoluteString)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,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 {
|
||||||
@@ -65,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)
|
||||||
@@ -83,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,6 +30,20 @@ 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>] = [:]
|
||||||
|
|
||||||
|
// 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 diagClaudeCookieCount: Int = 0
|
||||||
|
@Published var diagCookieDomains: [String] = []
|
||||||
|
@Published var diagLastPath: String = ""
|
||||||
|
@Published var diagLastStatus: Int = 0
|
||||||
|
@Published var diagLastError: String = ""
|
||||||
|
@Published var diagLastActiveOrg: String = "" // lastActiveOrg cookie value from JS
|
||||||
|
@Published var diagLastFetch: Date? = nil
|
||||||
|
|
||||||
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
|
||||||
@@ -38,15 +52,160 @@ class UsageViewModel: ObservableObject {
|
|||||||
burnHistoryStore = saved
|
burnHistoryStore = saved
|
||||||
}
|
}
|
||||||
loadPlaceholderData()
|
loadPlaceholderData()
|
||||||
|
Task { await checkInitialSignInState() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called after login: store the webview (already on claude.ai) so jsFetch can use it.
|
||||||
|
func adoptAndRefresh(_ loginWebView: WKWebView) async {
|
||||||
|
for _ in 0..<30 {
|
||||||
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||||
|
if cookies.contains(where: { $0.domain.contains("claude.ai") }) { break }
|
||||||
|
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||||
|
}
|
||||||
|
if loginWebView.url?.host?.hasSuffix("claude.ai") == true {
|
||||||
|
apiWebView = loginWebView
|
||||||
|
} else {
|
||||||
|
await prepareApiWebView()
|
||||||
|
}
|
||||||
|
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
|
||||||
|
diagLastError = status != 200 ? "JS HTTP \(status)" : ""
|
||||||
|
return (status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use JS fetch when apiWebView is ready on claude.ai, otherwise fall back to URLSession.
|
||||||
|
private func apiFetch(_ path: String) async throws -> (Int, String) {
|
||||||
|
if let wv = apiWebView, wv.url?.host?.hasSuffix("claude.ai") == true {
|
||||||
|
return try await jsFetch(path)
|
||||||
|
}
|
||||||
|
return try await urlFetch(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetches an API path via URLSession with browser-like headers.
|
||||||
|
// Claude.ai's API requires sec-fetch-*, Origin, Referer, and a browser User-Agent
|
||||||
|
// to avoid 401 — matching how the Windows version (HttpClient) handles this.
|
||||||
|
private func urlFetch(_ path: String) async throws -> (Int, String) {
|
||||||
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||||
|
let claudeCookies = cookies.filter {
|
||||||
|
$0.domain.contains("claude.ai") || $0.domain.contains("anthropic.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record cookie diagnostics
|
||||||
|
diagCookieCount = cookies.count
|
||||||
|
diagClaudeCookieCount = claudeCookies.count
|
||||||
|
diagCookieDomains = Array(Set(cookies.map { $0.domain })).sorted()
|
||||||
|
diagLastPath = path
|
||||||
|
diagLastFetch = Date()
|
||||||
|
diagLastError = ""
|
||||||
|
|
||||||
|
guard !claudeCookies.isEmpty else {
|
||||||
|
let msg = "No claude.ai cookies (\(cookies.count) total in store)"
|
||||||
|
diagLastError = msg
|
||||||
|
throw AppError.detail(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
var req = URLRequest(url: URL(string: "https://claude.ai\(path)")!)
|
||||||
|
req.httpShouldHandleCookies = false
|
||||||
|
// Build cookie header manually (same format as Windows HttpClient) to avoid
|
||||||
|
// any encoding differences from HTTPCookie.requestHeaderFields(with:)
|
||||||
|
let cookieHeader = claudeCookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
|
||||||
|
req.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
req.setValue("https://claude.ai", forHTTPHeaderField: "Origin")
|
||||||
|
req.setValue("https://claude.ai/", forHTTPHeaderField: "Referer")
|
||||||
|
req.setValue("empty", forHTTPHeaderField: "sec-fetch-dest")
|
||||||
|
req.setValue("cors", forHTTPHeaderField: "sec-fetch-mode")
|
||||||
|
req.setValue("same-origin", forHTTPHeaderField: "sec-fetch-site")
|
||||||
|
req.setValue(
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||||
|
forHTTPHeaderField: "User-Agent")
|
||||||
|
req.setValue(
|
||||||
|
"\"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\", \"Not-A.Brand\";v=\"99\"",
|
||||||
|
forHTTPHeaderField: "sec-ch-ua")
|
||||||
|
req.setValue("?0", forHTTPHeaderField: "sec-ch-ua-mobile")
|
||||||
|
req.setValue("\"macOS\"", forHTTPHeaderField: "sec-ch-ua-platform")
|
||||||
|
req.setValue("web", forHTTPHeaderField: "anthropic-client-platform")
|
||||||
|
|
||||||
|
let session = URLSession(configuration: .ephemeral)
|
||||||
|
do {
|
||||||
|
let (data, response) = try await session.data(for: req)
|
||||||
|
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
|
||||||
|
let body = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
diagLastStatus = http.statusCode
|
||||||
|
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 {
|
||||||
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||||
|
guard cookies.contains(where: { $0.domain.contains("claude.ai") }) else { return }
|
||||||
|
isSignedIn = true
|
||||||
|
await prepareApiWebView()
|
||||||
|
await refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
func signOut() async {
|
func signOut() async {
|
||||||
let store = WKWebsiteDataStore.default()
|
let store = WKWebsiteDataStore.default()
|
||||||
let types = WKWebsiteDataStore.allWebsiteDataTypes()
|
let types = WKWebsiteDataStore.allWebsiteDataTypes()
|
||||||
let records = await store.dataRecords(ofTypes: types)
|
let records = await store.dataRecords(ofTypes: types)
|
||||||
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") }
|
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
|
||||||
@@ -64,10 +223,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") {
|
||||||
@@ -84,8 +264,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
|
||||||
@@ -102,6 +282,7 @@ class UsageViewModel: ObservableObject {
|
|||||||
checkLimitNotifications(for: limits)
|
checkLimitNotifications(for: limits)
|
||||||
} catch AppError.notAuthenticated {
|
} catch AppError.notAuthenticated {
|
||||||
isNotAuthenticated = true
|
isNotAuthenticated = true
|
||||||
|
isSignedIn = false
|
||||||
errorMessage = "Not signed in"
|
errorMessage = "Not signed in"
|
||||||
} catch let error as DecodingError {
|
} catch let error as DecodingError {
|
||||||
switch error {
|
switch error {
|
||||||
@@ -119,38 +300,46 @@ 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(preferredOrgId: String? = nil) async throws -> (orgId: String?, email: String?, planLabel: String?) {
|
||||||
let url = URL(string: "https://claude.ai/api/bootstrap")!
|
let (status, body) = try await apiFetch("/api/bootstrap")
|
||||||
var req = URLRequest(url: url)
|
if status == 401 || status == 403 {
|
||||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
throw AppError.detail("HTTP \(status) — \(body.prefix(120))")
|
||||||
guard let cookie = await claudeCookieHeader() else { throw AppError.notAuthenticated }
|
}
|
||||||
req.setValue(cookie, forHTTPHeaderField: "Cookie")
|
guard status == 200 else { throw AppError.detail("HTTP \(status): \(body.prefix(80))") }
|
||||||
let (data, response) = try await URLSession.shared.data(for: req)
|
guard body.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") else {
|
||||||
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
|
throw AppError.detail("Non-JSON response (HTML?): \(body.prefix(80))")
|
||||||
if http.statusCode == 401 || http.statusCode == 403 { throw AppError.notAuthenticated }
|
}
|
||||||
guard http.statusCode == 200 else { throw AppError.networkError }
|
guard let data = body.data(using: .utf8),
|
||||||
guard 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
let account = json["account"] as? [String: Any]
|
let account = json["account"] as? [String: Any]
|
||||||
// memberships may live under account or at root (older API shape)
|
|
||||||
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
|
||||||
|
|
||||||
// org ID — primary path then flat-list fallback
|
|
||||||
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 let (orgsStatus, orgsBody) = try? await apiFetch("/api/organizations"),
|
||||||
|
orgsStatus == 200,
|
||||||
|
let orgsData = orgsBody.data(using: .utf8),
|
||||||
|
let orgs = try? JSONSerialization.jsonObject(with: orgsData) as? [[String: Any]] {
|
||||||
|
orgId = orgs.first?["uuid"] as? String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let email = account?["email_address"] as? String
|
let email = account?["email_address"] as? String
|
||||||
|
|
||||||
// plan label from capabilities e.g. "claude_pro" -> "Pro"
|
// 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()
|
||||||
@@ -161,41 +350,25 @@ class UsageViewModel: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Fetch usage
|
// MARK: - Fetch usage
|
||||||
|
|
||||||
private func claudeCookieHeader() async -> String? {
|
|
||||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
|
||||||
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
|
|
||||||
return HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"]
|
|
||||||
}
|
|
||||||
|
|
||||||
private func fetchUsage(orgId: String) async throws -> UsageResponse {
|
private func fetchUsage(orgId: String) async throws -> UsageResponse {
|
||||||
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/usage")!
|
let (status, body) = try await apiFetch("/api/organizations/\(orgId)/usage")
|
||||||
var req = URLRequest(url: url)
|
if status == 401 || status == 403 { throw AppError.detail("usage \(status): \(body.prefix(200))") }
|
||||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
guard status == 200 else { throw AppError.networkError }
|
||||||
if let cookie = await claudeCookieHeader() { req.setValue(cookie, forHTTPHeaderField: "Cookie") }
|
guard let data = body.data(using: .utf8) else { throw AppError.networkError }
|
||||||
let (data, response) = try await URLSession.shared.data(for: req)
|
|
||||||
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
|
|
||||||
if http.statusCode == 401 || http.statusCode == 403 { throw AppError.notAuthenticated }
|
|
||||||
guard http.statusCode == 200 else { throw AppError.networkError }
|
|
||||||
return try JSONDecoder().decode(UsageResponse.self, from: data)
|
return try JSONDecoder().decode(UsageResponse.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
|
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
|
||||||
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/prepaid/credits")!
|
guard let (status, body) = try? await apiFetch("/api/organizations/\(orgId)/prepaid/credits"),
|
||||||
var req = URLRequest(url: url)
|
status == 200,
|
||||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
let data = body.data(using: .utf8) else { return nil }
|
||||||
if let cookie = await claudeCookieHeader() { req.setValue(cookie, forHTTPHeaderField: "Cookie") }
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: req)
|
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 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? {
|
||||||
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/overage_spend_limit")!
|
guard let (status, body) = try? await apiFetch("/api/organizations/\(orgId)/overage_spend_limit"),
|
||||||
var req = URLRequest(url: url)
|
status == 200,
|
||||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
let data = body.data(using: .utf8) else { return nil }
|
||||||
if let cookie = await claudeCookieHeader() { req.setValue(cookie, forHTTPHeaderField: "Cookie") }
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: req)
|
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
|
||||||
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
|
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,13 +485,27 @@ 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
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
|
|
||||||
[](https://www.apple.com/macos/)
|
[](https://www.apple.com/macos/)
|
||||||
[](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.1.4-beta.4) <!-- BETA_BADGE -->
|
<!-- BETA_BADGE -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
## What's new in v1.1.4-beta.5
|
## What's new in v1.3.1
|
||||||
|
|
||||||
### Improvements
|
### Privacy
|
||||||
- Bootstrap API call consolidated — org ID, email, and plan name now fetched in a single request per refresh
|
- 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.
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.1.4-beta.4",
|
"version": "1.2.1-beta.25",
|
||||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.4-beta.4/ClaudeChecker.zip",
|
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.25/ClaudeChecker.zip",
|
||||||
"notes": "## What's new in v1.1.4-beta.4\n\n### Bug fix\n- Plan name now updates correctly on every refresh (was only set on first launch)"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "0.0.1-beta.35",
|
"version": "0.0.1-beta.49",
|
||||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/win-v0.0.1-beta.35/ClaudeChecker-Windows.zip",
|
"url": "https://github.com/superdooper86/claudechecker/releases/download/win-v0.0.1-beta.49/ClaudeChecker-Windows.zip",
|
||||||
"notes": "## What's new in beta.10\r\n\r\n### Bug fixes\r\n- Limits now update correctly on each refresh cycle\r\n- Session expiry is detected and prompts re-authentication instead of silently showing stale data\r\n- Plan name (e.g. Pro, Max) is now read from the API instead of being hardcoded"
|
"notes": "## What's new in beta.36\r\n\r\n### Bug fixes\r\n- Settings no longer incorrectly shows \"Not signed in\" when limits are working\r\n- Session Diary now shows correctly after the first successful refresh\r\n- Extra Usage Credits section now restored from cache on startup\r\n- Plan name, overage, and prepaid credits are now cached and shown immediately on launch\r\n- App now loads cached state instantly on startup before the background refresh completes"
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.1.3",
|
"version": "1.3.0",
|
||||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.3/ClaudeChecker.zip",
|
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.3.0/ClaudeChecker.zip",
|
||||||
"notes": "## What's new in v1.1.3\n\n### UI polish\n- Limit headers now read \"5 Hour Limit\" and \"7 Day Limit\"\n- Tapping anywhere on the blue update banner opens the update window\n\n### Session Diary fix\n- Burn history is persisted across app launches — the sparkline populates immediately on first open instead of requiring a manual refresh\n\n### Window sizing fixes\n- The popover now resizes correctly when switching between the main view and settings\n- Background colour no longer shows through during the resize animation"
|
"notes": "## What's new in v1.3.0\n\n### Multi-org support\n- 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.\n- Plan label (e.g. **Max**, **Pro**, **Team**) now reads capabilities from the active org, not the first membership.\n\n### Reliable API access via WebView\n- 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.\n- A background WebView is created at startup when session cookies are detected, so usage data loads immediately without requiring a manual sign-in.\n\n### Diagnostics\n- 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."
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user