diff --git a/AGENTS.md b/AGENTS.md index e62361a..6ffbca9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,9 @@ - Claude quota data comes from `GET https://api.anthropic.com/api/oauth/usage`, the endpoint `/usage` itself calls, with `Authorization: Bearer` and `anthropic-beta: oauth-2025-04-20`. Parse the `limits` array (`session`, `weekly_all`, `weekly_scoped` with `scope.model.display_name`), not the legacy top-level buckets: the model-scoped weekly limit exists only there. - **Never refresh, rotate, or write the OAuth credential.** Read the Keychain item (`Claude Code-credentials` / the login name) through `/usr/bin/security find-generic-password -w` as a subprocess, never `SecItemCopyMatching` from the app: Claude Code recreates the item on every token refresh, which drops the ACL grant Steven gave TokenGauge, so the direct read re-prompted him after every refresh (2026-08-26/27) even with "Always Allow"; `security` is the item creator and reads silently. Keep the token only in process memory (`ClaudeOAuthTokenReader` cache), read once per launch and re-read only after it expires. Claude Code owns that credential and refreshes it; a refresh from here would rotate the refresh token and sign Steven out. An expired token is a distinct recoverable state. With persisted opt-in, TokenGauge may briefly start the official Claude CLI in safe mode without a prompt, then re-read the credential and usage endpoint; Claude Code alone renews it. Bound the owned process group, discard terminal output, and persist retry backoff. Missing credentials, 401 revocation, and 403 must never trigger that startup. - Call the usage endpoint through an ephemeral `URLSession` with no URL cache: `URLSession.shared` cached a 401 and replayed it (`cache_hit=true`) on every refresh while the token was valid, so the Fable row vanished (2026-08-26). On a real 401, invalidate the token cache and re-read the Keychain once — Claude Code rotates the token. -- Persist only percentages and reset timestamps from that response. The token, the account fields, and the spend figures never reach disk. +- Persist only percentages and reset timestamps from that response. The token, the account fields, and the spend figures never reach disk; the only account data stored is a SHA-256 fingerprint of the account uuid, which scopes the account snapshot and pace continuity. +- The account identity comes from `~/.claude.json` only; `CLAUDE_CONFIG_DIR` is not resolved (the same assumption already made for `~/.claude/projects`), so users who move that directory get no account scoping. +- The status-line capture is tagged with the identity current when `TokenGaugeCapture` writes it, so a Claude Code session that outlives a `claude /login` in another terminal can still attribute its fallback samples to the new account until that session ends. - The status-line `rate_limits` payload stays as the credential-free fallback through `TokenGaugeCapture`. It carries only the session and all-models windows, so the Fable row is absent while the fallback is in use — leave it absent rather than estimating it. - Claude activity reads only timestamps, message IDs, model identifiers, and numeric usage fields from local JSONL transcripts, aggregated into hourly per-model buckets. - The official status line exposes only the `five_hour` and `seven_day` buckets. There is no per-model quota bucket, so per-model figures always come from local transcripts and are labelled as token totals, never as quota. @@ -62,7 +64,7 @@ - Preparing a public release does not authorize publishing it. Repository visibility changes, release publication, release tags and assets require explicit publication authorization; preparation or signing approval alone is insufficient. - UI iteration is M4-only for Steven to review. Broader QA, documentation screenshots, and public release publication require his explicit scope approval. -- Do not log or persist raw JSONL lines, prompts, responses, account identifiers, emails, tokens, or credentials. +- Do not log or persist raw JSONL lines, prompts, responses, account identifiers, emails, tokens, or credentials; only the SHA-256 fingerprint of the account uuid may reach disk. - Do not invoke a model request to refresh usage. - Do not scrape provider web pages. - Do not request Accessibility, Automation or Full Disk Access. Claude uses its existing read-only Keychain credential; explain the system access prompt if its ACL requires one. diff --git a/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings b/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings index 5f07e79..7292df0 100644 --- a/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings +++ b/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings @@ -175,3 +175,4 @@ "pace.info_method" = "Calculated over at least 30 observed minutes within the last hour. Resets and long gaps are excluded."; "pace.info_retention" = "When no new usage is recorded, the last active pace stays available with its date. Measurements are stored only on this Mac."; "pace.info_saved" = "Showing the last measurement with usage while waiting for new data."; +"account.current" = "Account: %@"; diff --git a/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings b/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings index 86fa16d..19bae90 100644 --- a/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings +++ b/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings @@ -175,3 +175,4 @@ "pace.info_method" = "Se calcula con al menos 30 minutos observados dentro de la última hora. Los reinicios y los huecos largos se excluyen."; "pace.info_retention" = "Si no se registra consumo nuevo, conservamos el último ritmo activo con su fecha. Las mediciones se guardan solo en este Mac."; "pace.info_saved" = "Mostramos la última medición con consumo mientras llegan datos nuevos."; +"account.current" = "Cuenta: %@"; diff --git a/Sources/TokenGaugeApp/Services/ClaudeAccountUsageClient.swift b/Sources/TokenGaugeApp/Services/ClaudeAccountUsageClient.swift index 7ce8550..0ebdb15 100644 --- a/Sources/TokenGaugeApp/Services/ClaudeAccountUsageClient.swift +++ b/Sources/TokenGaugeApp/Services/ClaudeAccountUsageClient.swift @@ -32,8 +32,8 @@ struct ClaudeAccountUsageClient: Sendable { return URLSession(configuration: configuration) }() - func fetch(now: Date = Date()) throws -> ClaudeAccountSnapshot { - guard let token = ClaudeOAuthTokenReader.read() else { + func fetch(now: Date = Date(), identity: ClaudeAccountIdentity?) throws -> ClaudeAccountSnapshot { + guard let token = ClaudeOAuthTokenReader.read(accountUuid: identity?.accountUuid) else { throw ClaudeAccountUsageError.authenticationRequired } guard !token.isExpired else { throw ClaudeAccountUsageError.credentialExpired } @@ -49,14 +49,17 @@ struct ClaudeAccountUsageClient: Sendable { outcome = try send(request) } catch ClaudeAccountUsageError.authenticationRequired { ClaudeOAuthTokenReader.invalidate() - guard let fresh = ClaudeOAuthTokenReader.read(), !fresh.isExpired, fresh.value != token.value else { + guard let fresh = ClaudeOAuthTokenReader.read(accountUuid: identity?.accountUuid), !fresh.isExpired, + fresh.value != token.value + else { throw ClaudeAccountUsageError.authenticationRequired } request.setValue("Bearer \(fresh.value)", forHTTPHeaderField: "Authorization") outcome = try send(request) } let windows = try Self.parseWindows(outcome) - let snapshot = ClaudeAccountSnapshot(capturedAt: now, windows: windows) + let snapshot = ClaudeAccountSnapshot( + capturedAt: now, windows: windows, accountFingerprint: identity?.fingerprint) if !windows.isEmpty { try? SecureMetricStore.write(snapshot, to: UsagePaths.claudeAccountCache(homeDirectory: homeDirectory)) } @@ -76,11 +79,13 @@ struct ClaudeAccountUsageClient: Sendable { } } - func cached() -> ClaudeAccountSnapshot? { - try? SecureMetricStore.read( + func cached(identity: ClaudeAccountIdentity?) -> ClaudeAccountSnapshot? { + let snapshot = try? SecureMetricStore.read( ClaudeAccountSnapshot.self, from: UsagePaths.claudeAccountCache(homeDirectory: homeDirectory) ) + guard let snapshot, snapshot.belongs(to: identity?.fingerprint) else { return nil } + return snapshot } private func send(_ request: URLRequest) throws -> Data { diff --git a/Sources/TokenGaugeApp/Services/ClaudeRecoveryProcess.swift b/Sources/TokenGaugeApp/Services/ClaudeRecoveryProcess.swift index 3c6d13a..ac6391a 100644 --- a/Sources/TokenGaugeApp/Services/ClaudeRecoveryProcess.swift +++ b/Sources/TokenGaugeApp/Services/ClaudeRecoveryProcess.swift @@ -1,5 +1,6 @@ import Darwin import Foundation +import TokenGaugeCore enum ClaudeRecoveryProcess { static func run( @@ -19,6 +20,13 @@ enum ClaudeRecoveryProcess { shouldContinue: () -> Bool = { true }, recovered: () -> Bool ) -> Bool { guard timeout.isFinite, timeout > 0 else { return false } + let workingDirectory = UsagePaths.recoveryWorkingDirectory(homeDirectory: homeDirectory) + do { + try FileManager.default.createDirectory( + at: workingDirectory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + } catch { return false } var master: Int32 = -1 var slave: Int32 = -1 guard openpty(&master, &slave, nil, nil, nil) == 0 else { return false } @@ -35,7 +43,7 @@ enum ClaudeRecoveryProcess { defer { posix_spawnattr_destroy(&attributes) } guard posix_spawnattr_setpgroup(&attributes, 0) == 0, posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_CLOEXEC_DEFAULT)) == 0, - posix_spawn_file_actions_addchdir_np(&actions, homeDirectory.path) == 0, + posix_spawn_file_actions_addchdir_np(&actions, workingDirectory.path) == 0, posix_spawn_file_actions_addclose(&actions, master) == 0 else { return false } for descriptor in [STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO] { diff --git a/Sources/TokenGaugeApp/Services/ClaudeSessionRecovery.swift b/Sources/TokenGaugeApp/Services/ClaudeSessionRecovery.swift index a171945..4ddbe99 100644 --- a/Sources/TokenGaugeApp/Services/ClaudeSessionRecovery.swift +++ b/Sources/TokenGaugeApp/Services/ClaudeSessionRecovery.swift @@ -27,7 +27,8 @@ final class ClaudeSessionRecovery: @unchecked Sendable { shouldContinue: { authorization.isAllowed } ) { ClaudeOAuthTokenReader.invalidate() - return ClaudeOAuthTokenReader.read().map { !$0.isExpired } ?? false + let accountUuid = ClaudeAccountIdentityReader.current(homeDirectory: homeDirectory)?.accountUuid + return ClaudeOAuthTokenReader.read(accountUuid: accountUuid).map { !$0.isExpired } ?? false } } } diff --git a/Sources/TokenGaugeApp/Services/ClaudeUsageClient.swift b/Sources/TokenGaugeApp/Services/ClaudeUsageClient.swift index 9031709..0ab8390 100644 --- a/Sources/TokenGaugeApp/Services/ClaudeUsageClient.swift +++ b/Sources/TokenGaugeApp/Services/ClaudeUsageClient.swift @@ -14,6 +14,19 @@ struct ClaudeUsageResult: Sendable { let snapshot: ProviderUsageSnapshot let access: ClaudeAccessState let lastActivityAt: Date? + let accountFingerprint: String? + let accountLabel: String? + + init( + snapshot: ProviderUsageSnapshot, access: ClaudeAccessState, lastActivityAt: Date?, + accountFingerprint: String? = nil, accountLabel: String? = nil + ) { + self.snapshot = snapshot + self.access = access + self.lastActivityAt = lastActivityAt + self.accountFingerprint = accountFingerprint + self.accountLabel = accountLabel + } } struct ClaudeUsageClient: Sendable { @@ -26,24 +39,32 @@ struct ClaudeUsageClient: Sendable { func fetch( now: Date = Date(), recoveryAuthorization: ClaudeRecoveryAuthorization = ClaudeRecoveryAuthorization() ) throws -> ClaudeUsageResult { + let identity = ClaudeAccountIdentityReader.current(homeDirectory: homeDirectory) let buckets = try? fetchModelBuckets() let account = ClaudeAccountUsageClient(homeDirectory: homeDirectory) let outcome = Self.readAccount( - fetch: { try account.fetch(now: Date()) }, + fetch: { try account.fetch(now: Date(), identity: identity) }, recover: { ClaudeSessionRecovery.shared.attempt(homeDirectory: homeDirectory, authorization: recoveryAuthorization) } ) - let capture = try? SecureMetricStore.read( - ClaudeCapturedSnapshot.self, - from: UsagePaths.claudeCapture(homeDirectory: homeDirectory) - ) + let capture = Self.capture( + at: UsagePaths.claudeCapture(homeDirectory: homeDirectory), + accountFingerprint: identity?.fingerprint) return Self.resolve( - account: outcome, cached: account.cached(), capture: capture, modelBuckets: buckets ?? [], now: now, - activityReadSucceeded: buckets != nil + account: outcome, cached: account.cached(identity: identity), capture: capture, + modelBuckets: buckets ?? [], now: now, activityReadSucceeded: buckets != nil, + accountFingerprint: identity?.fingerprint, accountLabel: identity?.label ) } + static func capture(at url: URL, accountFingerprint: String?) -> ClaudeCapturedSnapshot? { + guard let snapshot = try? SecureMetricStore.read(ClaudeCapturedSnapshot.self, from: url), + snapshot.belongs(to: accountFingerprint) + else { return nil } + return snapshot + } + static func readAccount( fetch: () throws -> ClaudeAccountSnapshot, recover: () -> Bool @@ -65,7 +86,9 @@ struct ClaudeUsageClient: Sendable { capture: ClaudeCapturedSnapshot?, modelBuckets: [ModelTokenBucket], now: Date, - activityReadSucceeded: Bool = true + activityReadSucceeded: Bool = true, + accountFingerprint: String? = nil, + accountLabel: String? = nil ) -> ClaudeUsageResult { if case .success(let live) = account, !live.windows.isEmpty { return ClaudeUsageResult( @@ -73,7 +96,9 @@ struct ClaudeUsageClient: Sendable { windows: live.windows, capturedAt: live.capturedAt, modelBuckets: modelBuckets, activityReadSucceeded: activityReadSucceeded), access: .live, - lastActivityAt: capture?.capturedAt + lastActivityAt: capture?.capturedAt, + accountFingerprint: accountFingerprint, + accountLabel: accountLabel ) } let fallback = Self.fallback(cached: cached, capture: capture, modelBuckets: modelBuckets, now: now) @@ -94,7 +119,9 @@ struct ClaudeUsageClient: Sendable { windows: fallback.windows, capturedAt: fallback.capturedAt, modelBuckets: modelBuckets, activityReadSucceeded: activityReadSucceeded), access: access, - lastActivityAt: capture?.capturedAt + lastActivityAt: capture?.capturedAt, + accountFingerprint: accountFingerprint, + accountLabel: accountLabel ) } @@ -144,18 +171,22 @@ struct ClaudeUsageClient: Sendable { private func fetchModelBuckets() throws -> [ModelTokenBucket] { guard let executable = resolveCaptureExecutable() else { throw UsageDataError.executableNotFound } - return try Self.historyBuckets(executable: executable) + return try Self.historyBuckets( + executable: executable, + workingDirectory: UsagePaths.recoveryWorkingDirectory(homeDirectory: homeDirectory)) } static func historyBuckets( executable: URL, arguments: [String] = ["--history"], - timeout: TimeInterval = 20 + timeout: TimeInterval = 20, + workingDirectory: URL ) throws -> [ModelTokenBucket] { let result: ProcessResult do { result = try ProcessRunner.run( - executable: executable, arguments: arguments, input: Data(), requiredResponseIDs: [], timeout: timeout + executable: executable, arguments: arguments, input: Data(), requiredResponseIDs: [], + timeout: timeout, workingDirectory: workingDirectory ) } catch UsageDataError.timedOut { throw UsageDataError.timedOut diff --git a/Sources/TokenGaugeApp/Services/CodexAppServerClient.swift b/Sources/TokenGaugeApp/Services/CodexAppServerClient.swift index 75853fe..3d39278 100644 --- a/Sources/TokenGaugeApp/Services/CodexAppServerClient.swift +++ b/Sources/TokenGaugeApp/Services/CodexAppServerClient.swift @@ -25,7 +25,8 @@ struct CodexAppServerClient: Sendable { arguments: ["app-server", "--stdio"], input: Data(input.utf8), requiredResponseIDs: [2, 3, 4], - timeout: 8 + timeout: 8, + workingDirectory: UsagePaths.recoveryWorkingDirectory(homeDirectory: homeDirectory) ) guard result.exitCode == 0 else { throw UsageDataError.processFailed("Codex app-server exited with status \(result.exitCode)") @@ -78,13 +79,19 @@ enum ProcessRunner { arguments: [String], input: Data, requiredResponseIDs: Set, - timeout: TimeInterval + timeout: TimeInterval, + workingDirectory: URL ) throws -> ProcessResult { + try FileManager.default.createDirectory( + at: workingDirectory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) let process = Process() let inputPipe = Pipe() let outputPipe = Pipe() process.executableURL = executable process.arguments = arguments + process.currentDirectoryURL = workingDirectory var environment = ProcessInfo.processInfo.environment let inheritedPath = environment["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin" environment["PATH"] = executable.deletingLastPathComponent().path + ":" + inheritedPath diff --git a/Sources/TokenGaugeApp/Services/EffortHistoryClient.swift b/Sources/TokenGaugeApp/Services/EffortHistoryClient.swift index dce218a..d7986a0 100644 --- a/Sources/TokenGaugeApp/Services/EffortHistoryClient.swift +++ b/Sources/TokenGaugeApp/Services/EffortHistoryClient.swift @@ -5,7 +5,7 @@ import os enum EffortHistoryClient { private static let collecting = OSAllocatedUnfairLock(initialState: false) - static func collect() throws { + static func collect(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) throws { let acquired = collecting.withLock { active in guard !active else { return false } active = true @@ -22,7 +22,8 @@ enum EffortHistoryClient { } let result = try ProcessRunner.run( executable: executable, arguments: ["--record-effort-history"], input: Data(), - requiredResponseIDs: [], timeout: 25) + requiredResponseIDs: [], timeout: 25, + workingDirectory: UsagePaths.recoveryWorkingDirectory(homeDirectory: homeDirectory)) guard result.exitCode == 0 else { throw UsageDataError.processFailed("Could not update effort history") } let receipt = try JSONDecoder().decode([String: Int].self, from: result.standardOutput) guard let records = receipt["records"], records >= 0 else { throw UsageDataError.invalidPayload } diff --git a/Sources/TokenGaugeApp/Services/HistoryDashboardModel.swift b/Sources/TokenGaugeApp/Services/HistoryDashboardModel.swift index 2d1cd2c..d0b8b23 100644 --- a/Sources/TokenGaugeApp/Services/HistoryDashboardModel.swift +++ b/Sources/TokenGaugeApp/Services/HistoryDashboardModel.swift @@ -121,7 +121,7 @@ final class HistoryDashboardModel: ObservableObject { func load( mode: HistoryMode, revision: Int, previewSnapshots: [ProviderUsageSnapshot]? = nil, - paceKeys: [HistoryPaceKey] = [] + paceKeys: [HistoryPaceKey] = [], accountFingerprint: String? = nil ) async { let request = UUID() generation = request @@ -136,7 +136,9 @@ final class HistoryDashboardModel: ObservableObject { cacheOrder.removeAll(keepingCapacity: true) cacheRevision = revision } - let cacheKey = first + ":" + last + ":" + paceKeys.map(\.id).sorted().joined(separator: "|") + let cacheKey = + first + ":" + last + ":" + (accountFingerprint ?? "") + + ":" + paceKeys.map(\.id).sorted().joined(separator: "|") do { let result: ReadResult if let cached = cachedReads[cacheKey] { @@ -157,8 +159,10 @@ final class HistoryDashboardModel: ObservableObject { return ReadResult( days: Self.makeDays(interval: interval, tokens: tokens, efforts: efforts), latest: try UsageHistoryStore.recentPaces( - for: paceKeys, limitPerWindow: 1, before: now, matchingLatestQuota: true), - retained: try UsageHistoryStore.recentPaces(for: paceKeys, activeOnly: true, before: now), + for: paceKeys, limitPerWindow: 1, before: now, matchingLatestQuota: true, + accountFingerprint: accountFingerprint), + retained: try UsageHistoryStore.recentPaces( + for: paceKeys, activeOnly: true, before: now, accountFingerprint: accountFingerprint), first: try UsageHistoryStore.bounds().firstDay) }.value } diff --git a/Sources/TokenGaugeApp/Services/UsageStore.swift b/Sources/TokenGaugeApp/Services/UsageStore.swift index 4acb099..8f3370b 100644 --- a/Sources/TokenGaugeApp/Services/UsageStore.swift +++ b/Sources/TokenGaugeApp/Services/UsageStore.swift @@ -76,6 +76,8 @@ final class UsageStore: ObservableObject { recoveryAuthorization.setAllowed(claudeAutomaticRecovery && claudeCancelledAt == nil) } } + @Published private(set) var claudeAccountLabel: String? + @Published private(set) var claudeAccountFingerprint: String? @Published private(set) var claudeCancelledAt: Date? @Published private(set) var codexCancelledAt: Date? @@ -130,6 +132,15 @@ final class UsageStore: ObservableObject { } if claudeCancelledAt != nil { claude.status = .cancelled } if codexCancelledAt != nil { codex.status = .cancelled } + if self.historyReadsEnabled { + let identity = ClaudeAccountIdentityReader.current(homeDirectory: claudeClient.homeDirectory) + claudeAccountLabel = identity?.label + claudeAccountFingerprint = identity?.fingerprint + } + } + + func setPreviewAccountLabel(_ label: String?) { + claudeAccountLabel = label } func start() { @@ -156,6 +167,7 @@ final class UsageStore: ObservableObject { let claudeClient = self.claudeClient let codexClient = self.codexClient let recoveryAuthorization = self.recoveryAuthorization + let effortHome = claudeClient.homeDirectory Task { async let claudeOutcome = Task.detached(priority: .utility) { @@ -164,18 +176,23 @@ final class UsageStore: ObservableObject { async let codexOutcome = Task.detached(priority: .utility) { Self.fetchCodex(client: codexClient) }.value - let codexState = await codexOutcome applyCodexState(codexState) let claudeResult = await claudeOutcome applyRefreshResults(claudeResult: claudeResult, codexState: codexState) + if historyReadsEnabled { + claudeAccountLabel = claudeResult?.accountLabel + claudeAccountFingerprint = claudeResult?.accountFingerprint + } lastRefresh = Date() isRefreshing = false scheduleResetRefresh() if historyReadsEnabled { await Task.detached(priority: .background) { - Self.archive(claudeResult: claudeResult, codexState: codexState) - try? EffortHistoryClient.collect() + Self.archive( + claudeResult: claudeResult, codexState: codexState, + accountFingerprint: claudeResult?.accountFingerprint) + try? EffortHistoryClient.collect(homeDirectory: effortHome) }.value historyRevision &+= 1 } @@ -202,10 +219,12 @@ final class UsageStore: ObservableObject { nonisolated static func archive( claudeResult: ClaudeUsageResult?, codexState: ProviderViewState, - at url: URL = UsagePaths.history() + at url: URL = UsagePaths.history(), accountFingerprint: String? = nil ) { if let result = claudeResult { - try? UsageHistoryStore.record(result.snapshot, at: url, recordQuota: result.access == .live) + try? UsageHistoryStore.record( + result.snapshot, at: url, recordQuota: result.access == .live, + accountFingerprint: accountFingerprint) } if let snapshot = codexState.snapshot { try? UsageHistoryStore.record( diff --git a/Sources/TokenGaugeApp/Views/PopoverView.swift b/Sources/TokenGaugeApp/Views/PopoverView.swift index fd69f95..f572de3 100644 --- a/Sources/TokenGaugeApp/Views/PopoverView.swift +++ b/Sources/TokenGaugeApp/Views/PopoverView.swift @@ -53,7 +53,8 @@ struct PopoverView: View { } } await history.load( - mode: store.historyMode, revision: store.historyRevision, previewSnapshots: preview, paceKeys: keys) + mode: store.historyMode, revision: store.historyRevision, previewSnapshots: preview, + paceKeys: keys, accountFingerprint: store.claudeAccountFingerprint) } } @@ -149,6 +150,7 @@ struct PopoverView: View { claudeMenuBarSource: store.claudeMenuBarSource, claudeAutomaticRecovery: store.claudeAutomaticRecovery, showHourlyPace: store.showHourlyPace, + accountLabel: store.claudeAccountLabel, paces: paces(for: provider), previousPaces: previousPaces(for: provider) ) .frame(width: ringCardWidth(for: provider)) diff --git a/Sources/TokenGaugeApp/Views/ProviderCard.swift b/Sources/TokenGaugeApp/Views/ProviderCard.swift index fa2d832..497d654 100644 --- a/Sources/TokenGaugeApp/Views/ProviderCard.swift +++ b/Sources/TokenGaugeApp/Views/ProviderCard.swift @@ -12,6 +12,7 @@ struct ProviderCard: View { var claudeMenuBarSource: ClaudeMenuBarSource = .automatic var claudeAutomaticRecovery = false var showHourlyPace = false + var accountLabel: String? var paces: [String: QuotaPace] = [:] var previousPaces: [String: QuotaPace] = [:] @@ -144,6 +145,17 @@ struct ProviderCard: View { Text(statusSubtitle) .font(.system(size: 10)) .foregroundStyle(.secondary) + .lineLimit(1) + .layoutPriority(2) + if provider == .claude, let account = accountLabel, !account.isEmpty { + Text(account) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(-1) + .accessibilityLabel("account.current".localized(account)) + } if !showsTitle { Spacer(minLength: 4) } if state.status == .ready, let credits = state.snapshot?.availableResetCredits, credits > 0 { Text(UsageFormatters.resetCredits(credits)) diff --git a/Sources/TokenGaugeCapture/main.swift b/Sources/TokenGaugeCapture/main.swift index 7f46cae..4949e4d 100644 --- a/Sources/TokenGaugeCapture/main.swift +++ b/Sources/TokenGaugeCapture/main.swift @@ -25,7 +25,10 @@ enum TokenGaugeCapture { return } let data = FileHandle.standardInput.readDataToEndOfFile() - guard let snapshot = try? ClaudeUsageParser.capture(from: data) else { return } + guard + let snapshot = try? ClaudeUsageParser.capture( + from: data, accountFingerprint: ClaudeAccountIdentityReader.current()?.fingerprint) + else { return } try SecureMetricStore.write(snapshot, to: UsagePaths.claudeCapture()) } } diff --git a/Sources/TokenGaugeCore/ClaudeAccountIdentity.swift b/Sources/TokenGaugeCore/ClaudeAccountIdentity.swift new file mode 100644 index 0000000..8372416 --- /dev/null +++ b/Sources/TokenGaugeCore/ClaudeAccountIdentity.swift @@ -0,0 +1,106 @@ +import CryptoKit +import Foundation +import os + +public struct ClaudeAccountIdentity: Equatable, Sendable { + public let accountUuid: String + public let emailAddress: String? + public let displayName: String? + public let organizationName: String? + + public init( + accountUuid: String, + emailAddress: String? = nil, + displayName: String? = nil, + organizationName: String? = nil + ) { + self.accountUuid = accountUuid + self.emailAddress = emailAddress + self.displayName = displayName + self.organizationName = organizationName + } + + public var fingerprint: String { + let hex = Array("0123456789abcdef".utf8) + return String( + decoding: SHA256.hash(data: Data(accountUuid.utf8)).flatMap { + [hex[Int($0 >> 4)], hex[Int($0 & 15)]] + }, as: UTF8.self) + } + + public static func isCompatible(stored: String?, current: String?) -> Bool { + guard let stored, let current else { return true } + return stored == current + } + + public var label: String? { + for candidate in [emailAddress, displayName] { + if let candidate, !candidate.isEmpty { return candidate } + } + return nil + } +} + +extension ClaudeAccountIdentity { + private struct Document: Decodable { + struct Account: Decodable { + let accountUuid: String + let emailAddress: String? + let displayName: String? + let organizationName: String? + } + + let oauthAccount: Account? + } + + public init?(data: Data) { + guard let account = (try? JSONDecoder().decode(Document.self, from: data))?.oauthAccount, + !account.accountUuid.isEmpty + else { return nil } + self.init( + accountUuid: account.accountUuid, + emailAddress: account.emailAddress, + displayName: account.displayName, + organizationName: account.organizationName + ) + } +} + +public enum ClaudeAccountIdentityReader { + private struct Entry { + let url: URL + let modifiedAt: Date? + let size: Int + let identity: ClaudeAccountIdentity? + } + + private static let cache = OSAllocatedUnfairLock(initialState: nil) + + public static func current( + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> ClaudeAccountIdentity? { + read(at: UsagePaths.claudeConfig(homeDirectory: homeDirectory)) + } + + public static func read(at url: URL) -> ClaudeAccountIdentity? { + cache.withLock { entry in + let previous = entry?.url == url ? entry?.identity : nil + guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) else { + return previous + } + let modifiedAt = attributes[.modificationDate] as? Date + let size = attributes[.size] as? Int ?? 0 + if let entry, entry.url == url, entry.modifiedAt == modifiedAt, entry.size == size { + return entry.identity + } + guard let data = try? Data(contentsOf: url) else { return previous } + let identity = ClaudeAccountIdentity(data: data) + entry = Entry(url: url, modifiedAt: modifiedAt, size: size, identity: identity) + return identity + } + } + + public static func invalidate() { + cache.withLock { $0 = nil } + } +} diff --git a/Sources/TokenGaugeCore/ClaudeAccountUsageParser.swift b/Sources/TokenGaugeCore/ClaudeAccountUsageParser.swift index cd88135..c66798e 100644 --- a/Sources/TokenGaugeCore/ClaudeAccountUsageParser.swift +++ b/Sources/TokenGaugeCore/ClaudeAccountUsageParser.swift @@ -3,10 +3,16 @@ import Foundation public struct ClaudeAccountSnapshot: Codable, Equatable, Sendable { public let capturedAt: Date public let windows: [QuotaWindow] + public let accountFingerprint: String? - public init(capturedAt: Date, windows: [QuotaWindow]) { + public init(capturedAt: Date, windows: [QuotaWindow], accountFingerprint: String? = nil) { self.capturedAt = capturedAt self.windows = windows + self.accountFingerprint = accountFingerprint + } + + public func belongs(to fingerprint: String?) -> Bool { + ClaudeAccountIdentity.isCompatible(stored: accountFingerprint, current: fingerprint) } } diff --git a/Sources/TokenGaugeCore/ClaudeOAuthToken.swift b/Sources/TokenGaugeCore/ClaudeOAuthToken.swift index 4a6ea54..2503a78 100644 --- a/Sources/TokenGaugeCore/ClaudeOAuthToken.swift +++ b/Sources/TokenGaugeCore/ClaudeOAuthToken.swift @@ -15,17 +15,35 @@ public struct ClaudeOAuthToken: Sendable { public enum ClaudeOAuthTokenReader { public static let service = "Claude Code-credentials" - private static let cache = OSAllocatedUnfairLock(initialState: nil) + private struct CachedToken { + let token: ClaudeOAuthToken + let accountUuid: String? + } + + private static let cache = OSAllocatedUnfairLock(initialState: nil) + + public static func read(account: String = NSUserName(), accountUuid: String? = nil) -> ClaudeOAuthToken? { + read(accountUuid: accountUuid) { readFromKeychain(account: account) } + } - public static func read(account: String = NSUserName()) -> ClaudeOAuthToken? { + static func read(accountUuid: String?, load: @Sendable () -> ClaudeOAuthToken?) -> ClaudeOAuthToken? { cache.withLock { cached in - if let cached, !cached.isExpired { return cached } - let fresh = readFromKeychain(account: account) - if fresh != nil { cached = fresh } + if let current = cached, !current.token.isExpired, + !isForeign(cached: current.accountUuid, requested: accountUuid) + { + return current.token + } + let fresh = load() + if let fresh { cached = CachedToken(token: fresh, accountUuid: accountUuid) } return fresh } } + static func isForeign(cached: String?, requested: String?) -> Bool { + guard let requested else { return false } + return cached != requested + } + public static func invalidate() { cache.withLock { $0 = nil } } diff --git a/Sources/TokenGaugeCore/ClaudeUsageParser.swift b/Sources/TokenGaugeCore/ClaudeUsageParser.swift index c7ce062..5d4d43b 100644 --- a/Sources/TokenGaugeCore/ClaudeUsageParser.swift +++ b/Sources/TokenGaugeCore/ClaudeUsageParser.swift @@ -13,15 +13,25 @@ public struct ClaudeCapturedWindow: Codable, Equatable, Sendable { public struct ClaudeCapturedSnapshot: Codable, Equatable, Sendable { public let capturedAt: Date public let windows: [String: ClaudeCapturedWindow] + public let accountFingerprint: String? - public init(capturedAt: Date, windows: [String: ClaudeCapturedWindow]) { + public init( + capturedAt: Date, windows: [String: ClaudeCapturedWindow], accountFingerprint: String? = nil + ) { self.capturedAt = capturedAt self.windows = windows + self.accountFingerprint = accountFingerprint + } + + public func belongs(to fingerprint: String?) -> Bool { + ClaudeAccountIdentity.isCompatible(stored: accountFingerprint, current: fingerprint) } } public enum ClaudeUsageParser { - public static func capture(from statusLineData: Data, capturedAt: Date = Date()) throws -> ClaudeCapturedSnapshot { + public static func capture( + from statusLineData: Data, capturedAt: Date = Date(), accountFingerprint: String? = nil + ) throws -> ClaudeCapturedSnapshot { guard let root = try JSONSerialization.jsonObject(with: statusLineData) as? [String: Any], let rateLimits = JSONValue.dictionary(root["rate_limits"]) @@ -37,7 +47,8 @@ public enum ClaudeUsageParser { windows[key] = ClaudeCapturedWindow(usedPercentage: used, resetsAt: reset) } guard !windows.isEmpty else { throw UsageDataError.invalidPayload } - return ClaudeCapturedSnapshot(capturedAt: capturedAt, windows: windows) + return ClaudeCapturedSnapshot( + capturedAt: capturedAt, windows: windows, accountFingerprint: accountFingerprint) } public static func normalize( diff --git a/Sources/TokenGaugeCore/UsageHistoryStore.swift b/Sources/TokenGaugeCore/UsageHistoryStore.swift index c828cde..02a5e04 100644 --- a/Sources/TokenGaugeCore/UsageHistoryStore.swift +++ b/Sources/TokenGaugeCore/UsageHistoryStore.swift @@ -32,6 +32,7 @@ public struct HistoryQuotaRow: Equatable, Sendable { public let isVerified: Bool public let continuityResetAt: Date? public let continuityStartedAt: Date? + public let accountFingerprint: String? public init( sampledAt: Date, @@ -43,7 +44,8 @@ public struct HistoryQuotaRow: Equatable, Sendable { durationMinutes: Int? = nil, isVerified: Bool = false, continuityStartedAt: Date? = nil, - continuityResetAt: Date? = nil + continuityResetAt: Date? = nil, + accountFingerprint: String? = nil ) { self.sampledAt = sampledAt self.provider = provider @@ -55,6 +57,7 @@ public struct HistoryQuotaRow: Equatable, Sendable { self.isVerified = isVerified self.continuityStartedAt = continuityStartedAt self.continuityResetAt = continuityResetAt + self.accountFingerprint = accountFingerprint } } @@ -67,14 +70,17 @@ public enum UsageHistoryStore { at url: URL = UsagePaths.history(), now: Date = Date(), recordQuota: Bool = true, - recordTokens: Bool = true + recordTokens: Bool = true, + accountFingerprint: String? = nil ) throws { let handle = try open(url) defer { sqlite3_close(handle) } try prepareSchema(handle) try exec(handle, "BEGIN IMMEDIATE;") do { - if recordQuota { try writeQuota(handle, snapshot: snapshot) } + if recordQuota { + try writeQuota(handle, snapshot: snapshot, accountFingerprint: accountFingerprint) + } if recordTokens && snapshot.activityReadSucceeded { try writeTokens(handle, snapshot: snapshot, now: now) } @@ -177,7 +183,7 @@ public enum UsageHistoryStore { ) throws -> [HistoryQuotaRow] { let sql = """ SELECT COALESCE(observed_at, sampled_at), provider, window_id, display_name, used_percentage, resets_at, - duration_minutes, verified, continuity_started_at, continuity_reset_at + duration_minutes, verified, continuity_started_at, continuity_reset_at, account_fingerprint FROM quota_samples WHERE sampled_at >= ?2 AND sampled_at <= ?3 AND (?1 IS NULL OR provider = ?1) AND COALESCE(observed_at, sampled_at) >= ?4 AND COALESCE(observed_at, sampled_at) <= ?5 @@ -203,7 +209,8 @@ public enum UsageHistoryStore { durationMinutes: sqlite3_column_type(statement, 6) == SQLITE_NULL ? nil : Int(sqlite3_column_int64(statement, 6)), isVerified: sqlite3_column_int(statement, 7) == 1, - continuityStartedAt: date(statement, 8), continuityResetAt: date(statement, 9) + continuityStartedAt: date(statement, 8), continuityResetAt: date(statement, 9), + accountFingerprint: optionalText(statement, 10) ) ) } @@ -212,7 +219,7 @@ public enum UsageHistoryStore { public static func paceRows( provider: UsageProvider? = nil, windowID: String? = nil, since: Date? = nil, until: Date? = nil, - at url: URL = UsagePaths.history() + accountFingerprint: String? = nil, at url: URL = UsagePaths.history() ) throws -> [HistoryPaceRow] { let handle = try open(url) defer { sqlite3_close(handle) } @@ -222,6 +229,7 @@ public enum UsageHistoryStore { suffix: """ WHERE sampled_at >= ?1 AND sampled_at <= ?2 AND (?3 IS NULL OR provider = ?3) AND (?4 IS NULL OR window_id = ?4) + AND account_fingerprint IS ?5 ORDER BY sampled_at, provider, window_id """ ) { statement in @@ -229,12 +237,14 @@ public enum UsageHistoryStore { sqlite3_bind_double(statement, 2, until?.timeIntervalSince1970 ?? Double.greatestFiniteMagnitude) bind(statement, 3, provider?.rawValue) bind(statement, 4, windowID) + bind(statement, 5, accountFingerprint) } } public static func recentPaces( for keys: [HistoryPaceKey], activeOnly: Bool = false, limitPerWindow: Int = 2, - before: Date? = nil, matchingLatestQuota: Bool = false, at url: URL = UsagePaths.history() + before: Date? = nil, matchingLatestQuota: Bool = false, accountFingerprint: String? = nil, + at url: URL = UsagePaths.history() ) throws -> [HistoryPaceRow] { guard !keys.isEmpty, limitPerWindow > 0 else { return [] } let handle = try open(url) @@ -243,7 +253,8 @@ public enum UsageHistoryStore { var rows: [HistoryPaceRow] = [] for key in Set(keys).sorted(by: { $0.id < $1.id }) { let candidates = try readRecentPaces( - handle, key: key, activeOnly: activeOnly, limit: limitPerWindow, before: before) + handle, key: key, activeOnly: activeOnly, limit: limitPerWindow, before: before, + accountFingerprint: accountFingerprint) if matchingLatestQuota { let quota = try latestQuota(handle, provider: key.provider, windowID: key.windowID) rows += candidates.filter { quota?.isVerified == true && $0.pace.sampledAt == quota?.sampledAt } @@ -255,13 +266,15 @@ public enum UsageHistoryStore { } private static func readRecentPaces( - _ handle: OpaquePointer, key: HistoryPaceKey, activeOnly: Bool, limit: Int, before: Date? + _ handle: OpaquePointer, key: HistoryPaceKey, activeOnly: Bool, limit: Int, before: Date?, + accountFingerprint: String? = nil ) throws -> [HistoryPaceRow] { let active = activeOnly ? "AND is_active = 1" : "" return try readPaces( handle, suffix: """ WHERE provider = ?1 AND window_id = ?2 AND sampled_at < ?3 \(active) + AND account_fingerprint IS ?5 ORDER BY sampled_at DESC LIMIT ?4 """ ) { statement in @@ -269,6 +282,7 @@ public enum UsageHistoryStore { bind(statement, 2, key.windowID) sqlite3_bind_double(statement, 3, before?.timeIntervalSince1970 ?? Double.greatestFiniteMagnitude) sqlite3_bind_int64(statement, 4, Int64(limit)) + bind(statement, 5, accountFingerprint) } } @@ -301,7 +315,7 @@ public enum UsageHistoryStore { private static func archivePace( _ handle: OpaquePointer, snapshot: ProviderUsageSnapshot, window: QuotaWindow, - previous: HistoryQuotaRow?, now: Date + previous: HistoryQuotaRow?, now: Date, accountFingerprint: String? ) throws { guard window.durationMinutes == 10_080 else { return } let rows = try readQuotaRows( @@ -315,15 +329,16 @@ public enum UsageHistoryStore { && HistoryAnalytics.sameReset($0.resetsAt, window.resetsAt) && window.usedPercentage > $0.usedPercentage } ?? false - let activeHistory = try readRecentPaces(handle, key: key, activeOnly: true, limit: 1, before: nil) + let activeHistory = try readRecentPaces( + handle, key: key, activeOnly: true, limit: 1, before: nil, accountFingerprint: accountFingerprint) let seed = pace.pointsPerHour > 0 && activeHistory.isEmpty try run( handle, """ INSERT OR IGNORE INTO pace_samples (provider, window_id, sampled_at, display_name, points_per_hour, observed_minutes, - resets_at, last_used_percentage, is_active) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9); + resets_at, last_used_percentage, is_active, account_fingerprint) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10); """ ) { statement in bind(statement, 1, snapshot.provider.rawValue) @@ -339,6 +354,7 @@ public enum UsageHistoryStore { } sqlite3_bind_double(statement, 8, window.usedPercentage) sqlite3_bind_int(statement, 9, increased || seed ? 1 : 0) + bind(statement, 10, accountFingerprint) } } @@ -451,6 +467,16 @@ extension UsageHistoryStore { if !columns.contains("continuity_reset_at") { try exec(handle, "ALTER TABLE quota_samples ADD COLUMN continuity_reset_at REAL;") } + if !columns.contains("account_fingerprint") { + try exec(handle, "ALTER TABLE quota_samples ADD COLUMN account_fingerprint TEXT;") + } + var paceColumns = Set() + try query(handle, "PRAGMA table_info(pace_samples);", bindings: { _ in }) { statement in + paceColumns.insert(text(statement, 1)) + } + if !paceColumns.contains("account_fingerprint") { + try exec(handle, "ALTER TABLE pace_samples ADD COLUMN account_fingerprint TEXT;") + } var version: Int32 = 0 try query(handle, "PRAGMA user_version;", bindings: { _ in }) { statement in version = sqlite3_column_int(statement, 0) @@ -489,13 +515,15 @@ extension UsageHistoryStore { } } - private static func writeQuota(_ handle: OpaquePointer, snapshot: ProviderUsageSnapshot) throws { + private static func writeQuota( + _ handle: OpaquePointer, snapshot: ProviderUsageSnapshot, accountFingerprint: String? + ) throws { guard let capturedAt = snapshot.capturedAt else { return } let sampledAt = bucket(capturedAt) let sql = """ INSERT INTO quota_samples - (provider, window_id, sampled_at, display_name, duration_minutes, used_percentage, resets_at, verified, observed_at, continuity_started_at, continuity_reset_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?10, ?8, ?9, ?11) + (provider, window_id, sampled_at, display_name, duration_minutes, used_percentage, resets_at, verified, observed_at, continuity_started_at, continuity_reset_at, account_fingerprint) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?10, ?8, ?9, ?11, ?12) ON CONFLICT(provider, window_id, sampled_at) DO UPDATE SET display_name = excluded.display_name, duration_minutes = excluded.duration_minutes, @@ -504,7 +532,8 @@ extension UsageHistoryStore { verified = excluded.verified, observed_at = excluded.observed_at, continuity_started_at = excluded.continuity_started_at, - continuity_reset_at = excluded.continuity_reset_at + continuity_reset_at = excluded.continuity_reset_at, + account_fingerprint = excluded.account_fingerprint WHERE excluded.observed_at >= COALESCE(quota_samples.observed_at, quota_samples.sampled_at); """ for window in snapshot.windows { @@ -515,6 +544,7 @@ extension UsageHistoryStore { var continuityResetAt = window.resetsAt if let previous, previous.isVerified, valid, previous.usedPercentage <= window.usedPercentage, + previous.accountFingerprint == accountFingerprint, HistoryAnalytics.sameReset(previous.continuityResetAt ?? previous.resetsAt, window.resetsAt), previous.durationMinutes == window.durationMinutes, capturedAt.timeIntervalSince(previous.sampledAt) <= 1800, @@ -543,8 +573,11 @@ extension UsageHistoryStore { } else { sqlite3_bind_null(statement, 11) } + bind(statement, 12, accountFingerprint) } - try archivePace(handle, snapshot: snapshot, window: window, previous: previous, now: capturedAt) + try archivePace( + handle, snapshot: snapshot, window: window, previous: previous, now: capturedAt, + accountFingerprint: accountFingerprint) } } @@ -553,7 +586,7 @@ extension UsageHistoryStore { ) throws -> HistoryQuotaRow? { let sql = """ SELECT COALESCE(observed_at, sampled_at), used_percentage, resets_at, duration_minutes, - verified, continuity_started_at, continuity_reset_at + verified, continuity_started_at, continuity_reset_at, account_fingerprint FROM quota_samples WHERE provider = ?1 AND window_id = ?2 ORDER BY sampled_at DESC LIMIT 1; """ var row: HistoryQuotaRow? @@ -568,7 +601,7 @@ extension UsageHistoryStore { durationMinutes: sqlite3_column_type(statement, 3) == SQLITE_NULL ? nil : Int(sqlite3_column_int64(statement, 3)), isVerified: sqlite3_column_int(statement, 4) == 1, continuityStartedAt: date(statement, 5), - continuityResetAt: date(statement, 6)) + continuityResetAt: date(statement, 6), accountFingerprint: optionalText(statement, 7)) } return row } diff --git a/Sources/TokenGaugeCore/UsagePaths.swift b/Sources/TokenGaugeCore/UsagePaths.swift index 400dbdc..b65e025 100644 --- a/Sources/TokenGaugeCore/UsagePaths.swift +++ b/Sources/TokenGaugeCore/UsagePaths.swift @@ -21,7 +21,17 @@ public enum UsagePaths { supportDirectory(homeDirectory: homeDirectory).appending(path: "usage-history.sqlite") } + public static func recoveryWorkingDirectory( + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> URL { + supportDirectory(homeDirectory: homeDirectory).appending(path: "recovery", directoryHint: .isDirectory) + } + public static func claudeProjects(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL { homeDirectory.appending(path: ".claude/projects", directoryHint: .isDirectory) } + + public static func claudeConfig(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL { + homeDirectory.appending(path: ".claude.json") + } } diff --git a/Tests/TokenGaugeAppTests/ClaudeAccountHeaderRenderingTests.swift b/Tests/TokenGaugeAppTests/ClaudeAccountHeaderRenderingTests.swift new file mode 100644 index 0000000..b4d7a0c --- /dev/null +++ b/Tests/TokenGaugeAppTests/ClaudeAccountHeaderRenderingTests.swift @@ -0,0 +1,71 @@ +import AppKit +import SwiftUI +import TokenGaugeCore +import XCTest + +@testable import TokenGaugeApp + +@MainActor +final class ClaudeAccountHeaderRenderingTests: XCTestCase { + private let label = "very.long.account.name.for.layout@example.com" + + func testLongAccountLabelKeepsTheHeaderOnOneRowAndTruncatesOnlyItself() throws { + let language = LocalizationManager.shared.language + defer { LocalizationManager.shared.language = language } + for language in AppLanguage.allCases { + LocalizationManager.shared.language = language + let plain = try render(card(accountLabel: nil)) + let labelled = try render(card(accountLabel: label)) + let longer = try render(card(accountLabel: label + label)) + XCTAssertEqual(labelled.size.width, Theme.Layout.compactPanelWidth, accuracy: 1) + XCTAssertEqual(labelled.size.height, plain.size.height, accuracy: 1) + XCTAssertEqual(longer.size.width, labelled.size.width, accuracy: 1) + XCTAssertEqual(longer.size.height, labelled.size.height, accuracy: 1) + } + } + + func testPreviewStoresExposeTheAccountLabelWithoutReadingTheRealConfiguration() throws { + let suite = "TokenGauge.account.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let store = UsageStore(defaults: defaults, initialSnapshots: [snapshot()], historyReadsEnabled: false) + + XCTAssertNil(store.claudeAccountLabel) + store.setPreviewAccountLabel(label) + XCTAssertEqual(store.claudeAccountLabel, label) + XCTAssertNil(store.claudeAccountFingerprint) + let view = NSHostingView(rootView: PopoverView(store: store, showSettings: {}, showAbout: {})) + XCTAssertLessThanOrEqual(view.fittingSize.height, Theme.Layout.maximumPanelHeight) + } + + private func card(accountLabel: String?) -> some View { + ProviderCard( + provider: .claude, state: ProviderViewState(snapshot: snapshot(), status: .ready, isRefreshing: false), + showProviderTitle: true, accountLabel: accountLabel + ) + .frame(width: Theme.Layout.compactPanelWidth) + } + + private func snapshot() -> ProviderUsageSnapshot { + ProviderUsageSnapshot( + provider: .claude, + windows: [ + QuotaWindow( + id: "seven_day", usedPercentage: 30, resetsAt: Date().addingTimeInterval(86_400), + durationMinutes: 10_080, displayName: nil) + ], dailyUsage: [], summary: nil, availableResetCredits: nil, creditBalance: nil, + capturedAt: Date().addingTimeInterval(-600)) + } + + private func render(_ content: some View) throws -> NSBitmapImageRep { + let view = NSHostingView(rootView: content.environment(\.quotaAnimationsEnabled, false)) + view.frame = NSRect(origin: .zero, size: view.fittingSize) + let window = NSWindow(contentRect: view.frame, styleMask: [.borderless], backing: .buffered, defer: false) + window.contentView = view + defer { window.contentView = nil } + view.layoutSubtreeIfNeeded() + let bitmap = try XCTUnwrap(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + return bitmap + } +} diff --git a/Tests/TokenGaugeAppTests/ClaudeAccountScopeTests.swift b/Tests/TokenGaugeAppTests/ClaudeAccountScopeTests.swift new file mode 100644 index 0000000..1820eb3 --- /dev/null +++ b/Tests/TokenGaugeAppTests/ClaudeAccountScopeTests.swift @@ -0,0 +1,92 @@ +import Foundation +import TokenGaugeCore +import XCTest + +@testable import TokenGaugeApp + +final class ClaudeAccountScopeTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 2_000_000_000) + + func testSnapshotOfAnotherAccountIsNotPresentedAsLiveData() throws { + let home = try makeHome(accountUuid: "uuid-a") + defer { try? FileManager.default.removeItem(at: home) } + try writeSnapshot(home: home, fingerprint: ClaudeAccountIdentity(accountUuid: "uuid-b").fingerprint) + let client = ClaudeAccountUsageClient(homeDirectory: home) + let identity = ClaudeAccountIdentityReader.current(homeDirectory: home) + + XCTAssertNil(client.cached(identity: identity)) + let result = ClaudeUsageClient.resolve( + account: .failure(ClaudeAccountUsageError.unavailable), cached: client.cached(identity: identity), + capture: nil, modelBuckets: [], now: now) + XCTAssertEqual(result.access, .unavailable) + XCTAssertTrue(result.snapshot.windows.isEmpty) + } + + func testSameAccountAndLegacySnapshotsStayValid() throws { + let home = try makeHome(accountUuid: "uuid-a") + defer { try? FileManager.default.removeItem(at: home) } + let client = ClaudeAccountUsageClient(homeDirectory: home) + let identity = ClaudeAccountIdentityReader.current(homeDirectory: home) + + try writeSnapshot(home: home, fingerprint: ClaudeAccountIdentity(accountUuid: "uuid-a").fingerprint) + XCTAssertEqual(client.cached(identity: identity)?.capturedAt, now.addingTimeInterval(-1)) + + try writeSnapshot(home: home, fingerprint: nil) + XCTAssertEqual(client.cached(identity: identity)?.capturedAt, now.addingTimeInterval(-1)) + XCTAssertNil(client.cached(identity: identity)?.accountFingerprint) + XCTAssertNotNil(client.cached(identity: nil)) + } + + func testStatusLineCaptureOfAnotherAccountIsNeverUsedAsFallback() throws { + let home = try makeHome(accountUuid: "uuid-a") + defer { try? FileManager.default.removeItem(at: home) } + let url = UsagePaths.claudeCapture(homeDirectory: home) + let current = ClaudeAccountIdentity(accountUuid: "uuid-a").fingerprint + + try writeCapture(at: url, fingerprint: ClaudeAccountIdentity(accountUuid: "uuid-b").fingerprint) + XCTAssertNil(ClaudeUsageClient.capture(at: url, accountFingerprint: current)) + let result = ClaudeUsageClient.resolve( + account: .failure(ClaudeAccountUsageError.unavailable), cached: nil, + capture: ClaudeUsageClient.capture(at: url, accountFingerprint: current), modelBuckets: [], now: now) + XCTAssertEqual(result.access, .unavailable) + XCTAssertTrue(result.snapshot.windows.isEmpty) + + try writeCapture(at: url, fingerprint: current) + XCTAssertNotNil(ClaudeUsageClient.capture(at: url, accountFingerprint: current)) + try writeCapture(at: url, fingerprint: nil) + XCTAssertNotNil(ClaudeUsageClient.capture(at: url, accountFingerprint: current)) + } + + private func makeHome(accountUuid: String) throws -> URL { + ClaudeAccountIdentityReader.invalidate() + let home = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + try Data(#"{"oauthAccount":{"accountUuid":"\#(accountUuid)"}}"#.utf8) + .write(to: UsagePaths.claudeConfig(homeDirectory: home)) + return home + } + + private func writeSnapshot(home: URL, fingerprint: String?) throws { + let snapshot = ClaudeAccountSnapshot( + capturedAt: now.addingTimeInterval(-1), + windows: [ + QuotaWindow( + id: "seven_day", usedPercentage: 40, resetsAt: now.addingTimeInterval(86_400), + durationMinutes: 10_080, displayName: nil) + ], + accountFingerprint: fingerprint + ) + try SecureMetricStore.write(snapshot, to: UsagePaths.claudeAccountCache(homeDirectory: home)) + } + + private func writeCapture(at url: URL, fingerprint: String?) throws { + let capture = ClaudeCapturedSnapshot( + capturedAt: now.addingTimeInterval(-1), + windows: [ + "five_hour": ClaudeCapturedWindow(usedPercentage: 30, resetsAt: now.addingTimeInterval(3_600)) + ], + accountFingerprint: fingerprint + ) + try SecureMetricStore.write(capture, to: url) + } +} diff --git a/Tests/TokenGaugeAppTests/ClaudeHistoryProcessTests.swift b/Tests/TokenGaugeAppTests/ClaudeHistoryProcessTests.swift index 6c76765..0700117 100644 --- a/Tests/TokenGaugeAppTests/ClaudeHistoryProcessTests.swift +++ b/Tests/TokenGaugeAppTests/ClaudeHistoryProcessTests.swift @@ -21,7 +21,8 @@ final class ClaudeHistoryProcessTests: XCTestCase { let actual = try ClaudeUsageClient.historyBuckets( executable: URL(filePath: "/bin/sh"), arguments: ["-c", "/usr/bin/head -c 131072 /dev/zero >&2; /bin/cat \"$1\"", "history-test", file.path], - timeout: 3 + timeout: 3, + workingDirectory: workingDirectory() ) XCTAssertEqual(actual, buckets) } @@ -49,7 +50,8 @@ final class ClaudeHistoryProcessTests: XCTestCase { "-c", "trap '' TERM; printf '%s' $$ > \"$1\"; printf 'private' >&2; while :; do :; done", "history-test", pidFile.path, ], - timeout: 0.2 + timeout: 0.2, + workingDirectory: workingDirectory() ) ) { error in XCTAssertEqual(error as? UsageDataError, .timedOut) @@ -62,10 +64,17 @@ final class ClaudeHistoryProcessTests: XCTestCase { private func run(_ script: String) throws -> [ModelTokenBucket] { try ClaudeUsageClient.historyBuckets( - executable: URL(filePath: "/bin/sh"), arguments: ["-c", script], timeout: 2 + executable: URL(filePath: "/bin/sh"), arguments: ["-c", script], timeout: 2, + workingDirectory: workingDirectory() ) } + private func workingDirectory() -> URL { + let url = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + return url + } + private func temporaryFile() -> URL { FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) } diff --git a/Tests/TokenGaugeAppTests/ClaudeRecoveryLiveTests.swift b/Tests/TokenGaugeAppTests/ClaudeRecoveryLiveTests.swift index f1015df..d815a05 100644 --- a/Tests/TokenGaugeAppTests/ClaudeRecoveryLiveTests.swift +++ b/Tests/TokenGaugeAppTests/ClaudeRecoveryLiveTests.swift @@ -17,7 +17,8 @@ final class ClaudeRecoveryLiveTests: XCTestCase { fetch: { reads += 1 if reads == 1 { throw ClaudeAccountUsageError.credentialExpired } - return try ClaudeAccountUsageClient(homeDirectory: home).fetch() + return try ClaudeAccountUsageClient(homeDirectory: home).fetch( + identity: ClaudeAccountIdentityReader.current(homeDirectory: home)) }, recover: { ClaudeRecoveryProcess.run(executable: executable, homeDirectory: home) { diff --git a/Tests/TokenGaugeAppTests/ClaudeRecoveryProcessTests.swift b/Tests/TokenGaugeAppTests/ClaudeRecoveryProcessTests.swift index 39f0337..a23d5a1 100644 --- a/Tests/TokenGaugeAppTests/ClaudeRecoveryProcessTests.swift +++ b/Tests/TokenGaugeAppTests/ClaudeRecoveryProcessTests.swift @@ -1,5 +1,6 @@ import Darwin import Foundation +import TokenGaugeCore import XCTest @testable import TokenGaugeApp @@ -11,14 +12,14 @@ final class ClaudeRecoveryProcessTests: XCTestCase { let marker = directory.appending(path: "ready") let script = """ test -t 0 && test -t 1 && test -t 2 || exit 1 - test "$PWD" -ef "$HOME" || exit 2 + test "$PWD" -ef "$HOME/Library/Application Support/TokenGauge/recovery" || exit 2 test -z "$ANTHROPIC_API_KEY$CLAUDECODE$NODE_OPTIONS$TMUX" || exit 3 case "$PATH" in *evil*) exit 4;; esac stty -icanon min 0 time 1 test "$(dd bs=1 count=1 2>/dev/null | wc -c | tr -d ' ')" = 0 || exit 5 /usr/bin/head -c 1048576 /dev/zero /usr/bin/head -c 1048576 /dev/zero >&2 - touch ready + touch "$HOME/ready" sleep 10 """ var checks: [TimeInterval] = [] @@ -51,9 +52,9 @@ final class ClaudeRecoveryProcessTests: XCTestCase { defer { try? FileManager.default.removeItem(at: directory) } let script = """ trap '' TERM - echo $$ > parent + echo $$ > "$HOME/parent" /bin/sh -c 'trap "" TERM; while :; do sleep 1; done' & - echo $! > child + echo $! > "$HOME/child" wait """ let start = ProcessInfo.processInfo.systemUptime @@ -73,7 +74,7 @@ final class ClaudeRecoveryProcessTests: XCTestCase { let directory = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: directory) } let executable = directory.appending(path: "fake-claude") - try "#!/bin/sh\n[ \"$#\" = 1 ] && [ \"$1\" = --safe-mode ] && touch ready\nsleep 10\n" + try "#!/bin/sh\n[ \"$#\" = 1 ] && [ \"$1\" = --safe-mode ] && touch \"$HOME/ready\"\nsleep 10\n" .write(to: executable, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) XCTAssertTrue( @@ -91,7 +92,8 @@ final class ClaudeRecoveryProcessTests: XCTestCase { XCTAssertFalse( ClaudeRecoveryProcess.run( executable: URL(filePath: "/bin/sh"), homeDirectory: directory, timeout: 10, - arguments: ["-c", "trap '' TERM; echo $$ > parent; while :; do sleep 1; done"], environment: [:], + arguments: ["-c", "trap '' TERM; echo $$ > \"$HOME/parent\"; while :; do sleep 1; done"], + environment: [:], shouldContinue: { !FileManager.default.fileExists(atPath: marker.path) } ) { false } ) @@ -118,6 +120,43 @@ final class ClaudeRecoveryProcessTests: XCTestCase { }) } + func testChildRunsInDedicatedDirectoryCreatedPrivatelyInsteadOfHome() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let recovery = UsagePaths.recoveryWorkingDirectory(homeDirectory: directory) + XCTAssertFalse(FileManager.default.fileExists(atPath: recovery.path)) + XCTAssertFalse( + ClaudeRecoveryProcess.run( + executable: URL(filePath: "/bin/sh"), homeDirectory: directory, timeout: 4, + arguments: ["-c", "pwd > \"$TMPDIR/cwd\""], environment: ["TMPDIR": directory.path] + ) { false } + ) + let value = try String(contentsOf: directory.appending(path: "cwd"), encoding: .utf8) + let observed = URL(filePath: value.trimmingCharacters(in: .whitespacesAndNewlines)) + XCTAssertEqual(observed.resolvingSymlinksInPath().path, recovery.resolvingSymlinksInPath().path) + XCTAssertNotEqual(observed.resolvingSymlinksInPath().path, directory.resolvingSymlinksInPath().path) + let permissions = + try FileManager.default.attributesOfItem(atPath: recovery.path)[.posixPermissions] as? Int + XCTAssertEqual(permissions, 0o700) + } + + func testAppServerAndCaptureHelperRunInTheSameDedicatedDirectory() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let recovery = UsagePaths.recoveryWorkingDirectory(homeDirectory: directory) + let result = try ProcessRunner.run( + executable: URL(filePath: "/bin/sh"), arguments: ["-c", "pwd"], input: Data(), + requiredResponseIDs: [], timeout: 4, workingDirectory: recovery + ) + let value = String(decoding: result.standardOutput, as: UTF8.self) + let observed = URL(filePath: value.trimmingCharacters(in: .whitespacesAndNewlines)) + XCTAssertEqual(observed.resolvingSymlinksInPath().path, recovery.resolvingSymlinksInPath().path) + XCTAssertNotEqual(observed.resolvingSymlinksInPath().path, directory.resolvingSymlinksInPath().path) + let permissions = + try FileManager.default.attributesOfItem(atPath: recovery.path)[.posixPermissions] as? Int + XCTAssertEqual(permissions, 0o700) + } + private func run( _ script: String, directory: URL, timeout: TimeInterval, recovered: () -> Bool ) -> Bool { diff --git a/Tests/TokenGaugeAppTests/PortableRuntimeTests.swift b/Tests/TokenGaugeAppTests/PortableRuntimeTests.swift index 402ce1e..dd3e179 100644 --- a/Tests/TokenGaugeAppTests/PortableRuntimeTests.swift +++ b/Tests/TokenGaugeAppTests/PortableRuntimeTests.swift @@ -90,10 +90,16 @@ final class PortableRuntimeTests: XCTestCase { XCTAssertLessThan(ProcessInfo.processInfo.systemUptime - start, 2) } + private func workingDirectory() -> URL { + let url = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + return url + } + private func run(_ script: String, input: String = "", timeout: TimeInterval = 2) throws -> ProcessResult { try ProcessRunner.run( executable: URL(filePath: "/bin/sh"), arguments: ["-c", script], input: Data(input.utf8), - requiredResponseIDs: [3, 4], timeout: timeout + requiredResponseIDs: [3, 4], timeout: timeout, workingDirectory: workingDirectory() ) } } diff --git a/Tests/TokenGaugeCoreTests/ClaudeAccountIdentityTests.swift b/Tests/TokenGaugeCoreTests/ClaudeAccountIdentityTests.swift new file mode 100644 index 0000000..9165419 --- /dev/null +++ b/Tests/TokenGaugeCoreTests/ClaudeAccountIdentityTests.swift @@ -0,0 +1,94 @@ +import Foundation +import XCTest + +@testable import TokenGaugeCore + +final class ClaudeAccountIdentityTests: XCTestCase { + func testDecodesAccountAndIgnoresUnknownKeys() { + let payload = Data( + #""" + {"numStartups":12,"projects":{"/tmp/a":{"allowedTools":[]}}, + "oauthAccount":{"accountUuid":"uuid-a","emailAddress":"one@example.com", + "displayName":"One","organizationName":"Org","seatTier":"max"}} + """#.utf8 + ) + let identity = ClaudeAccountIdentity(data: payload) + XCTAssertEqual(identity?.accountUuid, "uuid-a") + XCTAssertEqual(identity?.emailAddress, "one@example.com") + XCTAssertEqual(identity?.displayName, "One") + XCTAssertEqual(identity?.organizationName, "Org") + XCTAssertEqual(identity?.label, "one@example.com") + } + + func testFallsBackToDisplayNameAndRejectsIncompletePayloads() { + let onlyName = ClaudeAccountIdentity( + data: Data(#"{"oauthAccount":{"accountUuid":"uuid-b","displayName":"Two"}}"#.utf8)) + XCTAssertEqual(onlyName?.label, "Two") + XCTAssertNil(ClaudeAccountIdentity(data: Data(#"{"numStartups":1}"#.utf8))) + XCTAssertNil(ClaudeAccountIdentity(data: Data(#"{"oauthAccount":{"emailAddress":"one@example.com"}}"#.utf8))) + XCTAssertNil(ClaudeAccountIdentity(data: Data(#"{"oauthAccount":{"accountUuid":""}}"#.utf8))) + XCTAssertNil(ClaudeAccountIdentity(data: Data("not json".utf8))) + } + + func testFingerprintIsAStableHexDigestThatHidesTheUuid() { + let identity = ClaudeAccountIdentity(accountUuid: "uuid-a", emailAddress: "one@example.com") + XCTAssertEqual(identity.fingerprint, ClaudeAccountIdentity(accountUuid: "uuid-a").fingerprint) + XCTAssertNotEqual(identity.fingerprint, ClaudeAccountIdentity(accountUuid: "uuid-b").fingerprint) + XCTAssertEqual(identity.fingerprint.count, 64) + XCTAssertFalse(identity.fingerprint.contains("uuid-a")) + XCTAssertTrue(identity.fingerprint.allSatisfy { $0.isHexDigit && !$0.isUppercase }) + } + + func testReturnsNilForMissingFile() { + ClaudeAccountIdentityReader.invalidate() + let home = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + XCTAssertNil(ClaudeAccountIdentityReader.current(homeDirectory: home)) + } + + func testRereadsOnlyWhenModificationDateOrSizeChanged() throws { + ClaudeAccountIdentityReader.invalidate() + let home = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + let file = UsagePaths.claudeConfig(homeDirectory: home) + try Data(#"{"oauthAccount":{"accountUuid":"uuid-a"}}"#.utf8).write(to: file) + let stamp = Date(timeIntervalSince1970: 1_756_300_000) + try FileManager.default.setAttributes([.modificationDate: stamp], ofItemAtPath: file.path) + + XCTAssertEqual(ClaudeAccountIdentityReader.current(homeDirectory: home)?.accountUuid, "uuid-a") + + try Data(#"{"oauthAccount":{"accountUuid":"uuid-b"}}"#.utf8).write(to: file) + try FileManager.default.setAttributes([.modificationDate: stamp], ofItemAtPath: file.path) + XCTAssertEqual(ClaudeAccountIdentityReader.current(homeDirectory: home)?.accountUuid, "uuid-a") + + try FileManager.default.setAttributes( + [.modificationDate: stamp.addingTimeInterval(60)], ofItemAtPath: file.path) + XCTAssertEqual(ClaudeAccountIdentityReader.current(homeDirectory: home)?.accountUuid, "uuid-b") + } + func testTransientReadFailureKeepsTheLastIdentityButLogoutClearsIt() throws { + ClaudeAccountIdentityReader.invalidate() + let home = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: 0o644], + ofItemAtPath: UsagePaths.claudeConfig(homeDirectory: home).path) + try? FileManager.default.removeItem(at: home) + } + let file = UsagePaths.claudeConfig(homeDirectory: home) + try Data(#"{"oauthAccount":{"accountUuid":"uuid-a"}}"#.utf8).write(to: file) + XCTAssertEqual(ClaudeAccountIdentityReader.current(homeDirectory: home)?.accountUuid, "uuid-a") + + try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: file.path) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: 1_756_400_000)], ofItemAtPath: file.path) + XCTAssertEqual(ClaudeAccountIdentityReader.current(homeDirectory: home)?.accountUuid, "uuid-a") + + let moved = home.appending(path: "moved.json") + try FileManager.default.moveItem(at: file, to: moved) + XCTAssertEqual(ClaudeAccountIdentityReader.current(homeDirectory: home)?.accountUuid, "uuid-a") + + try Data(#"{"numStartups":3}"#.utf8).write(to: file) + XCTAssertNil(ClaudeAccountIdentityReader.current(homeDirectory: home)) + } +} diff --git a/Tests/TokenGaugeCoreTests/ClaudeOAuthTokenTests.swift b/Tests/TokenGaugeCoreTests/ClaudeOAuthTokenTests.swift index 8550f20..34f428a 100644 --- a/Tests/TokenGaugeCoreTests/ClaudeOAuthTokenTests.swift +++ b/Tests/TokenGaugeCoreTests/ClaudeOAuthTokenTests.swift @@ -18,4 +18,48 @@ final class ClaudeOAuthTokenTests: XCTestCase { XCTAssertNil(ClaudeOAuthTokenReader.parse(Data(#"{"other":1}"#.utf8))) XCTAssertNil(ClaudeOAuthTokenReader.parse(Data("not json".utf8))) } + + func testAccountSwitchReadsTheKeychainOnceAndKeepsTheCacheOtherwise() { + ClaudeOAuthTokenReader.invalidate() + nonisolated(unsafe) var reads = 0 + let load: (String) -> @Sendable () -> ClaudeOAuthToken? = { value in + { + reads += 1 + return ClaudeOAuthToken(value: value, expiresAt: Date().addingTimeInterval(3600)) + } + } + + XCTAssertEqual(ClaudeOAuthTokenReader.read(accountUuid: "uuid-a", load: load("token-a"))?.value, "token-a") + XCTAssertEqual(ClaudeOAuthTokenReader.read(accountUuid: "uuid-a", load: load("token-a"))?.value, "token-a") + XCTAssertEqual(reads, 1) + + XCTAssertEqual(ClaudeOAuthTokenReader.read(accountUuid: "uuid-b", load: load("token-b"))?.value, "token-b") + XCTAssertEqual(reads, 2) + + XCTAssertEqual(ClaudeOAuthTokenReader.read(accountUuid: "uuid-b", load: load("token-b"))?.value, "token-b") + XCTAssertEqual(ClaudeOAuthTokenReader.read(accountUuid: nil, load: load("token-b"))?.value, "token-b") + XCTAssertEqual(reads, 2) + ClaudeOAuthTokenReader.invalidate() + } + + func testUntaggedCacheIsNeverPromotedToARequestedAccount() { + ClaudeOAuthTokenReader.invalidate() + nonisolated(unsafe) var reads = 0 + let load: (String) -> @Sendable () -> ClaudeOAuthToken? = { value in + { + reads += 1 + return ClaudeOAuthToken(value: value, expiresAt: Date().addingTimeInterval(3600)) + } + } + + XCTAssertEqual(ClaudeOAuthTokenReader.read(accountUuid: nil, load: load("token-a"))?.value, "token-a") + XCTAssertEqual(reads, 1) + + XCTAssertEqual(ClaudeOAuthTokenReader.read(accountUuid: "uuid-b", load: load("token-b"))?.value, "token-b") + XCTAssertEqual(reads, 2) + + XCTAssertEqual(ClaudeOAuthTokenReader.read(accountUuid: "uuid-b", load: load("token-b"))?.value, "token-b") + XCTAssertEqual(reads, 2) + ClaudeOAuthTokenReader.invalidate() + } } diff --git a/Tests/TokenGaugeCoreTests/HistoryPaceStoreTests.swift b/Tests/TokenGaugeCoreTests/HistoryPaceStoreTests.swift index 28638ce..5e74562 100644 --- a/Tests/TokenGaugeCoreTests/HistoryPaceStoreTests.swift +++ b/Tests/TokenGaugeCoreTests/HistoryPaceStoreTests.swift @@ -128,9 +128,34 @@ final class HistoryPaceStoreTests: XCTestCase { XCTAssertTrue(try UsageHistoryStore.paceRows(at: database).isEmpty) } + func testPaceReadsOnlyReturnRowsOfTheCurrentAccount() throws { + try record(0, 10) + try record(15, 12) + try record(30, 14) + for (minute, used) in [(45, 20.0), (60, 24), (75, 28)] { + try record(Double(minute), used, fingerprint: "account-a") + } + + let legacyRows = try UsageHistoryStore.paceRows(at: database) + let accountRows = try UsageHistoryStore.paceRows(accountFingerprint: "account-a", at: database) + XCTAssertFalse(legacyRows.isEmpty) + XCTAssertFalse(accountRows.isEmpty) + XCTAssertTrue(legacyRows.allSatisfy { $0.pace.sampledAt <= instant(30) }) + XCTAssertTrue(accountRows.allSatisfy { $0.pace.sampledAt > instant(30) }) + XCTAssertTrue(try UsageHistoryStore.paceRows(accountFingerprint: "account-b", at: database).isEmpty) + XCTAssertTrue( + try UsageHistoryStore.recentPaces(for: [key], accountFingerprint: "account-b", at: database).isEmpty) + XCTAssertEqual( + try UsageHistoryStore.recentPaces(for: [key], accountFingerprint: "account-a", at: database) + .map { $0.pace.sampledAt }, + accountRows.map { $0.pace.sampledAt }.reversed()) + } + private func instant(_ minutes: Double) -> Date { base.addingTimeInterval(minutes * 60) } - private func record(_ minutes: Double, _ used: Double, jitter: Double = 0, archive: Bool = true) throws { + private func record( + _ minutes: Double, _ used: Double, jitter: Double = 0, archive: Bool = true, fingerprint: String? = nil + ) throws { let snapshot = ProviderUsageSnapshot( provider: .claude, windows: [ @@ -139,6 +164,7 @@ final class HistoryPaceStoreTests: XCTestCase { durationMinutes: 10_080, displayName: "Fable") ], dailyUsage: [], summary: nil, availableResetCredits: nil, creditBalance: nil, capturedAt: instant(minutes), activityReadSucceeded: false) - try UsageHistoryStore.record(snapshot, at: database, now: instant(minutes), recordQuota: archive) + try UsageHistoryStore.record( + snapshot, at: database, now: instant(minutes), recordQuota: archive, accountFingerprint: fingerprint) } } diff --git a/Tests/TokenGaugeCoreTests/QuotaContinuityTests.swift b/Tests/TokenGaugeCoreTests/QuotaContinuityTests.swift index 84a882c..d318848 100644 --- a/Tests/TokenGaugeCoreTests/QuotaContinuityTests.swift +++ b/Tests/TokenGaugeCoreTests/QuotaContinuityTests.swift @@ -77,9 +77,51 @@ final class QuotaContinuityTests: XCTestCase { XCTAssertEqual(try UsageHistoryStore.quotaRows(at: database).last?.continuityStartedAt, instant(0)) } + func testDifferentAccountFingerprintsNeverFormOnePace() throws { + try record(0, used: 10, fingerprint: "account-a") + try record(15, used: 11, fingerprint: "account-a") + try record(30, used: 12, fingerprint: "account-a") + XCTAssertNotNil(try pace(at: 30)) + + try record(45, used: 20, fingerprint: "account-b") + try record(60, used: 21, fingerprint: "account-b") + XCTAssertEqual(try UsageHistoryStore.quotaRows(at: database).last?.continuityStartedAt, instant(45)) + XCTAssertNil(try pace(at: 60)) + + try record(75, used: 22, fingerprint: "account-b") + XCTAssertNotNil(try pace(at: 75)) + } + + func testLegacyRowsBreakOnceWhenAFingerprintAppearsAndThenRebuild() throws { + try record(0, used: 10) + try record(15, used: 11) + try record(30, used: 12) + XCTAssertNotNil(try pace(at: 30)) + + try record(45, used: 13, fingerprint: "account-a") + XCTAssertEqual(try UsageHistoryStore.quotaRows(at: database).last?.continuityStartedAt, instant(45)) + XCTAssertNil(try pace(at: 45)) + + try record(60, used: 14, fingerprint: "account-a") + try record(75, used: 15, fingerprint: "account-a") + XCTAssertEqual(try UsageHistoryStore.quotaRows(at: database).last?.continuityStartedAt, instant(45)) + XCTAssertNotNil(try pace(at: 75)) + } + + func testAlwaysUnknownIdentityKeepsOneContinuousPace() throws { + try record(0, used: 10) + try record(15, used: 11) + try record(30, used: 12) + XCTAssertEqual(try UsageHistoryStore.quotaRows(at: database).last?.continuityStartedAt, instant(0)) + let result = try XCTUnwrap(pace(at: 30)) + XCTAssertEqual(result.observedMinutes, 30) + } + private func instant(_ minutes: Double) -> Date { base.addingTimeInterval(minutes * 60) } - private func record(_ minutes: Double, used: Double, reset: Date? = nil) throws { + private func record( + _ minutes: Double, used: Double, reset: Date? = nil, fingerprint: String? = nil + ) throws { let snapshot = ProviderUsageSnapshot( provider: .claude, windows: [ @@ -89,7 +131,8 @@ final class QuotaContinuityTests: XCTestCase { ], dailyUsage: [], summary: nil, availableResetCredits: nil, creditBalance: nil, capturedAt: instant(minutes), activityReadSucceeded: false) - try UsageHistoryStore.record(snapshot, at: database, now: instant(minutes)) + try UsageHistoryStore.record( + snapshot, at: database, now: instant(minutes), accountFingerprint: fingerprint) } private func pace(at minutes: Double) throws -> QuotaPace? {