Cookie domain filtering was wrong — the session token domain is unknown and was never found by claude.ai/anthropic.com filters. LoginView: replace getAllCookies domain check with callAsyncJavaScript that fetches /api/bootstrap directly from the WebView. The WebView uses its own full session (all cookies, any domain) so auth is detected correctly regardless of where the token lives. UsageViewModel: claudeCookieHeader now sends all cookies from the app's WKWebsiteDataStore instead of filtering by domain. checkInitialSignInState likewise checks for any cookie.
361 lines
16 KiB
Swift
361 lines
16 KiB
Swift
import SwiftUI
|
|
import WebKit
|
|
|
|
@MainActor
|
|
class UsageViewModel: ObservableObject {
|
|
@Published var limits: [AgentLimit] = []
|
|
@Published var isLoading = false
|
|
@Published var lastUpdated: Date?
|
|
@Published var errorMessage: String?
|
|
@Published var extraUsage: ExtraUsage?
|
|
@Published var prepaidCredits: PrepaidCredits?
|
|
@Published var overageSpendLimit: OverageSpendLimit?
|
|
@Published var planLabel: String = "Claude"
|
|
@Published var isNotAuthenticated: Bool = false
|
|
@Published var isSignedIn: Bool = false
|
|
@Published var userEmail: String = ""
|
|
@Published var triggerLogin: Bool = false
|
|
@Published var showInMenuBar: Bool = true {
|
|
didSet { UserDefaults.standard.set(showInMenuBar, forKey: "show_in_menubar") }
|
|
}
|
|
@Published var refreshInterval: TimeInterval = 60 {
|
|
didSet {
|
|
UserDefaults.standard.set(refreshInterval, forKey: "refresh_interval")
|
|
NotificationCenter.default.post(name: .refreshIntervalChanged, object: refreshInterval)
|
|
}
|
|
}
|
|
|
|
private var burnHistoryStore: [String: [Double]] = [:]
|
|
private let maxHistorySamples = 24
|
|
private var previousPercents: [String: Double] = [:]
|
|
private var firedThresholds: [String: Set<Int>] = [:]
|
|
|
|
init() {
|
|
let saved = UserDefaults.standard.double(forKey: "refresh_interval")
|
|
refreshInterval = saved > 0 ? saved : 60
|
|
showInMenuBar = UserDefaults.standard.object(forKey: "show_in_menubar") as? Bool ?? true
|
|
if let saved = UserDefaults.standard.object(forKey: "burn_history") as? [String: [Double]] {
|
|
burnHistoryStore = saved
|
|
}
|
|
loadPlaceholderData()
|
|
Task { await checkInitialSignInState() }
|
|
}
|
|
|
|
private func checkInitialSignInState() async {
|
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
|
if !cookies.isEmpty { isSignedIn = true }
|
|
}
|
|
|
|
func signOut() async {
|
|
let store = WKWebsiteDataStore.default()
|
|
let types = WKWebsiteDataStore.allWebsiteDataTypes()
|
|
let records = await store.dataRecords(ofTypes: types)
|
|
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") || $0.displayName.contains("anthropic.com") }
|
|
await store.removeData(ofTypes: types, for: claudeRecords)
|
|
UserDefaults.standard.removeObject(forKey: "claude_org_id")
|
|
isSignedIn = false
|
|
isNotAuthenticated = true
|
|
lastUpdated = nil
|
|
userEmail = ""
|
|
limits = []
|
|
extraUsage = nil
|
|
prepaidCredits = nil
|
|
overageSpendLimit = nil
|
|
}
|
|
|
|
func refresh() async {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
isNotAuthenticated = false
|
|
defer { isLoading = false }
|
|
|
|
do {
|
|
let (fetchedOrgId, fetchedEmail, fetchedPlan) = try await fetchBootstrap()
|
|
|
|
let orgId: String
|
|
if let id = fetchedOrgId {
|
|
UserDefaults.standard.set(id, forKey: "claude_org_id")
|
|
orgId = id
|
|
} else if let cached = UserDefaults.standard.string(forKey: "claude_org_id") {
|
|
orgId = cached
|
|
} else {
|
|
throw AppError.notAuthenticated
|
|
}
|
|
|
|
if let email = fetchedEmail { userEmail = email }
|
|
if let plan = fetchedPlan { planLabel = plan }
|
|
|
|
async let usageFetch = fetchUsage(orgId: orgId)
|
|
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId)
|
|
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
|
|
let (usage, prepaid, overage) = try await (usageFetch, prepaidFetch, overageFetch)
|
|
limits = buildLimits(from: usage)
|
|
extraUsage = usage.extraUsage
|
|
prepaidCredits = prepaid
|
|
overageSpendLimit = overage
|
|
lastUpdated = Date()
|
|
isSignedIn = true
|
|
|
|
for i in limits.indices {
|
|
let key = limits[i].window.rawValue
|
|
var history = burnHistoryStore[key] ?? []
|
|
history.append(limits[i].usedPercent)
|
|
if history.count > maxHistorySamples { history.removeFirst() }
|
|
burnHistoryStore[key] = history
|
|
limits[i].burnHistory = history
|
|
}
|
|
UserDefaults.standard.set(burnHistoryStore, forKey: "burn_history")
|
|
checkLimitNotifications(for: limits)
|
|
} catch AppError.notAuthenticated {
|
|
isNotAuthenticated = true
|
|
isSignedIn = false
|
|
errorMessage = "Not signed in"
|
|
} catch let error as DecodingError {
|
|
switch error {
|
|
case .keyNotFound(let key, let ctx):
|
|
errorMessage = "Missing key '\(key.stringValue)' at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))"
|
|
case .typeMismatch(let type, let ctx):
|
|
errorMessage = "Type mismatch (\(type)) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))"
|
|
case .valueNotFound(let type, let ctx):
|
|
errorMessage = "Value not found (\(type)) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))"
|
|
default:
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
// MARK: - Bootstrap (org ID + email + plan label in one call)
|
|
|
|
private func claudeAPIRequest(for url: URL) async -> URLRequest {
|
|
var req = URLRequest(url: url)
|
|
req.setValue("application/json, text/plain, */*", forHTTPHeaderField: "accept")
|
|
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("https://claude.ai", forHTTPHeaderField: "Origin")
|
|
req.setValue("https://claude.ai/", forHTTPHeaderField: "Referer")
|
|
req.setValue("same-origin", forHTTPHeaderField: "sec-fetch-site")
|
|
req.setValue("cors", forHTTPHeaderField: "sec-fetch-mode")
|
|
req.setValue("empty", forHTTPHeaderField: "sec-fetch-dest")
|
|
if let cookie = await claudeCookieHeader() {
|
|
req.setValue(cookie, forHTTPHeaderField: "Cookie")
|
|
}
|
|
return req
|
|
}
|
|
|
|
private func fetchBootstrap() async throws -> (orgId: String?, email: String?, planLabel: String?) {
|
|
let url = URL(string: "https://claude.ai/api/bootstrap")!
|
|
var req = await claudeAPIRequest(for: url)
|
|
guard req.value(forHTTPHeaderField: "Cookie") != nil else { throw AppError.notAuthenticated }
|
|
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 }
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
return (nil, nil, nil)
|
|
}
|
|
|
|
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 firstOrg = memberships?.first?["organization"] as? [String: Any]
|
|
|
|
// org ID — primary path then flat-list fallback then dedicated endpoint
|
|
var orgId: String? = firstOrg?["uuid"] as? String
|
|
if orgId == nil {
|
|
orgId = (json["organizations"] as? [[String: Any]])?.first?["uuid"] as? String
|
|
}
|
|
if orgId == nil {
|
|
// Final fallback: fetch /api/organizations directly
|
|
let orgsReq = await claudeAPIRequest(for: URL(string: "https://claude.ai/api/organizations")!)
|
|
if let (orgsData, orgsResp) = try? await URLSession.shared.data(for: orgsReq),
|
|
let orgsHttp = orgsResp as? HTTPURLResponse, orgsHttp.statusCode == 200,
|
|
let orgs = try? JSONSerialization.jsonObject(with: orgsData) as? [[String: Any]] {
|
|
orgId = orgs.first?["uuid"] as? String
|
|
}
|
|
}
|
|
|
|
let email = account?["email_address"] as? String
|
|
|
|
// plan label from capabilities e.g. "claude_pro" -> "Pro"
|
|
var planLabel: String? = nil
|
|
if let caps = firstOrg?["capabilities"] as? [String],
|
|
let cap = caps.first(where: { $0.hasPrefix("claude_") }) {
|
|
let name = String(cap.dropFirst("claude_".count))
|
|
planLabel = name.prefix(1).uppercased() + name.dropFirst().lowercased()
|
|
}
|
|
|
|
return (orgId, email, planLabel)
|
|
}
|
|
|
|
// MARK: - Fetch usage
|
|
|
|
private func claudeCookieHeader() async -> String? {
|
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
|
guard !cookies.isEmpty else { return nil }
|
|
// Send all cookies from the app's WebView store — the session token may be
|
|
// on any domain (claude.ai, anthropic.com, or an auth sub-service).
|
|
return HTTPCookie.requestHeaderFields(with: cookies)["Cookie"]
|
|
}
|
|
|
|
private func fetchUsage(orgId: String) async throws -> UsageResponse {
|
|
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/usage")!
|
|
let req = await claudeAPIRequest(for: url)
|
|
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)
|
|
}
|
|
|
|
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
|
|
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/prepaid/credits")!
|
|
let req = await claudeAPIRequest(for: url)
|
|
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)
|
|
}
|
|
|
|
private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? {
|
|
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/overage_spend_limit")!
|
|
let req = await claudeAPIRequest(for: url)
|
|
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)
|
|
}
|
|
|
|
// MARK: - Build limits
|
|
|
|
private func buildLimits(from usage: UsageResponse) -> [AgentLimit] {
|
|
let now = Date()
|
|
let iso = ISO8601DateFormatter()
|
|
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
|
|
func parseDate(_ str: String?) -> Date {
|
|
guard let str else { return now.addingTimeInterval(3600) }
|
|
return iso.date(from: str) ?? now.addingTimeInterval(3600)
|
|
}
|
|
|
|
func timeLeft(until reset: Date) -> String {
|
|
let diff = max(0, reset.timeIntervalSince(now))
|
|
if diff == 0 { return "resetting..." }
|
|
let h = Int(diff / 3600)
|
|
let m = Int((diff.truncatingRemainder(dividingBy: 3600)) / 60)
|
|
if h >= 24 { return "\(h/24)d \(h%24)h" }
|
|
return "\(h)h \(m)m"
|
|
}
|
|
|
|
func projected(pct: Double, windowHours: Double, reset: Date) -> ProjectedHit {
|
|
guard pct > 0 else { return .afterReset }
|
|
let rate = pct / windowHours
|
|
guard rate > 0 else { return .afterReset }
|
|
let hit = now.addingTimeInterval(((100 - pct) / rate) * 3600)
|
|
return hit > reset ? .afterReset : .at(hit)
|
|
}
|
|
|
|
var result: [AgentLimit] = []
|
|
|
|
if let fh = usage.fiveHour {
|
|
let reset = parseDate(fh.resetsAt)
|
|
let pct = min(100, max(0, fh.utilization))
|
|
result.append(AgentLimit(
|
|
agent: .claude, window: .fiveHour,
|
|
usedPercent: pct,
|
|
timeRemaining: timeLeft(until: reset),
|
|
resetDate: reset,
|
|
projectedHit: projected(pct: pct, windowHours: 5, reset: reset),
|
|
burnRate: pct / 5.0,
|
|
burnHistory: burnHistoryStore["5h"] ?? [pct],
|
|
isLive: true
|
|
))
|
|
}
|
|
|
|
if let sd = usage.sevenDay {
|
|
let reset = parseDate(sd.resetsAt)
|
|
let pct = min(100, max(0, sd.utilization))
|
|
result.append(AgentLimit(
|
|
agent: .claude, window: .sevenDay,
|
|
usedPercent: pct,
|
|
timeRemaining: timeLeft(until: reset),
|
|
resetDate: reset,
|
|
projectedHit: projected(pct: pct, windowHours: 7*24, reset: reset),
|
|
burnRate: pct / (7*24.0),
|
|
burnHistory: burnHistoryStore["7d"] ?? [pct],
|
|
isLive: true
|
|
))
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// MARK: - Limit notifications
|
|
|
|
private func checkLimitNotifications(for limits: [AgentLimit]) {
|
|
let thresholds = [80, 95]
|
|
for limit in limits {
|
|
let key = limit.window.rawValue
|
|
let curr = limit.usedPercent
|
|
let prev = previousPercents[key]
|
|
var fired = firedThresholds[key] ?? []
|
|
for t in thresholds {
|
|
let threshold = Double(t)
|
|
guard curr >= threshold, prev ?? threshold < threshold, !fired.contains(t) else { continue }
|
|
fired.insert(t)
|
|
NotificationCenter.default.post(
|
|
name: .limitWarning,
|
|
object: nil,
|
|
userInfo: ["windowName": limit.window.displayName, "percent": t]
|
|
)
|
|
}
|
|
if let prev, prev > 50, curr < 20 {
|
|
fired.removeAll()
|
|
NotificationCenter.default.post(
|
|
name: .limitReset,
|
|
object: nil,
|
|
userInfo: ["windowName": limit.window.displayName]
|
|
)
|
|
}
|
|
firedThresholds[key] = fired
|
|
previousPercents[key] = curr
|
|
}
|
|
}
|
|
|
|
private func loadPlaceholderData() {
|
|
let now = Date()
|
|
limits = [
|
|
AgentLimit(agent: .claude, window: .fiveHour,
|
|
usedPercent: 0, timeRemaining: "—",
|
|
resetDate: now.addingTimeInterval(3600),
|
|
projectedHit: .afterReset, burnRate: 0,
|
|
burnHistory: [], isLive: false),
|
|
AgentLimit(agent: .claude, window: .sevenDay,
|
|
usedPercent: 0, timeRemaining: "—",
|
|
resetDate: now.addingTimeInterval(7*24*3600),
|
|
projectedHit: .afterReset, burnRate: 0,
|
|
burnHistory: [], isLive: false),
|
|
]
|
|
}
|
|
}
|
|
|
|
enum AppError: LocalizedError {
|
|
case notAuthenticated
|
|
case networkError
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
|
|
case .networkError: return "Network error fetching usage data."
|
|
}
|
|
}
|
|
}
|
|
|
|
extension Notification.Name {
|
|
static let refreshIntervalChanged = Notification.Name("refreshIntervalChanged")
|
|
static let closePopover = Notification.Name("closePopover")
|
|
static let showPopover = Notification.Name("showPopover")
|
|
static let popoverWillOpen = Notification.Name("popoverWillOpen")
|
|
static let updateDetected = Notification.Name("updateDetected")
|
|
static let limitWarning = Notification.Name("limitWarning")
|
|
static let limitReset = Notification.Name("limitReset")
|
|
static let openUpdateSheet = Notification.Name("openUpdateSheet")
|
|
}
|