diff --git a/Sources/OpenUsage/App/AppContainer.swift b/Sources/OpenUsage/App/AppContainer.swift index 86859deab..62dabb244 100644 --- a/Sources/OpenUsage/App/AppContainer.swift +++ b/Sources/OpenUsage/App/AppContainer.swift @@ -7,6 +7,12 @@ import Observation @MainActor @Observable final class AppContainer { + /// The AppDelegate replaces its immutable registry, layout, runtime, and status-item graph when + /// a login changes; posting only after a verified graph diff keeps account cards stable otherwise. + static let accountGraphDidChangeNotification = Notification.Name( + "OpenUsage.accountGraphDidChange" + ) + let registry: WidgetRegistry let layout: LayoutStore let dataStore: WidgetDataStore @@ -41,6 +47,9 @@ final class AppContainer { /// provider were ever removed from the registry. Injected into the view tree via /// `\.codexResetClaim`. let codexResetClaim: CodexResetClaimService? + /// The authoritative account registry shared by discovery, runtime assembly, and live renames. + let accounts: ProviderAccountsStore + /// The provider runtimes, kept so on-demand credential detection (the Customize "Reset All" reseed) /// can re-probe `hasLocalCredentials()` the same way first-run seeding does. private let providers: [ProviderRuntime] @@ -56,6 +65,8 @@ final class AppContainer { /// Persists a fresh `ShellEnvironmentSnapshot` once the login-shell capture completes, so the next /// launch can read shell-exported facts (provider home overrides) even when its own capture is slow. private let shellEnvironmentSnapshotTask: Task + /// Cheap default-home identity checks catch live swaps promptly; full discovery is throttled. + private let accountGraphWatchTask: Task /// `isFreshInstall` must be captured by the caller BEFORE `SettingsMigrator.migrate()` runs (the /// migrator's schema stamp makes the defaults domain non-empty). See `AppDelegate`. @@ -67,11 +78,18 @@ final class AppContainer { // Once the capture lands, persist its identity-relevant facts so the NEXT launch has them // even if that launch's own capture is slow (see `ShellEnvironmentSnapshot`). self.shellEnvironmentSnapshotTask = ShellEnvironmentSnapshotStore(defaults: .standard).startRefreshTask() - // The launch account pass: which account is signed in at each family's default home. Feeds - // the snapshot cache's account stamp and reconciles the account registry. - let accountAssembly = ProviderAccountAssembly.make(waitsForLoginShell: true) + // Reconcile one shared registry before constructing any account-aware runtime. Every Claude + // runtime then follows its record's current verified source, regardless of its card id. + let accounts = ProviderAccountsStore() + let accountAssembly = ProviderAccountAssembly.make( + accountsStore: accounts, + waitsForLoginShell: true + ) + self.accounts = accounts - let providers = ProviderCatalog.make() + let providers = ProviderCatalog.make( + claude: accountAssembly.claudeRuntimePlan + ) let registry = WidgetRegistry.from(providers) let apiKeyProviders = providers.compactMap { $0 as? any APIKeyManaging } let enablement = ProviderEnablementStore() @@ -86,7 +104,8 @@ final class AppContainer { isProviderEnabled: { [enablement] in enablement.isEnabled($0) }, orderedDescriptors: { [layout] in layout.visiblePlaced.compactMap { layout.descriptor(for: $0) } }, notificationSettings: { notificationSettings }, - providerIdentityKeys: accountAssembly.identityKeysByCard + providerIdentityKeys: accountAssembly.identityKeysByCard, + resolveDisplayName: { [accounts] in accounts.resolvedDisplayName(cardID: $0) } ) let iCloudSync = ICloudUsageSyncStore(dataStore: dataStore) // Re-enabling a provider should fetch it promptly, so clear any leftover failure backoff before @@ -99,19 +118,19 @@ final class AppContainer { // Fresh installs start minimal: seed the enabled-provider list (Claude/Codex/Cursor right away, // then the detected set once the local credential probe finishes). No-op on every later launch. let onboarding = OnboardingStore() - self.seedTask = FirstRunSeeder.seedIfNeeded( + let firstRunSeedTask = FirstRunSeeder.seedIfNeeded( isFreshInstall: isFreshInstall, providers: providers, enablement: enablement, onboarding: onboarding ) + self.seedTask = firstRunSeedTask // Providers added by an update get the same credential detection on their first launch — enabled // only when the user actually has the tool. Runs every launch; a no-op unless the registry has a // provider this install has never seen (fresh installs were just baselined by FirstRunSeeder). - self.newProviderTask = NewProviderSeeder.reconcileIfNeeded( - providers: providers, - enablement: enablement - ) + self.newProviderTask = firstRunSeedTask == nil + ? NewProviderSeeder.reconcileIfNeeded(providers: providers, enablement: enablement) + : nil self.providers = providers self.onboarding = onboarding self.registry = registry @@ -196,7 +215,7 @@ final class AppContainer { self.telemetry = telemetry self.transparency = PopoverTransparencyStore() self.privacy = MenuBarPrivacyStore() - self.localAPI = LocalUsageServer(state: { [layout, enablement, dataStore] in + self.localAPI = LocalUsageServer(state: { [layout, enablement, dataStore, accounts] in LocalUsageAPI.State( enabledOrderedIDs: layout.orderedProviderIDs().filter { enablement.isEnabled($0) }, knownIDs: Set(registry.providers.map(\.id)), @@ -204,8 +223,13 @@ final class AppContainer { limitDescriptors: registry.limitDescriptorsByProvider, errors: dataStore.providerErrors ) + .resolvingDisplayNames(accounts.resolvedDisplayNamesByCardID) }) self.refreshTask = Self.startPeriodicRefresh(dataStore: dataStore, telemetry: telemetry) + self.accountGraphWatchTask = Self.startAccountGraphWatch( + accounts: accounts, + initialAssembly: accountAssembly + ) localAPI.start() // Become the notification-center delegate so banners show while frontmost — a menu-bar accessory // effectively always is. Notification authorization is requested the first time a trigger is @@ -218,6 +242,32 @@ final class AppContainer { seedTask?.cancel() newProviderTask?.cancel() shellEnvironmentSnapshotTask.cancel() + accountGraphWatchTask.cancel() + } + + /// Stop every long-lived service before the AppDelegate installs the replacement graph. + func shutdownForAccountGraphReload() async { + accountGraphWatchTask.cancel() + refreshTask.cancel() + seedTask?.cancel() + newProviderTask?.cancel() + shellEnvironmentSnapshotTask.cancel() + iCloudSync.shutdownForAccountGraphReload() + await localAPI.stop() + } + + func displayName(for provider: Provider) -> String { + accounts.resolvedDisplayName(cardID: provider.id) ?? provider.displayName + } + + func displayName(for providerID: String) -> String { + accounts.resolvedDisplayName(cardID: providerID) + ?? registry.provider(id: providerID)?.displayName + ?? providerID + } + + func canRename(_ providerID: String) -> Bool { + accounts.runtimeRecord(for: providerID) != nil } /// Re-runs first-launch credential detection on demand — the enablement half of the Customize @@ -263,6 +313,91 @@ final class AppContainer { AppLog.info(.config, "All settings reset to defaults") } + /// Watch one tiny default-home identity file every five seconds, and only walk config dirs and + /// Cowork sandboxes after a detected swap or once a minute. The potentially hundreds of identity + /// files are collected off the main actor; account-store reconciliation alone returns to it. + private static func startAccountGraphWatch( + accounts: ProviderAccountsStore, + initialAssembly: ProviderAccountAssembly + ) -> Task { + let initialDefaultIdentity = DefaultAccountObserver().observeClaude() + return Task { @MainActor in + var previousDefaultIdentity = initialDefaultIdentity + var checksSinceFullDiscovery = 0 + while !Task.isCancelled { + do { + try await Task.sleep(for: .seconds(5)) + } catch { + return + } + guard !Task.isCancelled else { return } + let currentDefaultIdentity = DefaultAccountObserver().observeClaude() + checksSinceFullDiscovery += 1 + let defaultChanged = currentDefaultIdentity != previousDefaultIdentity + guard defaultChanged || checksSinceFullDiscovery >= 12 else { continue } + + previousDefaultIdentity = currentDefaultIdentity + checksSinceFullDiscovery = 0 + let preparedDiscovery = await prepareAccountDiscovery() + guard !Task.isCancelled else { return } + guard DefaultAccountObserver().observeClaude() == currentDefaultIdentity else { + AppLog.info(.config, "accounts: default login changed during discovery; retrying with a fresh graph scan") + continue + } + let currentAssembly = ProviderAccountAssembly.make( + accountsStore: accounts, + waitsForLoginShell: true, + preparedDiscovery: preparedDiscovery + ) + guard !Task.isCancelled else { return } + guard currentAssembly.claudeCards != initialAssembly.claudeCards + || currentAssembly.identityKeysByCard != initialAssembly.identityKeysByCard + || currentAssembly.allowsUnboundClaudeFallback != initialAssembly.allowsUnboundClaudeFallback + else { continue } + + AppLog.info(.config, "accounts: verified account sources changed; rebuilding the runtime graph") + NotificationCenter.default.post( + name: Self.accountGraphDidChangeNotification, + object: nil + ) + return + } + } + } + + /// Collect blocking filesystem/keychain-attribute discovery on a detached utility executor. + /// Cancelling the graph watcher also cancels its detached scan; no stale result may reconcile. + static func prepareAccountDiscovery( + configScan: @escaping @Sendable () -> ClaudeConfigDirDiscovery.Result = { + ClaudeConfigDirDiscovery().run() + }, + coworkScan: @escaping @Sendable () -> ClaudeCoworkDiscovery.Result = { + ClaudeCoworkDiscovery().run() + } + ) async -> PreparedProviderAccountDiscovery { + let task = Task.detached(priority: .utility) { + guard !Task.isCancelled else { + return PreparedProviderAccountDiscovery( + config: ClaudeConfigDirDiscovery.Result(), + cowork: ClaudeCoworkDiscovery.Result(truncated: true) + ) + } + let config = configScan() + guard !Task.isCancelled else { + return PreparedProviderAccountDiscovery( + config: config, + cowork: ClaudeCoworkDiscovery.Result(truncated: true) + ) + } + return PreparedProviderAccountDiscovery(config: config, cowork: coworkScan()) + } + return await withTaskCancellationHandler { + await task.value + } onCancel: { + task.cancel() + } + } + /// Drives live updates: refresh on launch, then again every refresh interval. Each pass honors the /// cache, so it only hits the network once a snapshot has actually expired. `@Observable` propagates /// the resulting snapshot changes to the menu-bar label and any open widgets, so the UI refreshes on diff --git a/Sources/OpenUsage/App/FirstRunSeeder.swift b/Sources/OpenUsage/App/FirstRunSeeder.swift index c34608c2e..0d1e92f34 100644 --- a/Sources/OpenUsage/App/FirstRunSeeder.swift +++ b/Sources/OpenUsage/App/FirstRunSeeder.swift @@ -62,14 +62,21 @@ enum FirstRunSeeder { logPrefix: String, probeVerb: String = "probing" ) -> Task { - let fallback = fallbackProviderIDs.intersection(Set(providers.map(\.provider.id))) + let providerIDs = Set(providers.map(\.provider.id)) + let fallback = fallbackProviderIDs.intersection(providerIDs) enablement.seedEnabledProviders(fallback) + enablement.markProviderDetectionPending(providerIDs) AppLog.info(.config, "\(logPrefix): seeded providers \(fallback.sorted()); \(probeVerb) local credentials") return Task { let detected = await detectLocalProviders(providers) + guard !Task.isCancelled else { return } + defer { enablement.finishProviderDetection(providerIDs) } AppLog.info(.config, "\(logPrefix): detected credentials for \(detected.sorted())") guard enablement.enabledIDs == fallback, !detected.isEmpty else { return } - enablement.seedEnabledProviders(detected) + let honoringUserChoices = detected.intersection(enablement.pendingDetectionIDs) + .union(fallback.subtracting(enablement.pendingDetectionIDs)) + guard !honoringUserChoices.isEmpty else { return } + enablement.seedEnabledProviders(honoringUserChoices) } } diff --git a/Sources/OpenUsage/App/NewProviderSeeder.swift b/Sources/OpenUsage/App/NewProviderSeeder.swift index 0fb8b214c..05d18468d 100644 --- a/Sources/OpenUsage/App/NewProviderSeeder.swift +++ b/Sources/OpenUsage/App/NewProviderSeeder.swift @@ -32,17 +32,28 @@ enum NewProviderSeeder { } let newIDs = enablement.registerKnownProviders(currentIDs) - guard !newIDs.isEmpty else { return nil } - AppLog.info(.config, "new providers since last run: \(newIDs.sorted()); probing local credentials") + let pendingIDs = enablement.pendingDetectionIDs.intersection(currentIDs) + let detectionIDs = newIDs.union(pendingIDs) + guard !detectionIDs.isEmpty else { return nil } + enablement.markProviderDetectionPending(detectionIDs) + AppLog.info( + .config, + "new or interrupted provider checks: \(detectionIDs.sorted()); probing local credentials" + ) return Task { // Same concurrent local-only probe as first-run detection and the Reset All reseed. - let newProviders = providers.filter { newIDs.contains($0.provider.id) } + let newProviders = providers.filter { detectionIDs.contains($0.provider.id) } let detected = await FirstRunSeeder.detectLocalProviders(newProviders) + guard !Task.isCancelled else { return } + defer { enablement.finishProviderDetection(detectionIDs) } for id in detected.sorted() { + guard !Task.isCancelled else { return } // The probe takes a moment; if the user already turned the provider on themselves, - // leave their toggle alone (setEnabled would be a no-op anyway). - guard !enablement.isEnabled(id) else { continue } + // or explicitly toggled it back off, their choice cancels its pending detection. + guard enablement.pendingDetectionIDs.contains(id), + !enablement.isEnabled(id) + else { continue } AppLog.info(.config, "new provider \(id): credentials detected, enabling") enablement.setEnabled(true, for: id) } diff --git a/Sources/OpenUsage/App/OpenUsageApp.swift b/Sources/OpenUsage/App/OpenUsageApp.swift index 0a9f5dedf..f0d2ed329 100644 --- a/Sources/OpenUsage/App/OpenUsageApp.swift +++ b/Sources/OpenUsage/App/OpenUsageApp.swift @@ -5,6 +5,8 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { private var container: AppContainer? private var statusItemController: StatusItemController? private var singleInstanceLock: SingleInstanceLock.Token? + private var accountGraphObserver: NSObjectProtocol? + private var isReloadingAccountGraph = false private let updater = UpdaterController() public override init() { @@ -71,10 +73,48 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { let container = AppContainer(isFreshInstall: isFreshInstall) self.container = container statusItemController = StatusItemController(container: container, updater: updater) + observeAccountGraphChanges() // Starts background update checks (release build only; dormant under preview/`swift run`). updater.start() } + private func observeAccountGraphChanges() { + guard accountGraphObserver == nil else { return } + accountGraphObserver = NotificationCenter.default.addObserver( + forName: AppContainer.accountGraphDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + _ = Task { [weak self] in + await self?.reloadAccountGraph() + } + } + } + } + + private func reloadAccountGraph() async { + guard !isReloadingAccountGraph, let previous = container else { return } + isReloadingAccountGraph = true + defer { isReloadingAccountGraph = false } + + let wasVisible = statusItemController?.isPopoverVisible == true + let previousScreen = previous.layout.screen + statusItemController?.shutdown() + statusItemController = nil + await previous.shutdownForAccountGraphReload() + + let replacement = AppContainer() + replacement.layout.screen = previousScreen + container = replacement + let replacementController = StatusItemController(container: replacement, updater: updater) + statusItemController = replacementController + if wasVisible { + replacementController.showPopover() + } + AppLog.info(.config, "account graph rebuilt after a Claude login changed") + } + /// Flush queued telemetry on quit. The SDK's lifecycle autocapture is off (we emit our own daily /// rollups), so it won't auto-flush on termination — this explicit flush keeps low-frequency events /// from being stranded across a clean quit. diff --git a/Sources/OpenUsage/App/StatusItemController.swift b/Sources/OpenUsage/App/StatusItemController.swift index 92ffc4f6b..22ee105e2 100644 --- a/Sources/OpenUsage/App/StatusItemController.swift +++ b/Sources/OpenUsage/App/StatusItemController.swift @@ -107,7 +107,8 @@ final class StatusItemController: NSObject { self?.panel.appearance = AppearanceSetting.current.nsAppearance } } - // Registered once here; the controller lives for the app's whole life. + // Each account graph owns one controller; shutdown removes this handler before its + // replacement registers another, without clearing the user's configured shortcut. KeyboardShortcuts.onKeyUp(for: .togglePopover) { [weak self] in AppLog.info(.statusItem, "Global shortcut fired; toggling popover") self?.togglePopover() @@ -301,6 +302,28 @@ final class StatusItemController: NSObject { showPanel() } + var isPopoverVisible: Bool { panel.isVisible } + + /// Account ownership can change while the app remains open. Remove the old status item and + /// observers before installing the graph's replacement, otherwise both controllers stay visible. + func shutdown() { + // onKeyUp appends listeners globally, so leaving this installed would make every account + // graph reload add another popover toggle. Removing a handler preserves the saved shortcut. + KeyboardShortcuts.removeHandler(for: .togglePopover) + if panel.isVisible { + hidePanel() + } else { + outsideClickMonitor.stop() + panel.orderOut(nil) + } + if let appearanceObserver { + NotificationCenter.default.removeObserver(appearanceObserver) + self.appearanceObserver = nil + } + statusItem.button?.target = nil + NSStatusBar.system.removeStatusItem(statusItem) + } + private func showPanel() { guard let button = statusItem.button, let buttonWindow = button.window else { AppLog.error(.statusItem, "Cannot show panel: status item has no button") diff --git a/Sources/OpenUsage/App/StatusItemImageUpdater.swift b/Sources/OpenUsage/App/StatusItemImageUpdater.swift index b361ce0d8..dd80c0bad 100644 --- a/Sources/OpenUsage/App/StatusItemImageUpdater.swift +++ b/Sources/OpenUsage/App/StatusItemImageUpdater.swift @@ -68,7 +68,8 @@ final class StatusItemImageUpdater { } let content = MenuBarContentBuilder.build( groups: container.layout.pinnedGroups, - data: { container.dataStore.data(for: $0) } + data: { container.dataStore.data(for: $0) }, + title: { container.displayName(for: $0) } ) return MenuBarStripRenderer.image(for: content, style: container.layout.menuBarStyle) ?? MenuBarIcon.image diff --git a/Sources/OpenUsage/Models/MenuBarContent.swift b/Sources/OpenUsage/Models/MenuBarContent.swift index 39c0f373b..743d308e8 100644 --- a/Sources/OpenUsage/Models/MenuBarContent.swift +++ b/Sources/OpenUsage/Models/MenuBarContent.swift @@ -58,13 +58,19 @@ enum MenuBarContentBuilder { /// The strip is dynamic: a pinned metric without data is dropped (one of two pins renders alone at /// full size), and a provider with no data-carrying pins contributes no icon at all. Pins are /// membership; the strip shows whatever subset is real right now. - static func build(groups: [ProviderMetrics], data: (WidgetDescriptor) -> WidgetData) -> MenuBarContent { + /// `title` resolves each provider's card title (the VoiceOver summary is a human-facing name, so + /// the caller passes the account-registry resolver); defaults to the baked derived name. + static func build( + groups: [ProviderMetrics], + data: (WidgetDescriptor) -> WidgetData, + title: (Provider) -> String = { $0.displayName } + ) -> MenuBarContent { let resolvedGroups = groups.compactMap { group -> MenuBarContent.Group? in let metrics = group.metrics.map { resolve($0, data($0)) }.filter(\.hasData) guard !metrics.isEmpty else { return nil } return MenuBarContent.Group( providerID: group.provider.id, - displayName: group.provider.displayName, + displayName: title(group.provider), icon: group.provider.icon, metrics: metrics ) diff --git a/Sources/OpenUsage/Models/ProviderSnapshot.swift b/Sources/OpenUsage/Models/ProviderSnapshot.swift index aa53e62c1..47122add1 100644 --- a/Sources/OpenUsage/Models/ProviderSnapshot.swift +++ b/Sources/OpenUsage/Models/ProviderSnapshot.swift @@ -3,7 +3,11 @@ import Foundation /// Latest normalized output for one provider refresh. struct ProviderSnapshot: Hashable, Sendable, Codable { let providerID: String - let displayName: String + /// The card title at refresh time — always the baked DERIVED name (renames never reach the + /// cache or iCloud). The CLI/API boundary re-resolves it against the account registry at + /// respond time (`LocalUsageAPI.State.resolvingDisplayNames`), so human-facing output carries + /// renames without persisting them. + var displayName: String var plan: String? var lines: [MetricLine] var refreshedAt: Date diff --git a/Sources/OpenUsage/Models/UsageHistoryDocument.swift b/Sources/OpenUsage/Models/UsageHistoryDocument.swift index a3fe7f8cc..0f9147606 100644 --- a/Sources/OpenUsage/Models/UsageHistoryDocument.swift +++ b/Sources/OpenUsage/Models/UsageHistoryDocument.swift @@ -2,13 +2,23 @@ import Foundation /// One Mac's presentation-free usage history in the private iCloud container. struct UsageHistoryDocument: Hashable, Sendable, Codable, Identifiable { - static let currentSchema = "openusage.history.v1" + /// v2 adds account cards (`claude@ab12cd34`) and the `identities` map that lets peers match + /// histories by ACCOUNT instead of by card id — the same account can be the default card on one + /// Mac and an extra card on another. v1 documents stay readable, but their account histories + /// are quarantined because they cannot establish account ownership. Non-account providers still + /// merge normally. v1 readers reject v2 documents with their designed update message. + static let currentSchema = "openusage.history.v2" + static let legacySchemaV1 = "openusage.history.v1" var schema: String = currentSchema var deviceID: String var deviceName: String var updatedAt: Date var providers: [String: ProviderUsageHistory] + /// Card id → stable account identity key (see `ProviderAccountID`), for every card whose + /// identity this Mac knows. Absent on v1 documents. Contains no emails or names — identity keys + /// are opaque account/organization identifiers. + var identities: [String: String]? var id: String { deviceID } @@ -24,15 +34,47 @@ struct UsageHistoryDocument: Hashable, Sendable, Codable, Identifiable { } func validate() throws { - guard schema == Self.currentSchema else { throw UsageHistoryDocumentError.unsupportedSchema } + guard schema == Self.currentSchema || schema == Self.legacySchemaV1 else { + throw UsageHistoryDocumentError.unsupportedSchema + } + // v1 card ids are bare provider ids; v2 additionally carries account cards (`claude@ab12cd34`). + let idPattern = schema == Self.legacySchemaV1 + ? #"^[a-z0-9][a-z0-9-]*$"# + : #"^[a-z0-9][a-z0-9-]*(?:@[a-f0-9]{8})?$"# guard !deviceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !deviceName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw UsageHistoryDocumentError.invalidDevice } + if schema == Self.legacySchemaV1, identities != nil { + throw UsageHistoryDocumentError.invalidIdentity("legacy schema cannot contain account identities") + } + + var identitiesByFamily: [String: Set] = [:] + for (providerID, identity) in identities ?? [:] { + let family = ProviderAccountID.family(of: providerID) + guard providers[providerID] != nil, + ProviderAccountID.families.contains(family), + !identity.isEmpty, + identity.rangeOfCharacter(from: .whitespacesAndNewlines.union(.controlCharacters)) == nil, + !identity.contains("/"), + !identity.contains("\\") + else { throw UsageHistoryDocumentError.invalidIdentity(providerID) } + + guard identitiesByFamily[family, default: []].insert(identity).inserted else { + throw UsageHistoryDocumentError.duplicateIdentity(providerID) + } + } + for (providerID, history) in providers { - guard providerID.range(of: #"^[a-z0-9][a-z0-9-]*$"#, options: .regularExpression) != nil else { + guard providerID.range(of: idPattern, options: .regularExpression) != nil else { throw UsageHistoryDocumentError.invalidProvider(providerID) } + if ProviderAccountID.isAccountCard(providerID) { + let family = ProviderAccountID.family(of: providerID) + guard ProviderAccountID.families.contains(family), identities?[providerID] != nil else { + throw UsageHistoryDocumentError.invalidIdentity(providerID) + } + } var seriesDays: Set = [] for day in history.series.daily { guard seriesDays.insert(day.date).inserted else { throw UsageHistoryDocumentError.duplicateDay(day.date) } @@ -100,6 +142,8 @@ enum UsageHistoryDocumentError: Error, LocalizedError, Equatable { case unsupportedSchema case invalidDevice case invalidProvider(String) + case invalidIdentity(String) + case duplicateIdentity(String) case invalidDay(String) case duplicateDay(String) case duplicateModel(String) @@ -110,6 +154,8 @@ enum UsageHistoryDocumentError: Error, LocalizedError, Equatable { case .unsupportedSchema: "This Mac wrote a newer usage-history format. Update OpenUsage." case .invalidDevice: "The synced Mac identity is invalid." case .invalidProvider: "The synced provider identifier is invalid." + case .invalidIdentity: "The synced account identity is invalid." + case .duplicateIdentity: "The synced account identity appears more than once." case .invalidDay: "The synced history contains an invalid date." case .duplicateDay: "The synced history contains the same date more than once." case .duplicateModel: "The synced history contains the same model more than once." diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift b/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift index 72c0b65ee..9bef1ffa4 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift @@ -155,6 +155,22 @@ struct ClaudeOAuthConfig: Hashable, Sendable { var clientID: String } +/// Which login a `ClaudeAuthStore` is allowed to see. `.standard` is the default card — +/// byte-identical to the store's historical behavior. The scoped cases back extra account cards and +/// deliberately have no cross-account or environment-token fallback: a card can only ever read the +/// one login it was created for. +enum ClaudeCredentialScope: Hashable, Sendable { + case standard + /// One extra `CLAUDE_CONFIG_DIR` home. `keychainLiteral` is the literal string whose hash names + /// the keychain item (Claude Code hashes the env value as typed — `~/…` vs absolute differ). + /// No Desktop fallback: that login belongs to another card. + case configDir(path: String, keychainLiteral: String) + /// Claude Desktop only (a Cowork login distinct from the CLI login). `organization` pins the + /// read to that org's cached token — plans are org-scoped (a Team org next to a personal Max + /// org), so the card must never follow Desktop's *active* org. + case desktopOnly(organization: String) +} + struct ClaudeAuthStore: Sendable { private static let defaultClaudeHome = "~/.claude" private static let credentialFileName = ".credentials.json" @@ -169,18 +185,44 @@ struct ClaudeAuthStore: Sendable { var keychain: KeychainAccessing var desktop: ClaudeDesktopAuthStore var now: @Sendable () -> Date + let scope: ClaudeCredentialScope + /// Account assembly owns this closed authorization; spend-root routing cannot widen it. + let desktopAccessPolicy: ClaudeDesktopAccessPolicy + /// The standard CLI store's unsuffixed keychain fallback is independent of Desktop routing. + let allowsUnscopedStandardKeychainFallback: Bool + + var standardDesktopOrganization: String? { desktopAccessPolicy.organization } + /// Compatibility for direct auth-store callers; account-bound runtimes use the semantic name. + var allowsUnpinnedStandardDesktopFallback: Bool { allowsUnscopedStandardKeychainFallback } init( environment: EnvironmentReading = ProcessEnvironmentReader(), files: TextFileAccessing = LocalTextFileAccessor(), keychain: KeychainAccessing = SecurityKeychainAccessor(), desktop: ClaudeDesktopAuthStore? = nil, + scope: ClaudeCredentialScope = .standard, + desktopAccessPolicy: ClaudeDesktopAccessPolicy? = nil, + standardDesktopOrganization: String? = nil, + allowsUnpinnedStandardDesktopFallback: Bool = true, + allowsUnscopedStandardKeychainFallback: Bool? = nil, now: @escaping @Sendable () -> Date = Date.init ) { self.environment = environment self.files = files self.keychain = keychain self.desktop = desktop ?? ClaudeDesktopAuthStore(files: files, now: now) + self.scope = scope + if let desktopAccessPolicy { + self.desktopAccessPolicy = desktopAccessPolicy + } else if let organization = standardDesktopOrganization?.nilIfEmpty?.lowercased() { + self.desktopAccessPolicy = .pinned(organization) + } else { + self.desktopAccessPolicy = allowsUnpinnedStandardDesktopFallback + ? .activeOrganization + : .denied + } + self.allowsUnscopedStandardKeychainFallback = allowsUnscopedStandardKeychainFallback + ?? allowsUnpinnedStandardDesktopFallback self.now = now } @@ -197,12 +239,32 @@ struct ClaudeAuthStore: Sendable { var desktopStatus: ClaudeDesktopCredentialStatus = .notChecked // A working CLI login remains the source of truth and avoids a second Keychain prompt. Desktop // is a fallback for people who only use the native app (or whose stored CLI login lacks profile - // scope), never a competing account source. + // scope), never a competing account source. Scoped cards are stricter: a `.configDir` card + // never consults Desktop at all (that login belongs to another card), and a `.desktopOnly` + // card consults nothing else — always pinned to its own org. + let desktopAccess: ClaudeDesktopAccessPolicy + switch scope { + case .standard: + desktopAccess = desktopAccessPolicy + case .desktopOnly(let organization): + desktopAccess = .pinned(organization) + case .configDir: + desktopAccess = .denied + } + let desktopAllowed = desktopAccess != .denied + if forceDesktopFallback, !desktopAllowed { + // Tell the provider there is no safe Desktop candidate so it preserves the original CLI + // auth error instead of converting it to a generic "not logged in" result. + desktopStatus = .notFound + } let hasUsableCLILogin = stored.contains { $0.hasUsableAccessToken && liveUsageAvailability($0) == .available } - if forceDesktopFallback || !hasUsableCLILogin { - let result = desktop.load(allowInteraction: allowDesktopInteraction) + if desktopAllowed, forceDesktopFallback || !hasUsableCLILogin { + let result = desktop.load( + allowInteraction: allowDesktopInteraction, + organization: desktopAccess.organization + ) desktopStatus = result.status if let oauth = result.oauth { stored.insert(ClaudeCredentialState( @@ -222,7 +284,81 @@ struct ClaudeAuthStore: Sendable { loadCredentialSet().candidates } + /// Whether this scoped card's login leaves any local footprint, checked without ever reading a + /// keychain secret — safe for the every-launch seeding probe (`NewProviderSeeder`), which must + /// never raise a permission dialog. The `.standard` card keeps its richer + /// `loadCredentialSet`-based probe in `ClaudeProvider.hasLocalCredentials`. + func hasCredentialFootprint() -> Bool { + switch scope { + case .standard: + return !loadCredentialSet().candidates.isEmpty + case .configDir: + if files.exists(credentialsPath()) { return true } + return keychainServiceCandidates().contains { + keychain.genericPasswordExists(service: $0) == true + } + case .desktopOnly: + // Assembly already established this account's organization from its Cowork state. + // Encrypted Desktop material is sufficient for seeding; decrypting it here would + // touch another app's Safe Storage item before the user explicitly refreshes. + return desktop.hasCredentialMaterial() + } + } + + /// Verify the identity attached to this exact credential source before a bound account card + /// reads or publishes anything. Credential files do not identify their owner, so the source's + /// own Claude state file is the authority; a missing or ambiguous state never gets guessed. + func matchesIdentity(_ expectedIdentityKey: String) -> Bool { + guard let expected = ClaudeIdentity(expectedIdentityKey) else { return false } + let identityPath: String + + switch scope { + case .desktopOnly(let organization): + return expected.organization == organization.lowercased() + + case .configDir(let path, _): + identityPath = "\(path)/.claude.json" + + case .standard: + let configuredHome = claudeHomeOverride() ?? Self.defaultClaudeHome + guard !configuredHome.contains(",") else { return false } + let expandedHome = (configuredHome as NSString).expandingTildeInPath + let expandedDefault = (Self.defaultClaudeHome as NSString).expandingTildeInPath + identityPath = URL(fileURLWithPath: expandedHome).standardizedFileURL.path + == URL(fileURLWithPath: expandedDefault).standardizedFileURL.path + ? "\(configuredHome).json" + : "\(configuredHome)/.claude.json" + } + + let text: String + do { + guard let stored = try files.readTextIfPresent(identityPath) else { return false } + text = stored + } catch { + AppLog.error( + LogTag.auth("claude"), + "couldn't verify the bound Claude account identity: \(error.localizedDescription)" + ) + return false + } + + guard let state = try? JSONDecoder().decode( + DefaultAccountObserver.ClaudeStateFile.self, + from: Data(text.utf8) + ), + let account = state.oauthAccount, + let observed = DefaultAccountObserver.claudeIdentityKey(account) + else { + return false + } + guard let identity = ClaudeIdentity(observed) else { return false } + return identity.matchesExactly(expected) + } + private func applyingEnvironmentToken(to stored: [ClaudeCredentialState]) -> [ClaudeCredentialState] { + // An ambient env token describes the DEFAULT login's environment; a scoped card must never + // inherit it (that would leak one account's token into another account's card). + guard case .standard = scope else { return stored } guard let envAccessToken = envText("CLAUDE_CODE_OAUTH_TOKEN") else { return stored } @@ -330,38 +466,54 @@ struct ClaudeAuthStore: Sendable { } private func resolveOAuthEndpoints() -> ResolvedOAuthEndpoints { + Self.resolveOAuthEndpoints(environment: environment) + } + + private static func resolveOAuthEndpoints(environment: EnvironmentReading) -> ResolvedOAuthEndpoints { var baseAPI = Self.prodBaseAPIURL var refreshURL = Self.prodRefreshURL var clientID = Self.prodClientID var suffix = "" - let isAntUser = envText("USER_TYPE") == "ant" - if isAntUser, envFlag("USE_LOCAL_OAUTH") { - let base = (envText("CLAUDE_LOCAL_OAUTH_API_BASE") ?? "http://localhost:8000").trimmingTrailingSlashes + let isAntUser = envText(environment, "USER_TYPE") == "ant" + if isAntUser, envFlag(environment, "USE_LOCAL_OAUTH") { + let base = (envText(environment, "CLAUDE_LOCAL_OAUTH_API_BASE") ?? "http://localhost:8000").trimmingTrailingSlashes baseAPI = base refreshURL = "\(base)/v1/oauth/token" clientID = Self.nonProdClientID suffix = "-local-oauth" - } else if isAntUser, envFlag("USE_STAGING_OAUTH") { + } else if isAntUser, envFlag(environment, "USE_STAGING_OAUTH") { baseAPI = "https://api-staging.anthropic.com" refreshURL = "https://platform.staging.ant.dev/v1/oauth/token" clientID = Self.nonProdClientID suffix = "-staging-oauth" } - if let custom = envText("CLAUDE_CODE_CUSTOM_OAUTH_URL") { + if let custom = envText(environment, "CLAUDE_CODE_CUSTOM_OAUTH_URL") { let base = custom.trimmingTrailingSlashes baseAPI = base refreshURL = "\(base)/v1/oauth/token" suffix = "-custom-oauth" } - if let override = envText("CLAUDE_CODE_OAUTH_CLIENT_ID") { + if let override = envText(environment, "CLAUDE_CODE_OAUTH_CLIENT_ID") { clientID = override } return ResolvedOAuthEndpoints(baseAPI: baseAPI, refreshURL: refreshURL, clientID: clientID, suffix: suffix) } + /// The keychain service names as this environment's Claude Code writes them — the single source + /// both the scoped store and config-dir DISCOVERY build from, so a non-prod OAuth setup (local/ + /// staging/custom, which suffixes the service) can never make discovery probe one name while + /// refresh reads another. + static func baseKeychainServiceName(environment: EnvironmentReading) -> String { + "\(keychainServicePrefix)\(resolveOAuthEndpoints(environment: environment).suffix)-credentials" + } + + static func scopedKeychainServiceName(forConfigDirLiteral literal: String, environment: EnvironmentReading) -> String { + "\(baseKeychainServiceName(environment: environment))-\(hashSuffix(literal))" + } + // baseAPI/refreshURL can derive from user-set env vars (CLAUDE_CODE_CUSTOM_OAUTH_URL, // CLAUDE_LOCAL_OAUTH_API_BASE). A malformed value is a system-boundary input that must fail // loudly — never force-unwrap (crashes the app) and never silently fall back to prod (that hides @@ -386,10 +538,22 @@ struct ClaudeAuthStore: Sendable { // Only needs the file suffix, which never fails — keep this off the throwing URL path so // credential loading stays forgiving even when a custom OAuth URL is malformed. let base = "\(Self.keychainServicePrefix)\(resolveOAuthEndpoints().suffix)-credentials" - if let configDir = claudeHomeOverride() { - return ["\(base)-\(hashSuffix(configDir))", base] + switch scope { + case .configDir(_, let keychainLiteral): + // Exactly this card's item — never the bare default service, which is another account's + // login. + return ["\(base)-\(hashSuffix(keychainLiteral))"] + case .desktopOnly: + return [] + case .standard: + if let configDir = claudeHomeOverride() { + let scoped = "\(base)-\(hashSuffix(configDir))" + // With multiple known logins, the unsuffixed item can belong to another + // account's default home. A config-dir login must never borrow it. + return allowsUnscopedStandardKeychainFallback ? [scoped, base] : [scoped] + } + return [base] } - return [base] } static func parseCredentials(_ text: String) -> ClaudeCredentialsFile? { @@ -405,6 +569,8 @@ struct ClaudeAuthStore: Sendable { /// later expiry (the #738 regression from ranking purely by expiry). The source kind (never the /// token) is logged so a "locked out" report can be diagnosed from which source was chosen. private func orderedStoredCandidates() -> [ClaudeCredentialState] { + // A desktop-only card has no CLI sources at all. + if case .desktopOnly = scope { return [] } var candidates: [ClaudeCredentialState] = [] if let keychain = loadKeychainCredentials() { candidates.append(keychain) } if let file = loadFileCredentials() { candidates.append(file) } @@ -471,10 +637,17 @@ struct ClaudeAuthStore: Sendable { } private func credentialsPath() -> String { - "\(envText("CLAUDE_CONFIG_DIR") ?? Self.defaultClaudeHome)/\(Self.credentialFileName)" + if case .configDir(let path, _) = scope { + return "\(path)/\(Self.credentialFileName)" + } + return "\(envText("CLAUDE_CONFIG_DIR") ?? Self.defaultClaudeHome)/\(Self.credentialFileName)" } private func envText(_ name: String) -> String? { + Self.envText(environment, name) + } + + private static func envText(_ environment: EnvironmentReading, _ name: String) -> String? { guard let value = environment.value(for: name)?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { @@ -484,11 +657,19 @@ struct ClaudeAuthStore: Sendable { } private func envFlag(_ name: String) -> Bool { - guard let value = envText(name)?.lowercased() else { return false } + Self.envFlag(environment, name) + } + + private static func envFlag(_ environment: EnvironmentReading, _ name: String) -> Bool { + guard let value = envText(environment, name)?.lowercased() else { return false } return !["0", "false", "no", "off"].contains(value) } private func hashSuffix(_ value: String) -> String { + Self.hashSuffix(value) + } + + private static func hashSuffix(_ value: String) -> String { let normalized = value.precomposedStringWithCanonicalMapping let digest = SHA256.hash(data: Data(normalized.utf8)) return String(digest.map { String(format: "%02x", $0) }.joined().prefix(8)) diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift b/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift new file mode 100644 index 000000000..384db769b --- /dev/null +++ b/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift @@ -0,0 +1,215 @@ +import Foundation + +/// Launch-time scan for EXTRA Claude logins in custom config dirs — the homes a user points +/// `CLAUDE_CONFIG_DIR` at besides the default (`~/.claude` / `$XDG_CONFIG_HOME/claude`). +/// +/// Runs synchronously inside the launch account pass under a small time budget, and reads **no +/// keychain secrets** — credential presence is checked from file existence and attributes-only +/// keychain probes, so discovery can never raise a macOS permission dialog or block launch. +/// +/// Shape rules: candidates are dot-dirs at `~` and dirs under `~/.config` — bounded, never temp dirs +/// or project trees. A candidate only counts when it carries Claude's exact credential shape AND +/// names its account (identity read from the home itself). Identity-extraction-is-validation: that +/// routing, not name matching, is what keeps toys, forks, and sandbox homes out. +struct ClaudeConfigDirDiscovery { + /// One accepted custom-config-dir login. Whether it becomes its own card or attaches to an + /// existing account's record is the assembly's call, not discovery's. + struct Finding: Equatable, Sendable { + var identityKey: String + var label: String? + /// The expanded config-dir path (the card's credential home and its spend-log root). + var anchorPath: String + /// The literal string whose hash names the dir's keychain item (see `ClaudeCredentialScope`). + var keychainLiteral: String + } + + struct Result: Sendable { + var findings: [Finding] = [] + /// The support trail: one line per notable decision (near-miss rejections, folds), emitted to + /// the log so a "my account didn't show up" report is diagnosable from a default log. + /// Token-free and email-free by construction — identity hashes, kinds, and paths only. + var notes: [String] = [] + } + + var environment: EnvironmentReading + var files: TextFileAccessing + var keychain: KeychainAccessing + var homeDirectory: @Sendable () -> URL + var listSubdirectories: @Sendable (URL) -> [URL] + /// Wall-clock budget; on overrun the scan returns what it has (and the next launch resumes). + var timeBudget: TimeInterval + var now: @Sendable () -> Date + + init( + environment: EnvironmentReading = ProcessEnvironmentReader(), + files: TextFileAccessing = LocalTextFileAccessor(), + keychain: KeychainAccessing = SecurityKeychainAccessor(), + homeDirectory: @escaping @Sendable () -> URL = { FileManager.default.homeDirectoryForCurrentUser }, + listSubdirectories: @escaping @Sendable (URL) -> [URL] = Self.filesystemSubdirectories, + timeBudget: TimeInterval = 0.4, + now: @escaping @Sendable () -> Date = Date.init + ) { + self.environment = environment + self.files = files + self.keychain = keychain + self.homeDirectory = homeDirectory + self.listSubdirectories = listSubdirectories + self.timeBudget = timeBudget + self.now = now + } + + func run() -> Result { + let started = now() + var result = Result() + let excluded = Set(defaultClaudeConfigDirs().map(canonical)) + + for candidate in candidateDirectories() { + if now().timeIntervalSince(started) > timeBudget { + result.notes.append("claude config-dir scan hit its \(Int(timeBudget * 1000))ms budget; finishing with partial results") + break + } + guard !excluded.contains(canonical(candidate.path)) else { continue } + if let finding = claudeCandidate(at: candidate, notes: &result.notes) { + result.findings.append(finding) + } + } + return result + } + + // MARK: - Candidates + + /// Dot-dirs at `~` plus dirs under `~/.config`, in stable path order. + private func candidateDirectories() -> [URL] { + let home = homeDirectory() + var candidates = listSubdirectories(home).filter { $0.lastPathComponent.hasPrefix(".") } + candidates += listSubdirectories(home.appendingPathComponent(".config")) + return candidates.sorted { $0.path < $1.path } + } + + private static func filesystemSubdirectories(of url: URL) -> [URL] { + let contents = (try? FileManager.default.contentsOfDirectory( + at: url, + includingPropertiesForKeys: [.isDirectoryKey], + options: [] + )) ?? [] + return contents.filter { + (try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true + } + } + + private func claudeCandidate(at url: URL, notes: inout [String]) -> Finding? { + // Pre-gate: only dirs that carry an identity file at all enter the trail — everything else + // is a random dot-dir and stays out of the log. (A custom config dir keeps its state INSIDE + // the dir; only the default `~/.claude` keeps it next door at `~/.claude.json`, and the + // default homes are excluded before this runs.) + guard let identityText = try? files.readTextIfPresent(url.path + "/.claude.json") else { + return nil + } + guard let parsed = try? JSONDecoder().decode( + DefaultAccountObserver.ClaudeStateFile.self, from: Data(identityText.utf8) + ), + let account = parsed.oauthAccount, + let key = DefaultAccountObserver.claudeIdentityKey(account) + else { + notes.append("claude candidate \(logPath(url.path)): identity file present but names no account → skipped") + return nil + } + + // Credential shape: the dir's own `.credentials.json`, or its *computed* keychain item. + // Claude Code hashes the literal CLAUDE_CONFIG_DIR string, so every plausible spelling of + // this path is probed (attributes only — no secret, no prompt). + let fileBacked = (try? files.readTextIfPresent(url.path + "/.credentials.json")) + .flatMap { $0 } + .flatMap { ClaudeAuthStore.parseCredentials($0) }? + .claudeAiOauth?.accessToken?.nilIfEmpty != nil + + var matchedLiteral: String? + let literals = keychainLiterals(for: url) + for literal in literals { + let service = ClaudeAuthStore.scopedKeychainServiceName( + forConfigDirLiteral: literal, + environment: environment + ) + if keychain.genericPasswordExists(service: service) == true { + matchedLiteral = literal + break + } + } + guard fileBacked || matchedLiteral != nil else { + notes.append("claude candidate \(logPath(url.path)): identity \(hash8(key)) but no credential (no .credentials.json, no keychain item for \(literals.count) path spellings) → skipped") + return nil + } + + notes.append("claude candidate \(logPath(url.path)): accepted (\(hash8(key)), \(fileBacked ? "file" : "keychain") credential)") + return Finding( + identityKey: key, + label: DefaultAccountObserver.claudeIdentityLabel(account), + anchorPath: url.path, + keychainLiteral: matchedLiteral ?? url.path + ) + } + + /// Every plausible spelling Claude Code might have hashed for this dir's keychain item: the path + /// as listed, symlink-resolved, and each with the home prefix swapped for `~` (users export + /// `CLAUDE_CONFIG_DIR=~/x` and `=/Users/me/x` interchangeably). + private func keychainLiterals(for url: URL) -> [String] { + let home = homeDirectory() + let homePaths = Array(Set([home.path, home.resolvingSymlinksInPath().path])) + var candidates = [url.path, url.resolvingSymlinksInPath().path] + for candidate in candidates { + for homePath in homePaths where candidate.hasPrefix(homePath + "/") { + let suffix = candidate.dropFirst(homePath.count) + candidates += homePaths.map { $0 + suffix } + } + } + var literals: [String] = [] + for candidate in candidates { + literals.append(candidate) + for homePath in homePaths where candidate.hasPrefix(homePath + "/") { + literals.append("~" + candidate.dropFirst(homePath.count)) + } + } + var seen = Set() + return literals.filter { seen.insert($0).inserted } + } + + // MARK: - Default homes (the exclusion set) + + /// The default card's config dirs: every `CLAUDE_CONFIG_DIR` entry when set, else the scanner's + /// standard resolution (`$XDG_CONFIG_HOME/claude`, then `~/.claude`). + private func defaultClaudeConfigDirs() -> [String] { + if let raw = environment.value(for: "CLAUDE_CONFIG_DIR")? + .trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty { + let dirs = raw.split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + if !dirs.isEmpty { return dirs.map(expandTilde) } + } + let home = homeDirectory() + let xdg = environment.value(for: "XDG_CONFIG_HOME")?.nilIfEmpty.map(expandTilde) + ?? home.appendingPathComponent(".config").path + return [xdg + "/claude", home.appendingPathComponent(".claude").path] + } + + // MARK: - Path helpers + + private func expandTilde(_ path: String) -> String { + guard path == "~" || path.hasPrefix("~/") else { return path } + return homeDirectory().path + String(path.dropFirst(1)) + } + + private func canonical(_ path: String) -> String { + URL(fileURLWithPath: expandTilde(path)).resolvingSymlinksInPath().standardizedFileURL.path + } + + /// Log-safe path: the home prefix is folded to `~` so support logs don't carry the username. + private func logPath(_ path: String) -> String { + let home = homeDirectory().path + guard path.hasPrefix(home + "/") else { return path } + return "~" + path.dropFirst(home.count) + } + + private func hash8(_ identityKey: String) -> String { + String(ProviderAccountID.make(family: "claude", identityKey: identityKey).dropFirst("claude@".count)) + } +} diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeCoworkDiscovery.swift b/Sources/OpenUsage/Providers/Claude/ClaudeCoworkDiscovery.swift new file mode 100644 index 000000000..e4ad452cc --- /dev/null +++ b/Sources/OpenUsage/Providers/Claude/ClaudeCoworkDiscovery.swift @@ -0,0 +1,114 @@ +import Foundation + +/// Launch-time identity walk over Cowork's session sandboxes (the `.claude` dirs the Claude desktop +/// app creates per session, see `ClaudeLogUsageScanner.coworkClaudeDirs`). +/// +/// Each sandbox carries its own `.claude.json`, which names the account that ran the session. The +/// walk only reads those identity files — no keychain, no credential files — so it can never raise +/// a permission dialog or block launch. Routing is the assembly's job: sandboxes naming the default +/// account stay on the default card, sandboxes naming a known config-dir account attach as its log +/// roots, and a distinct account becomes one Desktop-backed card. +struct ClaudeCoworkDiscovery { + /// One Cowork session sandbox and the account it names, when it names one. + struct Sandbox: Equatable, Sendable { + /// The session's `.claude` dir (a spend-log root: it holds `projects/**/*.jsonl`). + var root: URL + /// `nil` when the sandbox's identity file is missing or names no account — such a sandbox + /// stays on the default card, exactly where the built-in walk has always put it. + var identityKey: String? + var label: String? + /// The account's org UUID (lowercased) — the pin a Desktop-backed card reads credentials + /// under. Claude Desktop caches tokens per org, so an account without one can't get a card. + var organization: String? + } + + struct Result: Sendable { + var sandboxes: [Sandbox] = [] + /// The support trail (token-free and email-free): identity hashes and paths only. + var notes: [String] = [] + /// True when the walk hit its time budget before visiting every sandbox. A partial list + /// must not drive routing — a missed non-default sandbox would silently bleed into the + /// default card, and a partial partition would drop default spend — so the assembly skips + /// the whole cowork pass this launch and retries next launch. + var truncated = false + } + + var files: TextFileAccessing + var homeDirectory: @Sendable () -> URL + /// The sandbox walk, injectable for tests; defaults to the scanner's own walk so discovery and + /// spend scanning can never see different sandbox sets. + var listSandboxes: @Sendable (URL) -> [URL] + /// Wall-clock budget; on overrun the scan returns what it has, flagged `truncated` so the + /// assembly knows the list is not the whole truth (and retries next launch). + var timeBudget: TimeInterval + var now: @Sendable () -> Date + + init( + files: TextFileAccessing = LocalTextFileAccessor(), + homeDirectory: @escaping @Sendable () -> URL = { FileManager.default.homeDirectoryForCurrentUser }, + listSandboxes: @escaping @Sendable (URL) -> [URL] = { ClaudeLogUsageScanner.coworkClaudeDirs(home: $0) }, + // Heavy Desktop users commonly accumulate hundreds of sandbox identities. A subsecond + // launch budget silently discarded the entire account partition on those real machines. + timeBudget: TimeInterval = 3, + now: @escaping @Sendable () -> Date = Date.init + ) { + self.files = files + self.homeDirectory = homeDirectory + self.listSandboxes = listSandboxes + self.timeBudget = timeBudget + self.now = now + } + + func run() -> Result { + let started = now() + var result = Result() + for root in listSandboxes(homeDirectory()) { + if Task.isCancelled { + result.notes.append("cowork sandbox scan cancelled → skipping incomplete cowork routing") + result.truncated = true + break + } + if now().timeIntervalSince(started) > timeBudget { + result.notes.append("cowork sandbox scan hit its \(Int(timeBudget * 1000))ms budget → skipping cowork routing this launch") + result.truncated = true + break + } + result.sandboxes.append(sandbox(at: root, notes: &result.notes)) + } + return result + } + + private func sandbox(at root: URL, notes: inout [String]) -> Sandbox { + let identityText: String? + do { + identityText = try files.readTextIfPresent(root.path + "/.claude.json") + } catch { + notes.append("cowork sandbox \(logPath(root.path)): identity file unreadable → kept on the default card") + return Sandbox(root: root) + } + guard let identityText, + let parsed = try? JSONDecoder().decode( + DefaultAccountObserver.ClaudeStateFile.self, from: Data(identityText.utf8) + ), + let account = parsed.oauthAccount, + let key = DefaultAccountObserver.claudeIdentityKey(account) + else { + // No identity = a sandbox the default login produced before identity files existed, or + // one mid-creation. The built-in walk has always counted these on the default card. + return Sandbox(root: root) + } + return Sandbox( + root: root, + identityKey: key, + label: DefaultAccountObserver.claudeIdentityLabel(account), + organization: account.organizationUuid?.nilIfEmpty?.lowercased() + ) + } + + /// Log-safe path: the home prefix is folded to `~` so support logs don't carry the username. + private func logPath(_ path: String) -> String { + let home = homeDirectory().path + guard path.hasPrefix(home + "/") else { return path } + return "~" + path.dropFirst(home.count) + } +} diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeDesktopAuthStore.swift b/Sources/OpenUsage/Providers/Claude/ClaudeDesktopAuthStore.swift index a58d3300e..5e92f263a 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeDesktopAuthStore.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeDesktopAuthStore.swift @@ -123,7 +123,10 @@ struct ClaudeDesktopAuthStore: Sendable { return Self.cookieRelativePaths.contains { files.exists(path($0)) } } - func load(allowInteraction: Bool) -> ClaudeDesktopCredentialResult { + /// `organization` pins the read to one org's cached token (a Desktop-backed account card for a + /// non-active Desktop org, e.g. a Team org alongside a personal Max org). `nil` keeps the default + /// behavior: resolve the app's currently active organization and use its token. + func load(allowInteraction: Bool, organization: String? = nil) -> ClaudeDesktopCredentialResult { guard hasCredentialMaterial() else { return ClaudeDesktopCredentialResult(oauth: nil, status: .notFound) } @@ -132,14 +135,20 @@ struct ClaudeDesktopAuthStore: Sendable { guard let key = try safeStorageKey(allowInteraction: allowInteraction) else { return ClaudeDesktopCredentialResult(oauth: nil, status: .notFound) } - guard let activeOrg = try loadActiveOrganization(key: key), - let caches = try loadCaches(key: key) - else { + let targetOrganization: String + if let organization { + targetOrganization = organization.lowercased() + } else if let activeOrg = try loadActiveOrganization(key: key) { + targetOrganization = activeOrg + } else { + return ClaudeDesktopCredentialResult(oauth: nil, status: .invalid) + } + guard let caches = try loadCaches(key: key) else { return ClaudeDesktopCredentialResult(oauth: nil, status: .invalid) } let selection = Self.selectCredential( - activeOrganization: activeOrg, + activeOrganization: targetOrganization, v2: caches.v2, v1: caches.v1, now: now() @@ -258,18 +267,21 @@ struct ClaudeDesktopAuthStore: Sendable { now: Date ) -> Selection { let normalizedOrg = activeOrganization.lowercased() - let v2Candidates = candidates(in: v2, organization: normalizedOrg, now: now) - if let best = v2Candidates.available.max(by: { $0.rank < $1.rank }) { - return .available(best.oauth) - } + let v2Candidates = candidates(in: v2, organization: normalizedOrg, now: now, isCurrentGeneration: true) + // A v2 key (live or tombstoned) always speaks for its v1 twin, but the two generations + // compete in ONE ranked pool. Short-circuiting on "any live v2 entry" once let a + // profile-only v2 leftover (carrying stale tier metadata) beat the current full-scope + // login whose live token existed only in v1 — token quality must outrank cache + // generation, with generation only breaking quality ties. let v2Keys = Set(v2?.keys ?? Dictionary().keys) let v1Candidates = candidates( in: v1?.filter { !v2Keys.contains($0.key) }, organization: normalizedOrg, - now: now + now: now, + isCurrentGeneration: false ) - if let best = v1Candidates.available.max(by: { $0.rank < $1.rank }) { + if let best = (v2Candidates.available + v1Candidates.available).max(by: { $0.rank < $1.rank }) { return .available(best.oauth) } if v2Candidates.sawStale || v1Candidates.sawStale { return .stale } @@ -288,12 +300,15 @@ struct ClaudeDesktopAuthStore: Sendable { var clientID: String var scopes: [String] var expiresAt: Double + /// `true` for the v2 cache, Desktop's current generation. + var isCurrentGeneration: Bool /// Selection order mirrors Desktop's own resolution instead of raw expiry: production client /// with full scopes first, then any full-scope entry over bare `user:profile` leftovers, then - /// scope richness, with expiry only as the final tiebreak. A stale wrong-tier token with a - /// longer TTL must not outrank the current login. - var rank: (Int, Int, Int, Double) { + /// scope richness, then the newer cache generation, with expiry only as the final tiebreak. + /// A stale wrong-tier token with a longer TTL must not outrank the current login, and a + /// newer-generation entry must not outrank a better-quality older one. + var rank: (Int, Int, Int, Int, Double) { let hasFullScope = scopes.contains(ClaudeDesktopAuthStore.usageScope) && scopes.contains(ClaudeDesktopAuthStore.inferenceScope) let isProductionClient = clientID == ClaudeDesktopAuthStore.productionClientID @@ -301,6 +316,7 @@ struct ClaudeDesktopAuthStore: Sendable { isProductionClient && hasFullScope ? 1 : 0, hasFullScope ? 1 : 0, scopes.count, + isCurrentGeneration ? 1 : 0, expiresAt ) } @@ -309,7 +325,8 @@ struct ClaudeDesktopAuthStore: Sendable { private static func candidates( in cache: [String: Any]?, organization: String, - now: Date + now: Date, + isCurrentGeneration: Bool ) -> (available: [Candidate], sawStale: Bool, sawInvalid: Bool) { guard let cache else { return ([], false, false) } var available: [Candidate] = [] @@ -349,7 +366,8 @@ struct ClaudeDesktopAuthStore: Sendable { oauth: oauth, clientID: parsedKey.clientID, scopes: parsedKey.scopes, - expiresAt: expiresAt + expiresAt: expiresAt, + isCurrentGeneration: isCurrentGeneration )) } return (available, sawStale, sawInvalid) diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeLogUsageScanner.swift b/Sources/OpenUsage/Providers/Claude/ClaudeLogUsageScanner.swift index 2479099a1..afb8161b8 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeLogUsageScanner.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeLogUsageScanner.swift @@ -26,6 +26,24 @@ actor ClaudeLogUsageScanner { /// Scoped provider instances pass their stable parse-source identity here. Account or time filters /// over the same physical roots deliberately pass the same value and share whole-file records. private let cacheIdentityOverride: String? + /// Extra account cards pin the scan to exactly their config dir(s), replacing the standard + /// resolution (env override, XDG, `~/.claude`, Cowork sandboxes) entirely — another account's + /// logs must never bleed into a scoped card. `nil` keeps the standard walk byte-identical. + private let rootsOverride: [URL]? + /// Same-account custom config dirs appended to the DEFAULT card's standard roots, so spend the + /// user's own login produced in a side home still counts on its card. + private let additionalRoots: [URL] + /// When set, replaces the built-in Cowork sandbox walk: `[]` for scoped account cards (Cowork + /// logs belong to the account that produced them, not this card), the account's partition of the + /// walk otherwise. `nil` keeps the built-in walk byte-identical (a machine where every sandbox + /// belongs to the default login). + /// + /// A partition is frozen at launch by design: a sandbox created afterwards routes on the next + /// launch. The live-walk alternative (subtract known-foreign roots) would count the OTHER + /// account's brand-new sessions on this card until relaunch — the exact bleed the partition + /// exists to prevent. Missing-until-relaunch is the safer failure. Files inside known sandboxes + /// still update live on every refresh. + private let coworkRootsOverride: [URL]? /// One parsed usage line. Token buckets are pre-normalized into `TokenBreakdown`; dedup fields /// ride along so the global dedup pass can run over cached entries. @@ -57,13 +75,22 @@ actor ClaudeLogUsageScanner { environment: EnvironmentReading = ProcessEnvironmentReader(), homeDirectory: @escaping @Sendable () -> URL = { FileManager.default.homeDirectoryForCurrentUser }, incrementalScanner: IncrementalJSONLScanner? = nil, - cacheIdentityOverride: String? = nil + cacheIdentityOverride: String? = nil, + rootsOverride: [URL]? = nil, + additionalRoots: [URL] = [], + coworkRootsOverride: [URL]? = nil ) { precondition(cacheIdentityOverride?.isEmpty != true) + // A scoped root set must carry its own parse-source identity, or its cache records would + // collide with the default card's under the standard identity. + precondition(rootsOverride == nil || cacheIdentityOverride != nil) self.environment = environment self.homeDirectory = homeDirectory self.scanner = incrementalScanner ?? Self.sharedScanner self.cacheIdentityOverride = cacheIdentityOverride + self.rootsOverride = rootsOverride + self.additionalRoots = additionalRoots + self.coworkRootsOverride = coworkRootsOverride } /// Scan the last `daysBack` days of Claude logs. Returns `nil` when no Claude data directory or @@ -129,7 +156,15 @@ actor ClaudeLogUsageScanner { let roots = Set(configuredRoots.map { $0.resolvingSymlinksInPath().standardizedFileURL.path }) .sorted() .joined(separator: "\n") - return "home=\(home)\nroots=\(roots)" + // The built-in Cowork walk deliberately leaves no mark here (session roots come and go; a new + // session must extend the same cache, and existing installs keep their warm cache). A cowork + // PARTITION does mark it: the same physical roots parsed under a different sandbox split must + // not share whole-file records with the unpartitioned identity. + guard let coworkRootsOverride else { return "home=\(home)\nroots=\(roots)" } + let cowork = Set(coworkRootsOverride.map { $0.resolvingSymlinksInPath().standardizedFileURL.path }) + .sorted() + .joined(separator: ",") + return "home=\(home)\nroots=\(roots)\ncowork=\(cowork)" } // MARK: - Root and file discovery @@ -150,6 +185,13 @@ actor ClaudeLogUsageScanner { roots.append(url) } + // A scoped card scans exactly its own config dir(s) — no env resolution, no Cowork walk + // (sandboxes belong to the default login until Phase 3 attributes them per account). + if let rootsOverride { + for root in rootsOverride { addIfValid(root) } + return roots + } + if let raw = environment.value(for: "CLAUDE_CONFIG_DIR")?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty { for part in raw.split(separator: ",").map({ $0.trimmingCharacters(in: .whitespaces) }) where !part.isEmpty { @@ -171,8 +213,18 @@ actor ClaudeLogUsageScanner { addIfValid(home.appendingPathComponent(".claude")) } - for sandbox in Self.coworkClaudeDirs(home: homeDirectory()) { - addIfValid(sandbox) + if let coworkRootsOverride { + // The default card's partition of the Cowork walk once another account's sandboxes + // exist — that account's sessions must not bleed into this card's spend. + for sandbox in coworkRootsOverride { addIfValid(sandbox) } + } else { + for sandbox in Self.coworkClaudeDirs(home: homeDirectory()) { + addIfValid(sandbox) + } + } + // Same-account custom config dirs (default card only): the user's own spend in a side home. + for root in additionalRoots { + addIfValid(root) } return roots } @@ -182,7 +234,9 @@ actor ClaudeLogUsageScanner { /// (plus an `agent/local_*` variant one level deeper). Each holds the same `projects/**/*.jsonl` /// session logs as `~/.claude`, so they scan as additional roots. The walk is bounded to those /// known levels — session dirs contain full sandbox homes we must not recurse into. - private static func coworkClaudeDirs(home: URL) -> [URL] { + /// Internal because `ClaudeCoworkDiscovery` walks the very same dirs for per-sandbox identity — + /// one walk, so discovery and the scanner can never see different sandbox sets. + static func coworkClaudeDirs(home: URL) -> [URL] { let base = home .appendingPathComponent("Library/Application Support/Claude/local-agent-mode-sessions") diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift b/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift index c295db33d..c87c7f6da 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift @@ -3,21 +3,26 @@ import Foundation @MainActor final class ClaudeProvider: ProviderRuntime { - let provider = Provider( - id: "claude", - displayName: "Claude", - icon: .providerMark("claude"), - links: [ - .init(label: "Status", url: "https://status.anthropic.com/"), - .init(label: "Dashboard", url: "https://claude.ai/settings/usage") - ] - ) + static func makeProvider(id: String = "claude", displayName: String = "Claude") -> Provider { + Provider( + id: id, + displayName: displayName, + icon: .providerMark("claude"), + links: [ + .init(label: "Status", url: "https://status.anthropic.com/"), + .init(label: "Dashboard", url: "https://claude.ai/settings/usage") + ] + ) + } + + let provider: Provider let authStore: ClaudeAuthStore let usageClient: ClaudeUsageClient let logUsageScanner: ClaudeLogUsageScanner let now: @Sendable () -> Date let pricing: @Sendable () async -> ModelPricing + let expectedIdentityKey: String? /// Last successful live-usage result and a rate-limit cooldown, carried across refreshes (the provider /// is a long-lived singleton). `/api/oauth/usage` rate-limits aggressively, so on a 429 we serve the @@ -30,30 +35,34 @@ final class ClaudeProvider: ProviderRuntime { private static let rateLimitCooldown: TimeInterval = 5 * 60 init( + provider: Provider = ClaudeProvider.makeProvider(), authStore: ClaudeAuthStore = ClaudeAuthStore(), usageClient: ClaudeUsageClient = ClaudeUsageClient(), logUsageScanner: ClaudeLogUsageScanner = ClaudeLogUsageScanner(), now: @escaping @Sendable () -> Date = Date.init, - pricing: @escaping @Sendable () async -> ModelPricing = { await ModelPricingStore.shared.current() } + pricing: @escaping @Sendable () async -> ModelPricing = { await ModelPricingStore.shared.current() }, + expectedIdentityKey: String? = nil ) { + self.provider = provider self.authStore = authStore self.usageClient = usageClient self.logUsageScanner = logUsageScanner self.now = now self.pricing = pricing + self.expectedIdentityKey = expectedIdentityKey } var widgetDescriptors: [WidgetDescriptor] { [ - .percent(id: "claude.session", provider: provider, title: "Session", isSessionWindow: true) + .percent(id: "\(provider.id).session", provider: provider, title: "Session", isSessionWindow: true) .exportingLimit("session", unit: "percent"), - .percent(id: "claude.weekly", provider: provider, title: "Weekly") + .percent(id: "\(provider.id).weekly", provider: provider, title: "Weekly") .exportingLimit("weekly", unit: "percent"), - .percent(id: "claude.fable", provider: provider, title: "Fable") + .percent(id: "\(provider.id).fable", provider: provider, title: "Fable") .exportingLimit("fable", unit: "percent"), - .percent(id: "claude.sonnet", provider: provider, title: "Sonnet") + .percent(id: "\(provider.id).sonnet", provider: provider, title: "Sonnet") .exportingLimit("sonnet", unit: "percent"), - .boundedDollars(id: "claude.extra", provider: provider, title: "Extra Usage", metricLabel: "Extra usage spent", limit: 100, valueWord: "spent") + .boundedDollars(id: "\(provider.id).extra", provider: provider, title: "Extra Usage", metricLabel: "Extra usage spent", limit: 100, valueWord: "spent") .exportingLimit("extraUsage", unit: "usd", source: .progressOrValue(kind: .dollars)), .usageTrend(provider: provider) .exportingHistory( @@ -65,6 +74,9 @@ final class ClaudeProvider: ProviderRuntime { } func hasLocalCredentials() async -> Bool { + if authStore.scope != .standard { + return await loadOffMainActor { [authStore] in authStore.hasCredentialFootprint() } + } // Never trigger another app's Keychain prompt during first-run detection. Encrypted Desktop // material still counts as a local login; the first manual refresh requests access if needed. let load = await loadOffMainActor { [authStore] in authStore.loadCredentialSet() } @@ -87,6 +99,12 @@ final class ClaudeProvider: ProviderRuntime { forceDesktopFallback: Bool, previousFallbackError: ClaudeAuthError? ) async -> ProviderSnapshot { + guard await sourceStillBelongsToAccount() else { + clearLiveUsageCache() + AppLog.warn(LogTag.auth("claude"), "account-bound credential source no longer matches its card") + return ProviderSnapshot.error(provider: provider, error: ClaudeAuthError.credentialsChanged) + } + let allowDesktopInteraction = ProviderRefreshContext.isManual let credentialLoad = await loadOffMainActor { [authStore] in authStore.loadCredentialSet( @@ -94,6 +112,10 @@ final class ClaudeProvider: ProviderRuntime { forceDesktopFallback: forceDesktopFallback ) } + guard await sourceStillBelongsToAccount() else { + clearLiveUsageCache() + return ProviderSnapshot.error(provider: provider, error: ClaudeAuthError.credentialsChanged) + } let storedCandidates = credentialLoad.candidates let candidates = storedCandidates.filter { $0.hasUsableAccessToken && (!forceDesktopFallback || $0.source == .desktop) @@ -144,6 +166,10 @@ final class ClaudeProvider: ProviderRuntime { mapped: ClaudeMappedUsage(plan: nil, lines: []), warning: error.localizedDescription ) + guard await sourceStillBelongsToAccount() else { + clearLiveUsageCache() + return ProviderSnapshot.error(provider: provider, error: ClaudeAuthError.credentialsChanged) + } guard let history = snapshot.usageHistory, history.series.daily.contains(where: { $0.totalTokens > 0 || ($0.costUSD ?? 0) > 0 @@ -261,7 +287,9 @@ final class ClaudeProvider: ProviderRuntime { warning = fallbackWarning } - return await snapshotWithLocalUsage(mapped: mapped, warning: warning) + let snapshot = await snapshotWithLocalUsage(mapped: mapped, warning: warning) + guard await sourceStillBelongsToAccount() else { throw ClaudeAuthError.credentialsChanged } + return snapshot } private func snapshotWithLocalUsage( @@ -274,7 +302,15 @@ final class ClaudeProvider: ProviderRuntime { // Both scans run on their scanner actors, off the main actor, and do not require an OAuth login. let pricing = await pricing() let nativeScan = await logUsageScanner.scan(now: now(), pricing: pricing) - let piScan = await PiUsageScanner.shared.scan(cardID: provider.id, now: now(), pricing: pricing) + // pi's logs name the Claude family, never an account id. Attribute them only to the + // runtime that currently owns the default credential source, even when that account's + // stable card id is hashed and the original bare-id account has moved elsewhere. + let piScan: LogUsageScan? + if authStore.scope == .standard { + piScan = await PiUsageScanner.shared.scan(cardID: "claude", now: now(), pricing: pricing) + } else { + piScan = nil + } var usageHistory: ProviderUsageHistory? // Cancellation can land between the native and pi scans. Treat the pair as one unit so a // partial result cannot replace the last-good combined history in WidgetDataStore. @@ -365,6 +401,7 @@ final class ClaudeProvider: ProviderRuntime { authStore.credentialGeneration(forceDesktopFallback: forceDesktopGeneration) } guard currentGeneration == expectedGeneration else { throw ClaudeAuthError.credentialsChanged } + guard await sourceStillBelongsToAccount() else { throw ClaudeAuthError.credentialsChanged } // 429 can come back from either attempt; the helper hands both through unchanged. Start a cooldown // (respecting Retry-After) and serve the last-good usage rather than a bare badge. @@ -400,10 +437,21 @@ final class ClaudeProvider: ProviderRuntime { let fingerprint = Self.credentialFingerprint(credentials) guard cachedCredentialFingerprint != fingerprint else { return } cachedCredentialFingerprint = fingerprint + clearLiveUsageCache() + } + + private func clearLiveUsageCache() { lastGoodUsage = nil rateLimitedUntil = nil } + private func sourceStillBelongsToAccount() async -> Bool { + guard let expectedIdentityKey else { return true } + return await loadOffMainActor { [authStore] in + authStore.matchesIdentity(expectedIdentityKey) + } + } + private static func credentialFingerprint(_ credentials: ClaudeOAuth) -> Data { let access = Data((credentials.accessToken ?? "").utf8) let refresh = Data((credentials.refreshToken ?? "").utf8) @@ -455,8 +503,12 @@ final class ClaudeProvider: ProviderRuntime { // rather than fail the live fetch. let persisted: Bool do { + let expectedIdentityKey = expectedIdentityKey guard try await Task.detached(priority: .utility, operation: { [authStore, state] in - try authStore.save(state, ifUnchanged: expectedGeneration) + if let expectedIdentityKey, !authStore.matchesIdentity(expectedIdentityKey) { + throw ClaudeAuthError.credentialsChanged + } + return try authStore.save(state, ifUnchanged: expectedGeneration) }).value else { throw ClaudeAuthError.credentialsChanged } diff --git a/Sources/OpenUsage/Providers/Codex/CodexProvider.swift b/Sources/OpenUsage/Providers/Codex/CodexProvider.swift index ccac6b9ae..b08a3fe93 100644 --- a/Sources/OpenUsage/Providers/Codex/CodexProvider.swift +++ b/Sources/OpenUsage/Providers/Codex/CodexProvider.swift @@ -1,7 +1,8 @@ +import CryptoKit import Foundation @MainActor -final class CodexProvider: ProviderRuntime { +final class CodexProvider: ProviderRuntime, AccountIdentityReporting { let provider = Provider( id: "codex", displayName: "Codex", @@ -17,6 +18,19 @@ final class CodexProvider: ProviderRuntime { let logUsageScanner: CodexLogUsageScanner let now: @Sendable () -> Date let pricing: @Sendable () async -> ModelPricing + /// Identity proven by the credential that produced the most recent successful snapshot. + /// Keychain-backed accounts cannot be identified during prompt-free launch discovery, but a + /// normal refresh already reads that credential and can safely expose its account id afterward. + private(set) var lastSuccessfulIdentityKey: String? + /// In-memory lineage anchor for the winning credential or its independently identified auth-file + /// precursor. Access tokens routinely rotate, so the anchor survives an overlapping access/refresh + /// token or an OAuth exchange we perform ourselves; file seeding never exposes a successful identity. + private(set) var lastSuccessfulCredentialFingerprint: Data? + private var trackedAccessTokenFingerprint: Data? + private var trackedRefreshTokenFingerprint: Data? + + var verifiedAccountIdentityKey: String? { lastSuccessfulIdentityKey } + var accountOwnershipContinuityToken: Data? { lastSuccessfulCredentialFingerprint } init( authStore: CodexAuthStore = CodexAuthStore(), @@ -30,6 +44,7 @@ final class CodexProvider: ProviderRuntime { self.logUsageScanner = logUsageScanner self.now = now self.pricing = pricing + seedIdentifiedFileCredential() } var widgetDescriptors: [WidgetDescriptor] { @@ -102,6 +117,7 @@ final class CodexProvider: ProviderRuntime { private func probe(authState initialState: CodexAuthState) async throws -> ProviderSnapshot { var authState = initialState + var continuesSuccessfulCredential = matchesLastSuccessfulCredential(authState.auth) guard var accessToken = authState.auth.tokens?.accessToken, !accessToken.isEmpty else { if authState.auth.apiKey?.isEmpty == false { throw CodexAuthError.usageAPIKey @@ -117,6 +133,9 @@ final class CodexProvider: ProviderRuntime { let liveToken = live.auth.tokens?.accessToken, !liveToken.isEmpty { authState = live accessToken = liveToken + // The same file or Keychain entry can have been replaced by another login. Its + // location alone proves nothing, so re-establish continuity from its actual tokens. + continuesSuccessfulCredential = matchesLastSuccessfulCredential(live.auth) } } @@ -164,6 +183,16 @@ final class CodexProvider: ProviderRuntime { } MetricLine.appendNoDataIfNeeded(&mapped.lines) + lastSuccessfulIdentityKey = Self.accountIdentityKey(from: authState.auth) + let accessFingerprint = Self.credentialFingerprint(currentToken) + let refreshFingerprint = authState.auth.tokens?.refreshToken.flatMap { token in + token.isEmpty ? nil : Self.credentialFingerprint(token) + } + if !continuesSuccessfulCredential || lastSuccessfulCredentialFingerprint == nil { + lastSuccessfulCredentialFingerprint = refreshFingerprint ?? accessFingerprint + } + trackedAccessTokenFingerprint = accessFingerprint + trackedRefreshTokenFingerprint = refreshFingerprint return ProviderSnapshot.make( provider: provider, plan: mapped.plan, @@ -173,6 +202,61 @@ final class CodexProvider: ProviderRuntime { ) } + private static func accountIdentityKey(from auth: CodexAuth) -> String? { + if let accountID = auth.tokens?.accountID? + .trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + { + return accountID.lowercased() + } + for token in [auth.tokens?.idToken, auth.tokens?.accessToken].compactMap({ $0 }) { + if let accountID = DefaultAccountObserver.chatGPTAccountID( + inIDTokenPayload: ProviderParse.jwtPayload(token) + ) { + return accountID.lowercased() + } + } + return nil + } + + private func seedIdentifiedFileCredential() { + // Launch discovery already reads this local file and can name its account without touching + // the Keychain. Preserve only token digests, so a newly appearing fallback login cannot + // inherit that launch account before its own identity has been verified by a successful API. + guard let candidate = authStore.loadAuthCandidates().first(where: { + $0.hasUsableAccessToken && Self.accountIdentityKey(from: $0.auth) != nil + }), + let accessToken = candidate.auth.tokens?.accessToken, !accessToken.isEmpty + else { return } + + let accessFingerprint = Self.credentialFingerprint(accessToken) + let refreshFingerprint = candidate.auth.tokens?.refreshToken.flatMap { token in + token.isEmpty ? nil : Self.credentialFingerprint(token) + } + lastSuccessfulCredentialFingerprint = refreshFingerprint ?? accessFingerprint + trackedAccessTokenFingerprint = accessFingerprint + trackedRefreshTokenFingerprint = refreshFingerprint + } + + private func matchesLastSuccessfulCredential(_ auth: CodexAuth) -> Bool { + guard lastSuccessfulCredentialFingerprint != nil else { return false } + + if let accessToken = auth.tokens?.accessToken, !accessToken.isEmpty, + Self.credentialFingerprint(accessToken) == trackedAccessTokenFingerprint + { + return true + } + if let refreshToken = auth.tokens?.refreshToken, !refreshToken.isEmpty, + Self.credentialFingerprint(refreshToken) == trackedRefreshTokenFingerprint + { + return true + } + return false + } + + private static func credentialFingerprint(_ token: String) -> Data { + Data(SHA256.hash(data: Data(token.utf8))) + } + /// Fetches the on-demand reset-credit balance (and per-credit expiry) without ever failing the /// refresh: this is supplementary to the usage metrics, so a network error, timeout, or non-2xx just /// yields `nil` and the mapper falls back to the count embedded in the usage body. Logged, not thrown — diff --git a/Sources/OpenUsage/Providers/ProviderCatalog.swift b/Sources/OpenUsage/Providers/ProviderCatalog.swift index 715250ac9..68da81329 100644 --- a/Sources/OpenUsage/Providers/ProviderCatalog.swift +++ b/Sources/OpenUsage/Providers/ProviderCatalog.swift @@ -4,11 +4,31 @@ import Foundation /// their runtimes here so credentials, refresh behavior, pricing, and normalization can never drift. @MainActor enum ProviderCatalog { - static func make(defaults: UserDefaults = .standard) -> [ProviderRuntime] { - // Default provider order (see AGENTS.md "## Providers"): the three established providers first, - // then every other provider alphabetically by display name. - [ - ClaudeProvider(), + static func make( + defaults: UserDefaults = .standard, + claude: ClaudeRuntimePlan = ClaudeRuntimePlan() + ) -> [ProviderRuntime] { + var runtimes: [ProviderRuntime] = [] + + if claude.cards.isEmpty, claude.allowsUnboundFallback { + // Preserve the existing spend-only / unresolved-identity behavior when discovery + // cannot prove any Claude account. Once an account is verified, every Claude runtime + // comes from its record instead; there is never a second hardcoded default card. + runtimes.append(ClaudeProvider( + authStore: ClaudeAuthStore( + allowsUnpinnedStandardDesktopFallback: claude.defaultCoworkRoots == nil + ), + logUsageScanner: ClaudeLogUsageScanner( + coworkRootsOverride: claude.defaultCoworkRoots + ) + )) + } else { + runtimes += claude.cards.map(claudeAccountRuntime) + } + + // The three established families remain first, followed by the alphabetical provider tail. + // Account instances sit together before Codex regardless of which one holds the default. + runtimes += [ CodexProvider(), CursorProvider(), AntigravityProvider(), @@ -19,5 +39,46 @@ enum ProviderCatalog { OpenRouterProvider(), ZAIProvider() ] + return runtimes + } + + private static func claudeAccountRuntime(_ card: ClaudeAccountCard) -> ClaudeProvider { + let authStore: ClaudeAuthStore + let scanner: ClaudeLogUsageScanner + + switch card.credential { + case .defaultHome: + authStore = ClaudeAuthStore( + scope: .standard, + desktopAccessPolicy: card.desktopAccess, + allowsUnscopedStandardKeychainFallback: card.allowsUnscopedKeychainFallback + ) + scanner = ClaudeLogUsageScanner( + cacheIdentityOverride: card.id == "claude" ? nil : "claude-account:\(card.id)", + additionalRoots: card.additionalLogRoots, + coworkRootsOverride: card.coworkRootsOverride + ) + case .configDir(let path, let keychainLiteral): + authStore = ClaudeAuthStore( + scope: .configDir(path: path, keychainLiteral: keychainLiteral) + ) + scanner = ClaudeLogUsageScanner( + cacheIdentityOverride: "claude-account:\(card.id)", + rootsOverride: card.logRoots + ) + case .desktop(let organization): + authStore = ClaudeAuthStore(scope: .desktopOnly(organization: organization)) + scanner = ClaudeLogUsageScanner( + cacheIdentityOverride: "claude-account:\(card.id)", + rootsOverride: card.logRoots + ) + } + + return ClaudeProvider( + provider: ClaudeProvider.makeProvider(id: card.id, displayName: card.displayName), + authStore: authStore, + logUsageScanner: scanner, + expectedIdentityKey: card.identityKey + ) } } diff --git a/Sources/OpenUsage/Providers/ProviderRuntime.swift b/Sources/OpenUsage/Providers/ProviderRuntime.swift index 35ee479bc..2c8f3dbb7 100644 --- a/Sources/OpenUsage/Providers/ProviderRuntime.swift +++ b/Sources/OpenUsage/Providers/ProviderRuntime.swift @@ -38,6 +38,14 @@ protocol ProviderRuntime: AnyObject { func hasLocalCredentials() async -> Bool } +/// Optional account-ownership evidence produced by a successful provider refresh. The continuity +/// value is opaque to consumers; each provider owns whatever credential-lineage proof it requires. +@MainActor +protocol AccountIdentityReporting: AnyObject { + var verifiedAccountIdentityKey: String? { get } + var accountOwnershipContinuityToken: Data? { get } +} + /// Run a blocking, `Sendable` credential load off the MainActor. /// /// Auth stores read credentials via the `security` (keychain) and `sqlite3` CLIs, whose `ProcessRunner` diff --git a/Sources/OpenUsage/Services/ClaudeAccountPlanning.swift b/Sources/OpenUsage/Services/ClaudeAccountPlanning.swift new file mode 100644 index 000000000..ccbbe9fe9 --- /dev/null +++ b/Sources/OpenUsage/Services/ClaudeAccountPlanning.swift @@ -0,0 +1,300 @@ +import Foundation + +/// A complete identity universe is assembled before any source is folded or accepted. Partial +/// Cowork identities still inform Desktop safety, but cannot create cards or route spend logs. +@MainActor +struct ClaudeSourceObservations { + struct FamilyOutcome { + let family: String + let outcome: DefaultAccountObserver.Outcome + } + + let outcomes: [FamilyOutcome] + let defaultOutcome: DefaultAccountObserver.Outcome? + let config: ClaudeConfigDirDiscovery.Result? + let cowork: ClaudeCoworkDiscovery.Result? + let knownIdentities: Set + let preferredIdentities: [String: String] + let desktopPolicy: ClaudeDesktopAccountPolicy + + static func observe( + observer: DefaultAccountObserver, + accountsStore: ProviderAccountsStore, + families: Set, + claudeDiscovery: ClaudeConfigDirDiscovery?, + coworkDiscovery: ClaudeCoworkDiscovery?, + preparedDiscovery: PreparedProviderAccountDiscovery? + ) -> Self { + let outcomes: [FamilyOutcome] = [ + ("claude", { observer.observeClaude() }), + ("codex", { observer.observeCodex() }), + ].compactMap { family, observe in + families.contains(family) ? FamilyOutcome(family: family, outcome: observe()) : nil + } + let defaultOutcome = outcomes.first { $0.family == "claude" }?.outcome + if case .unresolved? = defaultOutcome { + AppLog.info(.config, "discovery: claude default identity is unreadable; checking independently verified config-dir and Desktop accounts") + } + + let config = defaultOutcome != nil + ? preparedDiscovery?.config ?? claudeDiscovery?.run() + : nil + let cowork = defaultOutcome != nil + ? preparedDiscovery?.cowork ?? coworkDiscovery?.run() + : nil + for note in (config?.notes ?? []) + (cowork?.notes ?? []) { + AppLog.info(.config, "discovery: \(note)") + } + + var knownIdentities = Set( + accountsStore.records + .filter { $0.family == "claude" } + .flatMap { [$0.identityKey] + ($0.identityAliases ?? []) } + ) + if case .resolved(let identity, _, _)? = defaultOutcome { + knownIdentities.insert(identity) + } + for finding in config?.findings ?? [] { + knownIdentities.insert(finding.identityKey) + } + if cowork?.truncated != true { + for sandbox in cowork?.sandboxes ?? [] { + if let identity = sandbox.identityKey { knownIdentities.insert(identity) } + } + } + + var preferredIdentities: [String: String] = [:] + if case .resolved(let raw, _, _)? = defaultOutcome, + let identity = ClaudeIdentity(raw) + { + preferredIdentities[identity.user] = identity.key + } + for finding in config?.findings ?? [] { + guard let identity = ClaudeIdentity(finding.identityKey), + preferredIdentities[identity.user] == nil + else { continue } + preferredIdentities[identity.user] = identity.key + } + + return Self( + outcomes: outcomes, + defaultOutcome: defaultOutcome, + config: config, + cowork: cowork, + knownIdentities: knownIdentities, + preferredIdentities: preferredIdentities, + desktopPolicy: ClaudeDesktopAccountPolicy( + records: accountsStore.records, + defaultOutcome: defaultOutcome, + configFindings: config?.findings ?? [], + coworkScan: cowork + ) + ) + } + + func canonicalIdentity(_ raw: String) -> String? { + ClaudeIdentity.canonical(raw, among: knownIdentities, preferred: preferredIdentities) + } +} + +/// Credential-bearing default/config sources choose the identity spelling a bound runtime can +/// verify; Cowork partitions may attach roots later, but never change that source ownership. +@MainActor +struct ClaudeAccountPlan { + struct Account { + var identityKey: String + var label: String? + var sources: [ProviderAccountSource] + var credential: ClaudeAccountCard.Credential + var logRoots: [URL] + var additionalLogRoots: [URL] = [] + } + + var identityKeysByCard: [String: String] = [:] + var otherObservations: [ProviderAccountsStore.AccountObservation] = [] + var accounts: [String: Account] = [:] + var orderedIdentities: [String] = [] + var defaultIdentity: String? + + static func make(from observed: ClaudeSourceObservations) -> Self { + var plan = Self() + plan.observeDefaults(observed) + plan.attachConfigDirectories(observed) + return plan + } + + private mutating func observeDefaults(_ observed: ClaudeSourceObservations) { + for familyOutcome in observed.outcomes { + let family = familyOutcome.family + switch familyOutcome.outcome { + case .resolved(let rawIdentity, let label, let anchor): + let identity: String + if family == "claude" { + guard let canonical = observed.canonicalIdentity(rawIdentity) else { + AppLog.warn(.config, "accounts: claude default identity omits its organization while multiple organizations share that login; source quarantined") + continue + } + identity = canonical + defaultIdentity = canonical + orderedIdentities.append(canonical) + accounts[canonical] = Account( + identityKey: canonical, + label: label, + sources: [ProviderAccountSource( + kind: .defaultHome, anchor: anchor, holdsDefaultSource: true + )], + credential: .defaultHome, + logRoots: [URL(fileURLWithPath: anchor)] + ) + } else { + identity = rawIdentity + otherObservations.append(ProviderAccountsStore.AccountObservation( + family: family, + identityKey: identity, + label: label, + sources: [ProviderAccountSource( + kind: .defaultHome, anchor: anchor, holdsDefaultSource: true + )] + )) + identityKeysByCard[family] = identity + } + AppLog.info(.config, "accounts: \(family) default identity resolved (\(ProviderAccountID.make(family: family, identityKey: identity)))") + case .unresolved(let reason): + AppLog.info(.config, "accounts: \(family) default identity unresolved — \(reason)") + case .absent: + AppLog.debug(.config, "accounts: \(family) has no default login") + } + } + } + + private mutating func attachConfigDirectories(_ observed: ClaudeSourceObservations) { + for finding in observed.config?.findings ?? [] { + guard let identity = observed.canonicalIdentity(finding.identityKey) else { + AppLog.warn(.config, "discovery: claude config-dir identity omits its organization while multiple organizations share that login; source quarantined") + continue + } + let source = ProviderAccountSource( + kind: .configDir, + anchor: finding.anchorPath, + holdsDefaultSource: false, + keychainLiteral: finding.keychainLiteral + ) + let root = URL(fileURLWithPath: finding.anchorPath) + if var account = accounts[identity] { + appendUnique(source, to: &account.sources) + appendUnique(root, to: &account.logRoots) + if account.credential == .defaultHome { + appendUnique(root, to: &account.additionalLogRoots) + } + if account.label == nil { account.label = finding.label } + accounts[identity] = account + } else { + orderedIdentities.append(identity) + accounts[identity] = Account( + identityKey: identity, + label: finding.label, + sources: [source], + credential: .configDir( + path: finding.anchorPath, + keychainLiteral: finding.keychainLiteral + ), + logRoots: [root] + ) + } + } + } +} + +/// Cowork discovery changes spend routing and Desktop candidates only after their ownership has +/// been proven. A partial scan remains account-safety evidence but contributes no spend roots. +@MainActor +struct ClaudeCoworkPartition { + var defaultRoots: [URL] = [] + var requiresPartition = false + + static func make( + from observed: ClaudeSourceObservations, + plan: inout ClaudeAccountPlan, + hasDesktopCredentialMaterial: @Sendable () -> Bool + ) -> Self { + var partition = Self() + var unidentifiedRoots: [URL] = [] + var desktopCredentialMaterial: Bool? + + if let scan = observed.cowork, !scan.truncated { + for sandbox in scan.sandboxes { + guard let rawIdentity = sandbox.identityKey else { + unidentifiedRoots.append(sandbox.root) + continue + } + guard let identity = observed.canonicalIdentity(rawIdentity) else { + partition.requiresPartition = true + AppLog.warn(.config, "discovery: cowork sandbox identity omits its organization while multiple organizations share that login; sandbox quarantined") + continue + } + if identity == plan.defaultIdentity { + appendUnique(sandbox.root, to: &partition.defaultRoots) + continue + } + + partition.requiresPartition = true + let hasAmbiguousOwner = observed.desktopPolicy.hasAmbiguousOrganization( + sandbox.organization + ) + let desktopSource = ProviderAccountSource( + kind: .desktop, anchor: nil, holdsDefaultSource: false + ) + if var account = plan.accounts[identity] { + appendUnique(sandbox.root, to: &account.logRoots) + if sandbox.organization != nil && !hasAmbiguousOwner { + appendUnique(desktopSource, to: &account.sources) + } else if hasAmbiguousOwner { + AppLog.warn(.config, "discovery: cowork organization names multiple account owners; Desktop credential source quarantined") + } + if account.label == nil { account.label = sandbox.label } + plan.accounts[identity] = account + continue + } + + guard let organization = sandbox.organization?.nilIfEmpty else { + AppLog.warn(.config, "discovery: cowork account \(ProviderAccountID.make(family: "claude", identityKey: identity)) has no organization pin; sandbox quarantined") + continue + } + guard !hasAmbiguousOwner else { + AppLog.warn(.config, "discovery: cowork organization names multiple account owners; Desktop-only account quarantined") + continue + } + if desktopCredentialMaterial == nil { + desktopCredentialMaterial = hasDesktopCredentialMaterial() + } + guard desktopCredentialMaterial == true else { + AppLog.info(.config, "discovery: cowork account \(ProviderAccountID.make(family: "claude", identityKey: identity)) has no current Desktop credential material; historical sandbox skipped") + continue + } + plan.orderedIdentities.append(identity) + plan.accounts[identity] = ClaudeAccountPlan.Account( + identityKey: identity, + label: sandbox.label, + sources: [desktopSource], + credential: .desktop(organization: organization), + logRoots: [sandbox.root] + ) + } + } + + if plan.accounts.count > 1 { partition.requiresPartition = true } + if !unidentifiedRoots.isEmpty, partition.requiresPartition { + AppLog.warn(.config, "discovery: \(unidentifiedRoots.count) unidentified cowork sandbox(es) quarantined because account ownership cannot be proven") + } + if observed.cowork?.truncated == true { + partition.requiresPartition = true + partition.defaultRoots = [] + AppLog.warn(.config, "discovery: cowork scan truncated; cowork spend withheld until a complete scan proves account ownership") + } + return partition + } +} + +private func appendUnique(_ value: Value, to values: inout [Value]) { + if !values.contains(value) { values.append(value) } +} diff --git a/Sources/OpenUsage/Services/ClaudeDesktopAccountPolicy.swift b/Sources/OpenUsage/Services/ClaudeDesktopAccountPolicy.swift new file mode 100644 index 000000000..fcd30f53f --- /dev/null +++ b/Sources/OpenUsage/Services/ClaudeDesktopAccountPolicy.swift @@ -0,0 +1,183 @@ +import Foundation + +/// Account aliases combine only when their complete identity universe proves one organization. +struct ClaudeIdentity: Hashable, Sendable { + let user: String + let organization: String? + + init?(_ raw: String) { + let normalized = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let components = normalized.split(separator: "|", omittingEmptySubsequences: false) + guard (1...2).contains(components.count), + !components[0].isEmpty, + components.count == 1 || !components[1].isEmpty, + normalized.rangeOfCharacter(from: .whitespacesAndNewlines.union(.controlCharacters)) == nil + else { return nil } + user = String(components[0]) + organization = components.count == 2 ? String(components[1]) : nil + } + + var key: String { organization.map { "\(user)|\($0)" } ?? user } + func matchesExactly(_ other: ClaudeIdentity) -> Bool { self == other } + + static func canonical( + _ identity: ClaudeIdentity, + among known: some Sequence, + preferred: [String: ClaudeIdentity] = [:] + ) -> ClaudeIdentity? { + let organizations = Set(known.compactMap { candidate in + candidate.user == identity.user ? candidate.organization : nil + }) + if identity.organization != nil { + if organizations.count == 1, + let preferred = preferred[identity.user], + preferred.organization == nil + { + return preferred + } + return identity + } + guard organizations.count <= 1 else { return nil } + if let preferred = preferred[identity.user], preferred.organization == nil { return preferred } + guard let organization = organizations.first else { return identity } + return ClaudeIdentity("\(identity.user)|\(organization)") + } + + static func canonical( + _ raw: String, + among known: some Sequence, + preferred: [String: String] = [:] + ) -> String? { + guard let identity = ClaudeIdentity(raw) else { return nil } + let identities = known.compactMap(ClaudeIdentity.init) + let preferences = preferred.reduce(into: [String: ClaudeIdentity]()) { result, entry in + if let identity = ClaudeIdentity(entry.value) { result[entry.key.lowercased()] = identity } + } + return canonical(identity, among: identities, preferred: preferences)?.key + } +} + +/// A Desktop token is either forbidden, limited to one verified organization, or allowed to follow +/// the active organization only when the complete account evidence proves one possible owner. +enum ClaudeDesktopAccessPolicy: Equatable, Sendable { + case denied + case activeOrganization + case pinned(String) + + var organization: String? { + guard case .pinned(let organization) = self else { return nil } + return organization + } +} + +/// Keeps Desktop credential ownership independent from Cowork spend-log routing. Desktop tokens +/// identify an organization but not a user, so hidden accounts still contribute ownership evidence. +struct ClaudeDesktopAccountPolicy { + private let knownIdentities: Set + private let usersByOrganization: [String: Set] + private let organizationsByUser: [String: Set] + private let usersWithoutKnownOrganization: Set + + init( + records: [ProviderAccountRecord], + defaultOutcome: DefaultAccountObserver.Outcome?, + configFindings: [ClaudeConfigDirDiscovery.Finding], + coworkScan: ClaudeCoworkDiscovery.Result? + ) { + var identities = Set( + records + .filter { $0.family == "claude" } + .flatMap { [$0.identityKey] + ($0.identityAliases ?? []) } + .compactMap(ClaudeIdentity.init) + ) + if case .resolved(let raw, _, _)? = defaultOutcome, + let identity = ClaudeIdentity(raw) + { + identities.insert(identity) + } + for finding in configFindings { + if let identity = ClaudeIdentity(finding.identityKey) { + identities.insert(identity) + } + } + // A partial walk cannot assign logs or create cards, but identities it positively observed + // still disprove exclusive Desktop ownership and must remain usable as safety evidence. + for sandbox in coworkScan?.sandboxes ?? [] { + if let raw = sandbox.identityKey, let identity = ClaudeIdentity(raw) { + identities.insert(identity) + } + } + knownIdentities = identities + + var usersByOrganization: [String: Set] = [:] + var organizationsByUser: [String: Set] = [:] + var organizationlessUsers = Set() + for identity in identities { + guard let organization = identity.organization else { + organizationlessUsers.insert(identity.user) + continue + } + usersByOrganization[organization, default: []].insert(identity.user) + organizationsByUser[identity.user, default: []].insert(organization) + } + self.usersByOrganization = usersByOrganization + self.organizationsByUser = organizationsByUser + // An org-less alias of a user with one known org is not a second unknown account; an + // entirely org-less different user, by contrast, could own any Desktop organization. + usersWithoutKnownOrganization = organizationlessUsers.subtracting(organizationsByUser.keys) + } + + func hasAmbiguousOrganization(_ organization: String?) -> Bool { + guard let organization = organization?.nilIfEmpty?.lowercased() else { return false } + let identifiedUsers = usersByOrganization[organization] ?? [] + return identifiedUsers.count > 1 + || usersWithoutKnownOrganization.contains { !identifiedUsers.contains($0) } + } + + /// Prefer a known org even when Claude Code's current state temporarily omits it. The pin is + /// valid only when aliases, persisted records, and scanned sources name exactly one possibility. + func organization(for identityKey: String?) -> String? { + guard let identityKey, let identity = ClaudeIdentity(identityKey) else { return nil } + if let organization = identity.organization { return organization } + guard let organizations = organizationsByUser[identity.user], organizations.count == 1 else { + return nil + } + return organizations.first + } + + /// One closed decision travels unchanged from source assembly into the auth store; no runtime + /// reconstructs credential safety from account count, log partitions, or unrelated booleans. + func access( + for identityKey: String, + allowsActiveOrganization: Bool + ) -> ClaudeDesktopAccessPolicy { + if let organization = organization(for: identityKey) { + return hasAmbiguousOrganization(organization) ? .denied : .pinned(organization) + } + return allowsActiveOrganization ? .activeOrganization : .denied + } + + func allowsUnpinnedFallback( + defaultIdentity: String?, + hasExactlyOneDefaultAccount: Bool, + coworkScan: ClaudeCoworkDiscovery.Result? + ) -> Bool { + guard hasExactlyOneDefaultAccount, + let defaultIdentity, + let account = ClaudeIdentity(defaultIdentity) + else { return false } + // An explicit org still gets its pinned fallback during an incomplete walk; preserve the + // stricter scoped-keychain behavior rather than unnecessarily re-enabling the bare item. + if coworkScan?.truncated == true, account.organization != nil { return false } + + var organizations: Set = [] + for identity in knownIdentities { + guard identity.user == account.user else { return false } + if let organization = identity.organization { + organizations.insert(organization) + if organizations.count > 1 { return false } + } + } + return true + } +} diff --git a/Sources/OpenUsage/Services/LocalUsageAPI.swift b/Sources/OpenUsage/Services/LocalUsageAPI.swift index 401997907..d114583b5 100644 --- a/Sources/OpenUsage/Services/LocalUsageAPI.swift +++ b/Sources/OpenUsage/Services/LocalUsageAPI.swift @@ -28,6 +28,22 @@ enum LocalUsageAPI { func matchingCardIDs(for token: String) -> [String] { knownIDs.filter { $0 == token || ProviderAccountID.family(of: $0) == token }.sorted() } + + /// A copy whose snapshots carry live card titles: `titles` maps card id → resolved title + /// (from the account registry, so renames show). Applied where the state is captured — the + /// snapshots themselves always store the derived name, so a rename never persists into the + /// cache or iCloud. Cards without an entry keep their baked name. + func resolvingDisplayNames(_ titles: [String: String]) -> State { + guard !titles.isEmpty else { return self } + var state = self + state.snapshots = snapshots.mapValues { snapshot in + guard let title = titles[snapshot.providerID] else { return snapshot } + var snapshot = snapshot + snapshot.displayName = title + return snapshot + } + return state + } } struct Response: Equatable, Sendable { diff --git a/Sources/OpenUsage/Services/LocalUsageServer.swift b/Sources/OpenUsage/Services/LocalUsageServer.swift index 03e37c672..87d2316b6 100644 --- a/Sources/OpenUsage/Services/LocalUsageServer.swift +++ b/Sources/OpenUsage/Services/LocalUsageServer.swift @@ -1,10 +1,30 @@ import Foundation import Network +/// The listener boundary keeps asynchronous port-release races testable without binding a real port. +@MainActor +protocol LocalUsageListening: AnyObject { + func setStateHandler(_ handler: (@Sendable (NWListener.State) -> Void)?) + func setConnectionHandler(_ handler: (@Sendable (NWConnection) -> Void)?) + func start(queue: DispatchQueue) + func cancel() +} + +@MainActor +private final class NetworkLocalUsageListener: LocalUsageListening { + private let listener: NWListener + + init(parameters: NWParameters) throws { listener = try NWListener(using: parameters) } + func setStateHandler(_ handler: (@Sendable (NWListener.State) -> Void)?) { listener.stateUpdateHandler = handler } + func setConnectionHandler(_ handler: (@Sendable (NWConnection) -> Void)?) { listener.newConnectionHandler = handler } + func start(queue: DispatchQueue) { listener.start(queue: queue) } + func cancel() { listener.cancel() } +} + /// Loopback-only HTTP/1.1 listener for the read-only usage API on `127.0.0.1:6736`. Starts with -/// the app; when the port is already taken the feature is silently disabled for the session -/// (matching the original app). At most 16 requests are served concurrently — beyond that a -/// connection gets `503 {"error":"server_busy"}` immediately. +/// the app; account graph replacement waits for the old listener's completed cancellation before +/// binding its successor. At most 16 requests are served concurrently — beyond that a connection gets +/// `503 {"error":"server_busy"}` immediately. @MainActor final class LocalUsageServer { static let port: UInt16 = 6736 @@ -12,63 +32,133 @@ final class LocalUsageServer { private static let headLimit = 8192 private let state: @MainActor () -> LocalUsageAPI.State + private let makeListener: @MainActor (NWParameters) throws -> any LocalUsageListening private let queue = DispatchQueue(label: "openusage.local-api") - private var listener: NWListener? + private var listener: (any LocalUsageListening)? + private var listenerGeneration: UUID? + private var cancellationContinuation: CheckedContinuation? + private var shouldRun = false private var activeConnections = 0 - init(state: @escaping @MainActor () -> LocalUsageAPI.State) { + init( + state: @escaping @MainActor () -> LocalUsageAPI.State, + makeListener: (@MainActor (NWParameters) throws -> any LocalUsageListening)? = nil + ) { self.state = state + self.makeListener = makeListener ?? { try NetworkLocalUsageListener(parameters: $0) } } func start() { + guard !shouldRun else { return } + shouldRun = true let parameters = NWParameters.tcp parameters.requiredLocalEndpoint = NWEndpoint.hostPort( host: "127.0.0.1", port: NWEndpoint.Port(rawValue: Self.port)! ) - let listener: NWListener + let listener: any LocalUsageListening do { - listener = try NWListener(using: parameters) + listener = try makeListener(parameters) } catch { + shouldRun = false AppLog.info(.localAPI, "disabled: \(error.localizedDescription)") return } - listener.stateUpdateHandler = { state in - if case .failed(let error) = state { - // Most commonly the port is already in use — silently disable for this session. - AppLog.info(.localAPI, "disabled: \(error.localizedDescription)") + let generation = UUID() + listenerGeneration = generation + listener.setStateHandler { [weak self] state in + Task { @MainActor [weak self] in + guard let self, + self.shouldRun, + self.listenerGeneration == generation + else { return } + if case .failed(let error) = state { + self.disableListener(error) + } } } - listener.newConnectionHandler = { connection in + listener.setConnectionHandler { [weak self] connection in Task { @MainActor [weak self] in - self?.accept(connection) + guard let self, + self.shouldRun, + self.listenerGeneration == generation + else { + connection.cancel() + return + } + self.accept(connection, generation: generation) } } - listener.start(queue: queue) self.listener = listener + listener.start(queue: queue) } - private func accept(_ connection: NWConnection) { + private func disableListener(_ error: Error) { + shouldRun = false + listenerGeneration = nil + listener?.setConnectionHandler(nil) + listener?.setStateHandler(nil) + listener?.cancel() + listener = nil + AppLog.info(.localAPI, "disabled: \(error.localizedDescription)") + } + + /// Network cancellation is asynchronous. Keep its state handler installed until `.cancelled` + /// confirms the socket was released, then let the replacement account graph bind the same port. + func stop() async { + shouldRun = false + listenerGeneration = nil + guard let listener else { return } + listener.setConnectionHandler(nil) + await withCheckedContinuation { continuation in + cancellationContinuation = continuation + listener.setStateHandler { [weak self] state in + guard case .cancelled = state else { return } + Task { @MainActor [weak self] in + self?.finishListenerCancellation() + } + } + listener.cancel() + } + } + + private func finishListenerCancellation() { + listener?.setStateHandler(nil) + listener = nil + let continuation = cancellationContinuation + cancellationContinuation = nil + continuation?.resume() + } + + private func accept(_ connection: NWConnection, generation: UUID) { connection.start(queue: queue) guard activeConnections < Self.maxConcurrentConnections else { Self.send(LocalUsageAPI.busy, over: connection) return } activeConnections += 1 - receiveHead(connection, buffered: Data()) + receiveHead(connection, buffered: Data(), generation: generation) } /// Reads until the end of the request head (`\r\n\r\n`). GET/OPTIONS bodies are irrelevant, /// so the head is all the router needs. - private func receiveHead(_ connection: NWConnection, buffered: Data) { - connection.receive(minimumIncompleteLength: 1, maximumLength: Self.headLimit) { data, _, isComplete, error in + private func receiveHead(_ connection: NWConnection, buffered: Data, generation: UUID) { + connection.receive(minimumIncompleteLength: 1, maximumLength: Self.headLimit) { [weak self] data, _, isComplete, error in Task { @MainActor [weak self] in guard let self else { connection.cancel() return } + guard Self.canServeConnection( + isRunning: self.shouldRun, + listenerGeneration: self.listenerGeneration, + connectionGeneration: generation + ) else { + self.finish(connection, with: nil) + return + } var buffered = buffered if let data { buffered.append(data) @@ -79,12 +169,22 @@ final class LocalUsageServer { } else if error != nil || isComplete || buffered.count >= Self.headLimit { self.finish(connection, with: nil) } else { - self.receiveHead(connection, buffered: buffered) + self.receiveHead(connection, buffered: buffered, generation: generation) } } } } + /// Accepted sockets can outlive their listener during account-graph replacement. Their original + /// generation must still own the live listener before any old account state can be routed. + nonisolated static func canServeConnection( + isRunning: Bool, + listenerGeneration: UUID?, + connectionGeneration: UUID + ) -> Bool { + isRunning && listenerGeneration == connectionGeneration + } + private func route(head: String) -> LocalUsageAPI.Response { let (method, path) = Self.parseRequestLine(head) // Path is secret-free (the loopback API serves only normalized usage); Debug-only. diff --git a/Sources/OpenUsage/Services/PeerHistoryRemapper.swift b/Sources/OpenUsage/Services/PeerHistoryRemapper.swift new file mode 100644 index 000000000..28a7be8ca --- /dev/null +++ b/Sources/OpenUsage/Services/PeerHistoryRemapper.swift @@ -0,0 +1,200 @@ +import Foundation + +/// Matches synced peer histories to this Mac's cards by ACCOUNT identity instead of by card id. +/// +/// Account-card ids don't necessarily describe the same login on different Macs: the first account +/// observed on each machine keeps the bare family id. An account history therefore enters a local +/// card only when both machines identify the same account unambiguously. Older v1 account histories +/// and unresolved identities are quarantined rather than guessed from a matching provider id. +enum PeerHistoryRemapper { + struct Remapped { + /// Peer histories addressed to a LOCAL card id, ready for the same-id day merge. + var histories: [(cardID: String, history: ProviderUsageHistory)] = [] + /// Peer accounts with no local card, keyed by identity. + var remoteOnly: [RemoteOnlyHistory] = [] + /// Histories whose owner cannot be established safely. These never reach a concrete card, + /// Total Spend, or a subsequently published local history document. + var quarantined: [QuarantinedHistory] = [] + } + + struct QuarantinedHistory { + enum Reason: Equatable { + case missingPeerIdentity + case ambiguousPeerIdentity + case unresolvedLocalIdentity + case ambiguousLocalIdentity + } + + var cardID: String + var family: String + var reason: Reason + } + + struct RemoteOnlyHistory { + var identityKey: String + var family: String + /// The account's identity-derived display code (`claude@ab12cd34`). A card may retain the + /// historical bare family id on another Mac, so this is a presentation/grouping key rather + /// than a claim that card ids are globally identical. + var cardID: String + var histories: [ProviderUsageHistory] + } + + /// `localIdentityByCardID` is this Mac's card → identity map (the launch account pass's + /// `identityKeysByCard`). + static func remap( + documents: [UsageHistoryDocument], + localIdentityByCardID: [String: String], + localAccountCardIDs: Set? = nil + ) -> Remapped { + struct FamilyIdentity: Hashable { + var family: String + var identity: String + } + + let newestDocuments = UsageHistoryDocument.newestByDevice(documents) + var knownClaudeIdentities: Set = [] + func collectClaudeIdentity(cardID: String, identity: String) { + guard ProviderAccountID.family(of: cardID) == "claude", + let parsed = ClaudeIdentity(identity) + else { return } + knownClaudeIdentities.insert(parsed) + } + for (cardID, identity) in localIdentityByCardID { + collectClaudeIdentity(cardID: cardID, identity: identity) + } + for document in newestDocuments { + for (cardID, identity) in document.identities ?? [:] { + collectClaudeIdentity(cardID: cardID, identity: identity) + } + } + + // Claude's state can report the same login either as its account UUID alone or as + // UUID|organization. Only fold those spellings when the complete local-and-peer view + // proves one organization; an org-less login spanning several organizations is unsafe. + func canonicalIdentity(family: String, identity: String) -> String? { + guard family == "claude" else { return identity } + guard let parsed = ClaudeIdentity(identity) else { return nil } + return ClaudeIdentity.canonical(parsed, among: knownClaudeIdentities)?.key + } + + var localCardsByIdentity: [FamilyIdentity: [String]] = [:] + var ambiguousLocalClaudeUsers: Set = [] + for (cardID, identity) in localIdentityByCardID { + let family = ProviderAccountID.family(of: cardID) + guard ProviderAccountID.families.contains(family), !identity.isEmpty else { continue } + guard let canonical = canonicalIdentity(family: family, identity: identity) else { + if family == "claude", let parsed = ClaudeIdentity(identity) { + ambiguousLocalClaudeUsers.insert(parsed.user) + } + continue + } + localCardsByIdentity[FamilyIdentity(family: family, identity: canonical), default: []] + .append(cardID) + } + let localCardIDs = localAccountCardIDs ?? Set(localIdentityByCardID.keys) + + var result = Remapped() + var remoteByIdentity: [FamilyIdentity: RemoteOnlyHistory] = [:] + func collectRemoteOnly(identity: String, family: String, cardID: String, history: ProviderUsageHistory) { + let key = FamilyIdentity(family: family, identity: identity) + var entry = remoteByIdentity[key] ?? RemoteOnlyHistory( + identityKey: identity, family: family, cardID: cardID, histories: [] + ) + entry.histories.append(history) + remoteByIdentity[key] = entry + } + + for document in newestDocuments { + let peerIdentityCounts = Dictionary( + (document.identities ?? [:]).compactMap { cardID, identity -> (FamilyIdentity, Int)? in + let family = ProviderAccountID.family(of: cardID) + guard let canonical = canonicalIdentity(family: family, identity: identity) else { + return nil + } + return (FamilyIdentity(family: family, identity: canonical), 1) + }, + uniquingKeysWith: + + ) + for (peerCardID, history) in document.providers.sorted(by: { $0.key < $1.key }) { + let family = ProviderAccountID.family(of: peerCardID) + + guard ProviderAccountID.families.contains(family) else { + result.histories.append((peerCardID, history)) + continue + } + + guard let identity = document.identities?[peerCardID], !identity.isEmpty else { + result.quarantined.append(QuarantinedHistory( + cardID: peerCardID, family: family, reason: .missingPeerIdentity + )) + continue + } + + guard let canonical = canonicalIdentity(family: family, identity: identity) else { + result.quarantined.append(QuarantinedHistory( + cardID: peerCardID, family: family, reason: .ambiguousPeerIdentity + )) + continue + } + let identityKey = FamilyIdentity(family: family, identity: canonical) + guard peerIdentityCounts[identityKey] == 1 else { + result.quarantined.append(QuarantinedHistory( + cardID: peerCardID, family: family, reason: .ambiguousPeerIdentity + )) + continue + } + + if family == "claude", + let parsed = ClaudeIdentity(canonical), + ambiguousLocalClaudeUsers.contains(parsed.user) + { + result.quarantined.append(QuarantinedHistory( + cardID: peerCardID, family: family, reason: .ambiguousLocalIdentity + )) + continue + } + + let localMatches = localCardsByIdentity[identityKey] ?? [] + if localMatches.count == 1 { + result.histories.append((localMatches[0], history)) + continue + } + if localMatches.count > 1 { + result.quarantined.append(QuarantinedHistory( + cardID: peerCardID, family: family, reason: .ambiguousLocalIdentity + )) + continue + } + + let knownFamilyCards = localCardIDs.filter { + ProviderAccountID.family(of: $0) == family + } + let hasUnresolvedLocalCard = knownFamilyCards.contains { + localIdentityByCardID[$0] == nil + } + let hasResolvedLocalFamily = localIdentityByCardID.keys.contains { + ProviderAccountID.family(of: $0) == family + } + guard hasResolvedLocalFamily, !hasUnresolvedLocalCard else { + result.quarantined.append(QuarantinedHistory( + cardID: peerCardID, family: family, reason: .unresolvedLocalIdentity + )) + continue + } + + collectRemoteOnly( + identity: canonical, + family: family, + cardID: ProviderAccountID.make(family: family, identityKey: canonical), + history: history + ) + } + } + + result.remoteOnly = remoteByIdentity.values.sorted { + ($0.family, $0.identityKey) < ($1.family, $1.identityKey) + } + return result + } +} diff --git a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift index f027b6a17..2996a6cbd 100644 --- a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift +++ b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift @@ -1,97 +1,227 @@ import Foundation -/// The launch-time account pass: read which account is signed in at each family's default home, -/// reconcile the account registry, and expose the per-card identity map that the snapshot cache's -/// account stamp consumes. Runs once per launch (app) or per invocation (one-shot CLI); a mid-run -/// swap is caught on the next launch. +/// Account-routing inputs cross the assembly/runtime boundary together. +struct ClaudeRuntimePlan: Sendable { + let cards: [ClaudeAccountCard] + let allowsUnboundFallback: Bool + let defaultCoworkRoots: [URL]? + + init( + cards: [ClaudeAccountCard] = [], + allowsUnboundFallback: Bool = true, + defaultCoworkRoots: [URL]? = nil + ) { + self.cards = cards + self.allowsUnboundFallback = allowsUnboundFallback + self.defaultCoworkRoots = defaultCoworkRoots + } +} + +/// One verified account-to-runtime binding. The credential source belongs to its permanent account +/// record, so the original bare card can move while another account takes over the default home. +struct ClaudeAccountCard: Equatable, Sendable { + enum Credential: Equatable, Sendable { + case defaultHome + case configDir(path: String, keychainLiteral: String) + case desktop(organization: String) + } + + var id: String + var displayName: String + var identityKey: String + var credential: Credential + var logRoots: [URL] + var additionalLogRoots: [URL] = [] + var coworkRootsOverride: [URL]? + /// Credential ownership is decided once by assembly, never reconstructed from spend roots. + var desktopAccess: ClaudeDesktopAccessPolicy = .denied + /// The bare CLI keychain item is safe only when one verified account owns the default home. + var allowsUnscopedKeychainFallback = false +} + +/// Filesystem discovery happens off the main actor; account reconciliation remains main-actor-owned. +struct PreparedProviderAccountDiscovery: Sendable { + var config: ClaudeConfigDirDiscovery.Result + var cowork: ClaudeCoworkDiscovery.Result +} + +/// Account assembly has five explicit phases: observe identity sources, plan verified accounts, +/// partition Cowork sessions, reconcile stable records, and bind one runtime per observed account. @MainActor struct ProviderAccountAssembly { - /// Card id → the account identity signed in there this launch. Phase 1 observes only default - /// homes, so the keys are the bare family ids; a family whose identity didn't resolve is absent. let identityKeysByCard: [String: String] + var claudeCards: [ClaudeAccountCard] = [] + var allowsUnboundClaudeFallback = true - /// `waitsForLoginShell`: true for the menu-bar app (a Finder/Dock launch inherits no shell - /// exports, so the pass leans on the login-shell layers), false for the one-shot CLI (a terminal - /// launch's process environment already carries the user's exports). - static func make(defaults: UserDefaults = .standard, waitsForLoginShell: Bool) -> ProviderAccountAssembly { - // The identity read needs the login shell's exports (CLAUDE_CONFIG_DIR/CODEX_HOME name the - // default homes), and it reads them through the very same reader the provider auth stores - // use — `ProcessEnvironmentReader`, which pins identity-relevant keys to the persisted - // shell-environment snapshot for the whole session, so identity and usage resolve the same - // homes no matter when the async capture lands. The one unreadable state is a genuinely - // FIRST Finder/Dock launch: capture still cold and no snapshot persisted yet — a - // shell-exported home override would be invisible, so that family's read must be skipped - // rather than misread as "no override". The skip is per family: a family whose home override - // is already visible in the process environment (a terminal launch, `launchctl setenv`) - // doesn't need the shell layers at all and still resolves. + var claudeRuntimePlan: ClaudeRuntimePlan { + ClaudeRuntimePlan( + cards: claudeCards, + allowsUnboundFallback: allowsUnboundClaudeFallback + ) + } + + static func make( + defaults: UserDefaults = .standard, + accountsStore: ProviderAccountsStore? = nil, + waitsForLoginShell: Bool, + preparedDiscovery: PreparedProviderAccountDiscovery? = nil + ) -> Self { + let store = accountsStore ?? ProviderAccountsStore(defaults: defaults) let shellFactsReadable = !waitsForLoginShell || LoginShellEnvironment.shared.capturedSuccessfully || ShellEnvironmentSnapshotStore.launchSnapshot != nil let families = shellFactsReadable ? ProviderAccountID.families : ProviderAccountID.families.filter { family in - guard let key = Self.homeOverrideKeys[family] else { return false } + guard let key = homeOverrideKeys[family] else { return false } return ProcessInfo.processInfo.environment[key]?.nilIfEmpty != nil } if families.count < ProviderAccountID.families.count { AppLog.info(.config, "account identity read skipped for \(ProviderAccountID.families.subtracting(families).sorted().joined(separator: ", ")): login shell cold and no shell-environment snapshot exists yet") } guard !families.isEmpty else { - return ProviderAccountAssembly(identityKeysByCard: [:]) + return Self( + identityKeysByCard: [:], + allowsUnboundClaudeFallback: !store.records.contains { $0.family == "claude" } + ) } return make( observer: DefaultAccountObserver(), - accountsStore: ProviderAccountsStore(defaults: defaults), - families: families + accountsStore: store, + families: families, + claudeDiscovery: ClaudeConfigDirDiscovery(), + coworkDiscovery: ClaudeCoworkDiscovery(), + preparedDiscovery: preparedDiscovery + ) + } + + static func make( + observer: DefaultAccountObserver, + accountsStore: ProviderAccountsStore, + families: Set = ProviderAccountID.families, + claudeDiscovery: ClaudeConfigDirDiscovery? = nil, + coworkDiscovery: ClaudeCoworkDiscovery? = nil, + preparedDiscovery: PreparedProviderAccountDiscovery? = nil, + hasDesktopCredentialMaterial: @Sendable () -> Bool = { + ClaudeDesktopAuthStore().hasCredentialMaterial() + } + ) -> Self { + let observed = ClaudeSourceObservations.observe( + observer: observer, + accountsStore: accountsStore, + families: families, + claudeDiscovery: claudeDiscovery, + coworkDiscovery: coworkDiscovery, + preparedDiscovery: preparedDiscovery + ) + var plan = ClaudeAccountPlan.make(from: observed) + let partition = ClaudeCoworkPartition.make( + from: observed, + plan: &plan, + hasDesktopCredentialMaterial: hasDesktopCredentialMaterial + ) + let records = reconcile(plan, observed: observed, accountsStore: accountsStore) + return bind( + plan: plan, + observed: observed, + partition: partition, + records: records, + accountsStore: accountsStore ) } - /// The environment variable that relocates each family's default home — the fact whose - /// invisibility (shell layers unreadable AND not in the process environment) makes that family's - /// identity read unsafe on a first launch. private static let homeOverrideKeys: [String: String] = [ "claude": "CLAUDE_CONFIG_DIR", "codex": "CODEX_HOME", ] - /// The environment-independent core, separated so tests inject a fixed observer and scratch - /// store. `families` limits the pass to the families whose home facts are readable this launch - /// (see `make(defaults:waitsForLoginShell:)`); a family left out is simply not observed — - /// no identity key, no reconciliation, exactly as if the pass never ran for it. - static func make( - observer: DefaultAccountObserver, - accountsStore: ProviderAccountsStore, - families: Set = ProviderAccountID.families - ) -> ProviderAccountAssembly { - var identityKeys: [String: String] = [:] - var observations: [ProviderAccountsStore.Observation] = [] + private static func reconcile( + _ plan: ClaudeAccountPlan, + observed: ClaudeSourceObservations, + accountsStore: ProviderAccountsStore + ) -> [ProviderAccountRecord] { + if observed.defaultOutcome != nil, plan.defaultIdentity == nil { + accountsStore.clearDefaultSource(family: "claude") + } + let claudeObservations: [ProviderAccountsStore.AccountObservation] = plan.orderedIdentities.compactMap { identity in + guard let account = plan.accounts[identity] else { return nil } + return ProviderAccountsStore.AccountObservation( + family: "claude", + identityKey: account.identityKey, + label: account.label, + sources: account.sources + ) + } + return accountsStore.reconcile(with: claudeObservations + plan.otherObservations) + } - let outcomes: [(family: String, outcome: DefaultAccountObserver.Outcome)] = [ - ("claude", { observer.observeClaude() }), - ("codex", { observer.observeCodex() }), - ].compactMap { family, observe in - families.contains(family) ? (family, observe()) : nil + private static func bind( + plan: ClaudeAccountPlan, + observed: ClaudeSourceObservations, + partition: ClaudeCoworkPartition, + records: [ProviderAccountRecord], + accountsStore: ProviderAccountsStore + ) -> Self { + let hasExactlyOneDefaultAccount = plan.accounts.count == 1 + && plan.defaultIdentity.flatMap { plan.accounts[$0]?.credential } == .defaultHome + let permitsUnscopedFallback = observed.desktopPolicy.allowsUnpinnedFallback( + defaultIdentity: plan.defaultIdentity, + hasExactlyOneDefaultAccount: hasExactlyOneDefaultAccount, + coworkScan: observed.cowork + ) + + var identityKeys = plan.identityKeysByCard + var cards: [ClaudeAccountCard] = [] + for identity in plan.orderedIdentities { + guard let planned = plan.accounts[identity], + let record = records.first(where: { + $0.family == "claude" + && $0.identityKey == identity + && !$0.removedTombstone + }) + else { continue } + let isDefaultHome = planned.credential == .defaultHome + let desktopAccess: ClaudeDesktopAccessPolicy = isDefaultHome + ? observed.desktopPolicy.access( + for: record.identityKey, + allowsActiveOrganization: permitsUnscopedFallback + ) + : .denied + cards.append(ClaudeAccountCard( + id: record.id, + displayName: accountsStore.derivedDisplayName(cardID: record.id) + ?? record.derivedDisplayName, + identityKey: record.identityKey, + credential: planned.credential, + logRoots: planned.logRoots, + additionalLogRoots: planned.additionalLogRoots, + coworkRootsOverride: isDefaultHome && partition.requiresPartition + ? partition.defaultRoots + : nil, + desktopAccess: desktopAccess, + allowsUnscopedKeychainFallback: isDefaultHome && permitsUnscopedFallback + )) + identityKeys[record.id] = record.identityKey + AppLog.info(.config, "accounts: claude card \(record.id) bound to \(sourceDescription(planned.credential)); \(planned.logRoots.count) verified log root(s)") } - for (family, outcome) in outcomes { - switch outcome { - case .resolved(let identityKey, let label, let anchor): - identityKeys[family] = identityKey - observations.append(ProviderAccountsStore.Observation( - family: family, - identityKey: identityKey, - label: label, - sources: [ProviderAccountSource(kind: .defaultHome, anchor: anchor, holdsDefaultSource: true)] - )) - AppLog.info(.config, "accounts: \(family) default identity resolved (\(ProviderAccountID.make(family: family, identityKey: identityKey)))") - case .unresolved(let reason): - // The soak signal for later phases: how often a real login can't name its account. - AppLog.info(.config, "accounts: \(family) default identity unresolved — \(reason)") - case .absent: - AppLog.debug(.config, "accounts: \(family) has no default login") - } + cards.sort { + if $0.id == "claude" { return true } + if $1.id == "claude" { return false } + return $0.id < $1.id } - accountsStore.reconcile(with: observations) - return ProviderAccountAssembly(identityKeysByCard: identityKeys) + return Self( + identityKeysByCard: identityKeys, + claudeCards: cards, + allowsUnboundClaudeFallback: !records.contains { $0.family == "claude" } + ) + } + + private static func sourceDescription(_ credential: ClaudeAccountCard.Credential) -> String { + switch credential { + case .defaultHome: "default home" + case .configDir: "config dir" + case .desktop: "desktop" + } } } diff --git a/Sources/OpenUsage/Services/UsageHistoryAggregator.swift b/Sources/OpenUsage/Services/UsageHistoryAggregator.swift index f6c73151f..09480318e 100644 --- a/Sources/OpenUsage/Services/UsageHistoryAggregator.swift +++ b/Sources/OpenUsage/Services/UsageHistoryAggregator.swift @@ -8,23 +8,44 @@ enum UsageHistoryAggregator { peerDocuments: [UsageHistoryDocument], descriptors: [String: UsageHistoryDescriptor], now: Date = Date() + ) -> [String: ProviderUsageHistory] { + var pairs: [(String, ProviderUsageHistory)] = [] + for document in UsageHistoryDocument.newestByDevice(peerDocuments) { + for (providerID, history) in document.providers { + pairs.append((providerID, history)) + } + } + return merged(localSnapshots: localSnapshots, peerHistories: pairs, descriptors: descriptors, now: now) + } + + /// The identity-remapped variant: peers arrive as (LOCAL card id, history) pairs — see + /// `PeerHistoryRemapper` — so the same account merges into the same card regardless of which Mac + /// calls it the default and which shows it as an extra account card. + static func merged( + localSnapshots: [String: ProviderSnapshot], + peerHistories: [(String, ProviderUsageHistory)], + descriptors: [String: UsageHistoryDescriptor], + now: Date = Date() ) -> [String: ProviderUsageHistory] { var inputs: [String: [ProviderUsageHistory]] = [:] - let peerDocuments = UsageHistoryDocument.newestByDevice(peerDocuments) for (providerID, descriptor) in descriptors where descriptor.scope == .machineLocal { if let local = localSnapshots[providerID]?.usageHistory { inputs[providerID, default: []].append(local) } - for document in peerDocuments { - if let peer = document.providers[providerID] { - inputs[providerID, default: []].append(peer) - } + for (peerID, history) in peerHistories where peerID == providerID { + inputs[providerID, default: []].append(history) } } let includedDays = UsageHistoryWindow.dayKeys(through: now) return inputs.mapValues { merge($0, includedDays: includedDays) } } + /// Day-sum several histories into one — the same merge the per-card path uses, exposed for + /// remote-only accounts (histories synced from other Macs with no local card). + static func mergeHistories(_ histories: [ProviderUsageHistory], now: Date = Date()) -> ProviderUsageHistory { + merge(histories, includedDays: UsageHistoryWindow.dayKeys(through: now)) + } + private static func merge( _ histories: [ProviderUsageHistory], includedDays: Set diff --git a/Sources/OpenUsage/Services/UsageReader.swift b/Sources/OpenUsage/Services/UsageReader.swift index 2e19d93f7..bfb6c13bb 100644 --- a/Sources/OpenUsage/Services/UsageReader.swift +++ b/Sources/OpenUsage/Services/UsageReader.swift @@ -37,13 +37,10 @@ public struct UsageReader { } public func read(providerID requestedProviderID: String? = nil, force: Bool = false) async throws -> UsageReadResult { - let providers = providersOverride ?? ProviderCatalog.make(defaults: defaults) - let registry = WidgetRegistry.from(providers) - let knownIDs = Set(registry.providers.map(\.id)) - let enablement = ProviderEnablementStore(defaults: defaults) // The launch account pass (see `ProviderAccountAssembly`): resolves each family's default - // account so cached snapshots are guarded — and refreshed ones stamped — with the correct - // account. Skipped when a test injects its own providers — they have no real homes to read. + // account and discovers every verified Claude runtime before the catalog is constructed. + // The CLI and menu-bar app therefore expose the same stable account graph and cache stamps. + // Skipped when a test injects its own providers — they have no real homes to read. // // Warm the login-shell capture FIRST (off-main, one bounded subprocess). Identity-relevant // keys are pinned to the persisted shell-environment snapshot, but a CLI spawned without the @@ -56,9 +53,21 @@ public struct UsageReader { _ = LoginShellEnvironment.shared.ensureCaptured() }.value } + let accounts = providersOverride == nil ? ProviderAccountsStore(defaults: defaults) : nil let accountAssembly = providersOverride == nil - ? ProviderAccountAssembly.make(defaults: defaults, waitsForLoginShell: false) + ? ProviderAccountAssembly.make( + defaults: defaults, + accountsStore: accounts, + waitsForLoginShell: false + ) : ProviderAccountAssembly(identityKeysByCard: [:]) + let providers = providersOverride ?? ProviderCatalog.make( + defaults: defaults, + claude: accountAssembly.claudeRuntimePlan + ) + let registry = WidgetRegistry.from(providers) + let knownIDs = Set(registry.providers.map(\.id)) + let enablement = ProviderEnablementStore(defaults: defaults) // A requested id names cards by plain string matching — an exact card id, or a family id // naming all of that family's cards — mirroring the local HTTP API exactly (see // `LocalUsageAPI.State.matchingCardIDs`). Never resolved from runtime state: the same @@ -132,6 +141,7 @@ public struct UsageReader { limitDescriptors: registry.limitDescriptorsByProvider, errors: errors ) + .resolvingDisplayNames(accounts?.resolvedDisplayNamesByCardID ?? [:]) let path = requestedToken.map { "/v1/limits/\($0)" } ?? "/v1/limits" let response = LocalUsageAPI.respond(method: "GET", path: path, state: state) guard let data = response.body else { diff --git a/Sources/OpenUsage/Stores/DefaultLayout.swift b/Sources/OpenUsage/Stores/DefaultLayout.swift index fa13439cf..94d6c4526 100644 --- a/Sources/OpenUsage/Stores/DefaultLayout.swift +++ b/Sources/OpenUsage/Stores/DefaultLayout.swift @@ -69,6 +69,25 @@ enum DefaultLayout { "zai.session", "zai.weekly" ] + /// Account-card-aware default list: for every extra account card in the registry + /// (`claude@ab12cd34`), the family's entries are re-prefixed onto the card and appended, so a + /// newly discovered account seeds the same metric set (and caret split) as its family's default + /// card. Pins are deliberately NOT translated — an extra account never claims menu-bar space by + /// default. `migrationBaselineMetricIDs` is deliberately NOT translated either: account-card ids + /// must always read as never-offered so their defaults seed the first time the card appears. + static func translatedForAccountCards(_ ids: [String], providerIDs: [String]) -> [String] { + let accountCardIDs = providerIDs.filter(ProviderAccountID.isAccountCard) + guard !accountCardIDs.isEmpty else { return ids } + var result = ids + for cardID in accountCardIDs { + let prefix = ProviderAccountID.family(of: cardID) + "." + for id in ids where id.hasPrefix(prefix) { + result.append("\(cardID).\(id.dropFirst(prefix.count))") + } + } + return result + } + /// Metrics placed in the per-provider On Demand section on a fresh install. This is /// membership, not enablement: optional disabled rows like Sonnet or Cursor Requests/Credits are /// listed here so if the user enables them later they appear below the caret by default. diff --git a/Sources/OpenUsage/Stores/ICloudUsageSyncStore.swift b/Sources/OpenUsage/Stores/ICloudUsageSyncStore.swift index 6b39082a5..310cc0152 100644 --- a/Sources/OpenUsage/Stores/ICloudUsageSyncStore.swift +++ b/Sources/OpenUsage/Stores/ICloudUsageSyncStore.swift @@ -90,10 +90,12 @@ actor ICloudUsageHistoryFileStore: UsageHistoryFileStoring { } func write(_ document: UsageHistoryDocument) async throws { + try Task.checkCancellation() try document.validate() let directory = try historyDirectory(create: true) let url = directory.appendingPathComponent(document.deviceID).appendingPathExtension("json") let data = try encoder.encode(document) + try Task.checkCancellation() try coordinatedWrite(data, to: url) } @@ -135,11 +137,21 @@ actor ICloudUsageHistoryFileStore: UsageHistoryFileStoring { return try result?.get() ?? { throw CocoaError(.fileReadUnknown) }() } - private func coordinatedWrite(_ data: Data, to url: URL) throws { + func coordinatedWrite( + _ data: Data, + to url: URL, + beforeWriting: @Sendable () -> Void = {} + ) throws { var coordinationError: NSError? var operationError: Error? NSFileCoordinator().coordinate(writingItemAt: url, options: .forReplacing, error: &coordinationError) { coordinatedURL in - do { try data.write(to: coordinatedURL, options: .atomic) } + beforeWriting() + do { + // File coordination can block until after this account graph has been retired. + // Recheck inside its accessor so an older graph cannot overwrite its replacement. + try Task.checkCancellation() + try data.write(to: coordinatedURL, options: .atomic) + } catch { operationError = error } } if let coordinationError { throw coordinationError } @@ -152,6 +164,12 @@ actor ICloudUsageHistoryFileStore: UsageHistoryFileStoring { final class ICloudUsageSyncStore { private static let enabledKey = "openusage.icloudSync.enabled.v1" private static let deviceIDKey = "openusage.icloudSync.deviceID.v1" + private static var pendingDisableCleanups: [String: PendingDisableCleanup] = [:] + + private struct PendingDisableCleanup { + var id: UUID + var task: Task + } private let defaults: UserDefaults private let fileStore: any UsageHistoryFileStoring @@ -160,9 +178,11 @@ final class ICloudUsageSyncStore { private let writeDebounce: Duration private let observesMetadataChanges: Bool private var writeTask: Task? + private var activationTask: Task? private var metadataQuery: NSMetadataQuery? private var notificationTokens: [NSObjectProtocol] = [] private var syncActivityCount = 0 + private var isShutDownForAccountGraphReload = false let deviceID: String let deviceName: String @@ -170,7 +190,7 @@ final class ICloudUsageSyncStore { didSet { guard enabled != oldValue else { return } defaults.set(enabled, forKey: Self.enabledKey) - Task { await applyEnabledChange() } + activationTask = Task { [weak self] in await self?.applyEnabledChange() } } } private(set) var isSyncing = false @@ -199,7 +219,7 @@ final class ICloudUsageSyncStore { self.enabled = defaults.bool(forKey: Self.enabledKey) dataStore.onLocalHistoryChanged = { [weak self] in self?.scheduleWrite() } if enabled { - Task { await applyEnabledChange() } + activationTask = Task { [weak self] in await self?.applyEnabledChange() } } } @@ -212,20 +232,62 @@ final class ICloudUsageSyncStore { } func scheduleWrite() { - guard enabled else { return } + guard enabled, !isShutDownForAccountGraphReload else { return } writeTask?.cancel() writeTask = Task { [weak self] in guard let self else { return } try? await Task.sleep(for: writeDebounce) - guard !Task.isCancelled else { return } + guard !Task.isCancelled, !isShutDownForAccountGraphReload else { return } await writeNow() } } + /// Retire this graph's sync worker before its replacement starts writing the same device file. + /// Unlike turning sync off, a graph reload keeps both the user's preference and the existing + /// iCloud document intact; the replacement worker immediately rewrites that document itself. + func shutdownForAccountGraphReload() { + guard !isShutDownForAccountGraphReload else { return } + isShutDownForAccountGraphReload = true + writeTask?.cancel() + writeTask = nil + activationTask?.cancel() + activationTask = nil + dataStore.onLocalHistoryChanged = nil + stopObserving() + + guard !enabled else { return } + // Opt-out may have queued its deletion without getting a chance to run before the graph was + // retired. Finish independently of that worker, and serialize every later graph's activation + // behind this device's cleanup so a delayed delete cannot remove its replacement's new file. + let cleanupID = UUID() + let previousCleanup = Self.pendingDisableCleanups[deviceID]?.task + let cleanup = Task { @MainActor [defaults, fileStore, deviceID] in + defer { + if Self.pendingDisableCleanups[deviceID]?.id == cleanupID { + Self.pendingDisableCleanups.removeValue(forKey: deviceID) + } + } + await previousCleanup?.value + guard !defaults.bool(forKey: Self.enabledKey) else { return } + do { + try await fileStore.delete(deviceID: deviceID) + } catch { + AppLog.error(.config, "iCloud history disable cleanup failed after account reload: \(error.localizedDescription)") + } + } + Self.pendingDisableCleanups[deviceID] = PendingDisableCleanup(id: cleanupID, task: cleanup) + } + private func applyEnabledChange() async { + guard !isShutDownForAccountGraphReload else { return } if enabled { + if let cleanup = Self.pendingDisableCleanups[deviceID] { + await cleanup.task.value + } + guard enabled, !isShutDownForAccountGraphReload, !Task.isCancelled else { return } startObserving() await reload() + guard !isShutDownForAccountGraphReload, !Task.isCancelled else { return } await writeNow() } else { writeTask?.cancel() @@ -243,8 +305,9 @@ final class ICloudUsageSyncStore { } private func writeNow() async { - guard enabled else { return } + guard enabled, !isShutDownForAccountGraphReload, !Task.isCancelled else { return } await withSyncActivity { + guard enabled, !isShutDownForAccountGraphReload, !Task.isCancelled else { return } let document = dataStore.localHistoryDocument( deviceID: deviceID, deviceName: deviceName @@ -257,21 +320,23 @@ final class ICloudUsageSyncStore { try await fileStore.delete(deviceID: deviceID) return } + guard !isShutDownForAccountGraphReload, !Task.isCancelled else { return } operationError = nil await reload() } catch { + guard !isShutDownForAccountGraphReload, !Task.isCancelled else { return } report(error, context: "write") } } } private func reload() async { - guard enabled else { return } + guard enabled, !isShutDownForAccountGraphReload, !Task.isCancelled else { return } await withSyncActivity { do { let result = try await fileStore.loadDocuments() // A read that began while enabled must not restore peer state after sync was disabled. - guard enabled else { return } + guard enabled, !isShutDownForAccountGraphReload, !Task.isCancelled else { return } documents = UsageHistoryDocument.newestByDevice(result.documents) invalidFileMessages = result.invalidFileMessages dataStore.setPeerHistoryDocuments(result.documents, ownDeviceID: deviceID) @@ -279,6 +344,7 @@ final class ICloudUsageSyncStore { ? nil : "Some synced usage data couldn’t be read. Check the log for details." } catch { + guard !isShutDownForAccountGraphReload, !Task.isCancelled else { return } report(error, context: "read") } } diff --git a/Sources/OpenUsage/Stores/LayoutBootstrap.swift b/Sources/OpenUsage/Stores/LayoutBootstrap.swift index 88ae1da91..d279f72ae 100644 --- a/Sources/OpenUsage/Stores/LayoutBootstrap.swift +++ b/Sources/OpenUsage/Stores/LayoutBootstrap.swift @@ -36,7 +36,11 @@ enum LayoutBootstrap { defaults: LayoutDefaultSet ) -> LayoutInitialState { let hasStoredLayout = persistence.hasStoredLayout - let savedPlaced = persistence.loadPlaced()?.filter { registry.descriptor(id: $0.descriptorID) != nil } + // Keep widgets whose provider is absent from this launch's registry (an account card whose + // login wasn't found this launch). They remain invisible because rendering resolves through + // the live registry, but carrying the tombstones through unrelated layout writes lets the + // card recover its enabled state when its account returns. + let savedPlaced = persistence.loadPlaced() let startingPlaced = savedPlaced ?? defaults.metricIDs .filter { registry.descriptor(id: $0) != nil } .map { PlacedWidget(descriptorID: $0) } @@ -54,17 +58,20 @@ enum LayoutBootstrap { } ?? LayoutOrdering.defaultMetricOrder(registry: registry) // An existing value — including an empty array from a user who unpinned everything — wins. - let pinnedMetricIDs = Set( - (persistence.loadPins() ?? defaults.pinnedMetricIDs) - .filter { registry.descriptor(id: $0) != nil } - ) + // Unknown saved ids are retained as invisible tombstones for temporarily absent account cards. + let pinnedMetricIDs: Set + if let savedPins = persistence.loadPins() { + pinnedMetricIDs = Set(savedPins) + } else { + pinnedMetricIDs = Set(defaults.pinnedMetricIDs.filter { registry.descriptor(id: $0) != nil }) + } // Expanded membership is a fresh-install default only. Existing layouts that predate the feature // keep every familiar metric above the caret unless the user later moves one. var shouldPersistExpanded = false var expandedMetricIDs: Set if let savedExpanded = persistence.loadExpandedMetrics() { - expandedMetricIDs = Set(savedExpanded.filter { registry.descriptor(id: $0) != nil }) + expandedMetricIDs = Set(savedExpanded) } else if hasStoredLayout { expandedMetricIDs = [] } else { @@ -72,9 +79,7 @@ enum LayoutBootstrap { shouldPersistExpanded = true } - let expandedProviderIDs = Set( - (persistence.loadExpandedProviders() ?? []).filter { registry.provider(id: $0) != nil } - ) + let expandedProviderIDs = Set(persistence.loadExpandedProviders() ?? []) // A newly-shipped default metric is new to an existing user, so it may safely start below the // caret when that is its declared default. Metrics they already had are never silently hidden. @@ -103,9 +108,16 @@ enum LayoutBootstrap { registry.descriptor(id: id) != nil && !expandedNow.contains(id) && !placedIDs.contains(id) } let savedOnEnable = persistence.loadExpandOnEnable() - let defaultExpandedOnEnableIDs = Set( - (savedOnEnable ?? defaults.expandedMetricIDs).filter(isExpandOnEnableCandidate) - ) + let defaultExpandedOnEnableIDs: Set + if let savedOnEnable { + // Known metrics still have to be valid candidates, but an unknown id may belong to a + // temporarily absent account card and must survive until its descriptor returns. + defaultExpandedOnEnableIDs = Set(savedOnEnable.filter { id in + registry.descriptor(id: id) == nil || isExpandOnEnableCandidate(id) + }) + } else { + defaultExpandedOnEnableIDs = Set(defaults.expandedMetricIDs.filter(isExpandOnEnableCandidate)) + } let promotedQueuedIDs = Set(savedOnEnable ?? []).intersection(newlyAlwaysShown) return LayoutInitialState( @@ -148,8 +160,12 @@ enum LayoutBootstrap { let seededDefaults: Set var shouldPersistSeededDefaults = false if let saved = persistence.loadSeededDefaults() { - seededDefaults = Set(LayoutOrdering.knownMetricIDs(saved, registry: registry)) - shouldPersistSeededDefaults = seededDefaults != Set(saved) + // Keep markers for metrics whose provider is absent from this launch's registry (an + // account card whose login wasn't found). Pruning them would make a default metric the + // user disabled look newly introduced when the card returns, so startup would turn it + // back on. Permanently removed metric ids are harmless tombstones and can stay here. + seededDefaults = Set(saved) + shouldPersistSeededDefaults = seededDefaults.count != saved.count } else if hasStoredLayout { seededDefaults = Set(LayoutOrdering.knownMetricIDs(defaults.migrationBaselineMetricIDs, registry: registry)) shouldPersistSeededDefaults = true @@ -198,11 +214,20 @@ enum LayoutOrdering { _ saved: [String: [String]], registry: WidgetRegistry ) -> [String: [String]] { - var fallback = defaultMetricOrder(registry: registry) + // Start with every saved provider so a temporarily absent account card keeps its ordering + // entry. For providers present now, deduplicate the saved sequence (including unknown metric + // tombstones) and append newly introduced live metrics; `LayoutStore` filters this persisted + // superset through the live registry before rendering. + var fallback = saved for provider in registry.providers { let valid = registry.descriptors(for: provider.id).map(\.id) if let savedIDs = saved[provider.id] { - fallback[provider.id] = normalizedMetricIDs(savedIDs, validIDs: valid) + var seen = Set() + var retained = savedIDs.filter { seen.insert($0).inserted } + retained.append(contentsOf: valid.filter { seen.insert($0).inserted }) + fallback[provider.id] = retained + } else { + fallback[provider.id] = valid } } return fallback diff --git a/Sources/OpenUsage/Stores/LayoutStore+Customization.swift b/Sources/OpenUsage/Stores/LayoutStore+Customization.swift index c3574cb28..5c378c233 100644 --- a/Sources/OpenUsage/Stores/LayoutStore+Customization.swift +++ b/Sources/OpenUsage/Stores/LayoutStore+Customization.swift @@ -17,7 +17,8 @@ extension LayoutStore { } func isMetricEnabled(_ descriptorID: String) -> Bool { - placed.contains { $0.descriptorID == descriptorID } + registry.descriptor(id: descriptorID) != nil + && placed.contains { $0.descriptorID == descriptorID } } /// Whether any enabled provider ships the local spend tiles — the capability gate for the @@ -159,8 +160,25 @@ extension LayoutStore { recordingUndoStep { let shown = customizeGroups.map(\.provider.id) guard let next = Self.reordered(shown, dragged: dragged, target: target) else { return false } - let rest = orderedProviderIDs().filter { !next.contains($0) } - providerOrder = next + rest + // Reorder only the visible slots in the raw persisted sequence. Unknown ids may be + // account cards absent from this launch's registry, and disabled providers are hidden + // from `customizeGroups`; both keep their exact positions while the visible ids move + // around them. + let shownSet = Set(shown) + var replacements = next.makeIterator() + var rebuilt: [String] = [] + for providerID in providerOrder { + if shownSet.contains(providerID) { + if let replacement = replacements.next() { rebuilt.append(replacement) } + } else { + rebuilt.append(providerID) + } + } + while let replacement = replacements.next() { rebuilt.append(replacement) } + for providerID in orderedProviderIDs() where !rebuilt.contains(providerID) { + rebuilt.append(providerID) + } + providerOrder = rebuilt persistProviderOrder() syncPlacedOrder() return true diff --git a/Sources/OpenUsage/Stores/LayoutStore.swift b/Sources/OpenUsage/Stores/LayoutStore.swift index fb02999c9..92cf5b6aa 100644 --- a/Sources/OpenUsage/Stores/LayoutStore.swift +++ b/Sources/OpenUsage/Stores/LayoutStore.swift @@ -123,19 +123,24 @@ final class LayoutStore { self.registry = registry let persistence = LayoutPersistence(defaults: defaults, storageKey: storageKey) self.persistence = persistence - self.defaultMetricIDs = defaultMetricIDs + // Extra account cards seed their family's default metric set (and caret split); pins and the + // migration baseline are deliberately never translated (see `translatedForAccountCards`). + let registryProviderIDs = registry.providers.map(\.id) + let translatedMetricIDs = DefaultLayout.translatedForAccountCards(defaultMetricIDs, providerIDs: registryProviderIDs) + let translatedExpandedIDs = DefaultLayout.translatedForAccountCards(defaultExpandedMetricIDs, providerIDs: registryProviderIDs) + self.defaultMetricIDs = translatedMetricIDs self.defaultPinnedMetricIDs = defaultPinnedMetricIDs - self.defaultExpandedMetricIDs = defaultExpandedMetricIDs + self.defaultExpandedMetricIDs = translatedExpandedIDs self.isProviderEnabled = isProviderEnabled let initial = LayoutBootstrap.load( registry: registry, persistence: persistence, defaults: LayoutDefaultSet( - metricIDs: defaultMetricIDs, + metricIDs: translatedMetricIDs, migrationBaselineMetricIDs: migrationBaselineMetricIDs, pinnedMetricIDs: defaultPinnedMetricIDs, - expandedMetricIDs: defaultExpandedMetricIDs + expandedMetricIDs: translatedExpandedIDs ) ) placed = initial.placed @@ -154,7 +159,7 @@ final class LayoutStore { } func isProviderExpanded(_ providerID: String) -> Bool { - expandedProviderIDs.contains(providerID) + registry.provider(id: providerID) != nil && expandedProviderIDs.contains(providerID) } @discardableResult @@ -263,7 +268,9 @@ final class LayoutStore { /// column, so a third would not fit the menu bar height. static let maxPinsPerProvider = 2 - func isPinned(_ descriptorID: String) -> Bool { pinnedMetricIDs.contains(descriptorID) } + func isPinned(_ descriptorID: String) -> Bool { + registry.descriptor(id: descriptorID) != nil && pinnedMetricIDs.contains(descriptorID) + } func pinnedCount(forProvider providerID: String) -> Int { pinnedMetricIDs.count { registry.descriptor(id: $0)?.providerID == providerID } diff --git a/Sources/OpenUsage/Stores/ProviderAccountsStore.swift b/Sources/OpenUsage/Stores/ProviderAccountsStore.swift index 79b44068a..56e0ae450 100644 --- a/Sources/OpenUsage/Stores/ProviderAccountsStore.swift +++ b/Sources/OpenUsage/Stores/ProviderAccountsStore.swift @@ -1,25 +1,49 @@ import CryptoKit import Foundation +import Observation /// Card-id helpers for the account-first model. The account occupying a family's default home when /// first observed keeps the bare family id (`claude`, `codex`) as its permanent record id — that is /// what makes existing installs migrate by doing nothing. Any later account of the same family mints /// `family@` from its identity key. enum ProviderAccountID { + enum BareRuntimeResolution: Sendable { + case permanentRecord + case currentDefaultSource + } + + struct FamilyMetadata: Sendable { + var bareRuntimeResolution: BareRuntimeResolution + } + + /// Runtime aliases belong to family metadata: multi-card families keep their permanent bare + /// record, while a single-runtime family follows whichever account currently owns its source. + static let metadataByFamily: [String: FamilyMetadata] = [ + "claude": FamilyMetadata(bareRuntimeResolution: .permanentRecord), + "codex": FamilyMetadata(bareRuntimeResolution: .currentDefaultSource), + ] /// The family ids that participate in the account-first model. - static let families: Set = ["claude", "codex"] + static let families = Set(metadataByFamily.keys) /// `claude@ab12cd34` — a stable, non-reversible id derived from the account's identity key. static func make(family: String, identityKey: String) -> String { + "\(family)@\(hash8(identityKey))" + } + + /// The digest also identifies remote-only account histories without exposing account details. + static func hash8(_ identityKey: String) -> String { let digest = SHA256.hash(data: Data(identityKey.lowercased().utf8)) - let hash8 = digest.prefix(4).map { String(format: "%02x", $0) }.joined() - return "\(family)@\(hash8)" + return digest.prefix(4).map { String(format: "%02x", $0) }.joined() } /// The family a card id belongs to: `claude@ab12cd34` → `claude`, bare ids map to themselves. static func family(of cardID: String) -> String { cardID.firstIndex(of: "@").map { String(cardID[..<$0]) } ?? cardID } + + static func isAccountCard(_ cardID: String) -> Bool { + cardID.contains("@") + } } /// One place an account is signed in. "Default" is a badge on a source (`holdsDefaultSource`), never @@ -27,15 +51,47 @@ enum ProviderAccountID { /// a swap re-points source edges, cards don't move. Phase 1 only observes the default home; later /// phases add config dirs, cswap vault slots, Codex homes, and Desktop logins as more kinds. struct ProviderAccountSource: Codable, Equatable, Sendable { - enum Kind: String, Codable, Sendable { - /// The provider's standard home for this machine (`~/.claude`, `~/.codex`, env override). - case defaultHome + /// A string-backed value, rather than an exhaustive enum, keeps account records readable when + /// a newer development build introduces another source kind. Unknown sources stay persisted; + /// this build simply cannot bind a runtime to one until it learns how to verify that source. + struct Kind: RawRepresentable, Codable, Equatable, Hashable, Sendable { + static let defaultHome = Self(rawValue: "defaultHome") + static let configDir = Self(rawValue: "configDir") + static let desktop = Self(rawValue: "desktop") + + let rawValue: String + + init(rawValue: String) { + self.rawValue = rawValue + } + + init(from decoder: Decoder) throws { + rawValue = try decoder.singleValueContainer().decode(String.self) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + var isKnown: Bool { + self == .defaultHome || self == .configDir || self == .desktop + } } var kind: Kind /// Canonical home path the source was observed at. var anchor: String? var holdsDefaultSource: Bool + /// Claude Code hashes the literal config-dir spelling to derive its keychain service. + var keychainLiteral: String? + + init(kind: Kind, anchor: String?, holdsDefaultSource: Bool, keychainLiteral: String? = nil) { + self.kind = kind + self.anchor = anchor + self.holdsDefaultSource = holdsDefaultSource + self.keychainLiteral = keychainLiteral + } } /// An account as the account-first model sees it: opaque identity key, stable record id minted at @@ -46,26 +102,65 @@ struct ProviderAccountRecord: Codable, Equatable, Sendable { var id: String var family: String var identityKey: String + /// Older Claude state sometimes omits the organization. Keep its previous spelling when that + /// changes so a later second organization cannot silently inherit the same account card. + var identityAliases: [String]? = nil var label: String? + /// User-entered names belong to the account and survive source changes and rescans. + var customLabel: String? var sources: [ProviderAccountSource] - /// Set by a future "Remove Account…". A tombstoned account is never resurrected by rescans. + /// Historical tombstones remain honored, but unavailable accounts are hidden instead of removed. var removedTombstone: Bool = false + + var derivedDisplayName: String { + derivedDisplayName(identifyingBareAccount: false) + } + + /// The bare card keeps its original title while it is alone, but gains the same organization + /// label as its siblings when multiple accounts need to be distinguished. + func derivedDisplayName(identifyingBareAccount: Bool) -> String { + guard ProviderAccountID.isAccountCard(id) || identifyingBareAccount else { + return family.capitalized + } + guard let label = label?.nilIfEmpty else { + if ProviderAccountID.isAccountCard(id) { return id } + return "\(family.capitalized) — \(ProviderAccountID.hash8(identityKey).prefix(4))" + } + if label.hasSuffix(")"), let openingParenthesis = label.lastIndex(of: "(") { + let organization = label[ + label.index(after: openingParenthesis).. [ProviderAccountRecord] { + func reconcile(with observations: [AccountObservation]) -> [ProviderAccountRecord] { var updated = records var changed = false for observation in observations { - let index = updated.firstIndex { - $0.family == observation.family && $0.identityKey == observation.identityKey + let match = Self.matchingRecord( + for: observation, + in: updated, + observations: observations + ) + if case .ambiguous = match { + AppLog.warn(.config, "accounts: claude identity omitted its organization while multiple organizations share the login; observation quarantined") + continue } - if let index { + if case .existing(let index) = match { guard !updated[index].removedTombstone else { continue } var record = updated[index] + if record.identityKey != observation.identityKey { + var aliases = record.identityAliases ?? [] + if !aliases.contains(record.identityKey) { + aliases.append(record.identityKey) + } + aliases.removeAll { $0 == observation.identityKey } + record.identityAliases = aliases.isEmpty ? nil : aliases + record.identityKey = observation.identityKey + } record.label = observation.label ?? record.label - record.sources = observation.sources + // A source added by a newer app must survive this build's narrower discovery pass. + // Known sources, by contrast, are authoritative for the current launch: keeping a + // stale default-home edge would let a moved account borrow another login. + let forwardCompatibleSources = record.sources.filter { !$0.kind.isKnown } + record.sources = observation.sources + forwardCompatibleSources if record != updated[index] { updated[index] = record changed = true @@ -144,6 +266,121 @@ final class ProviderAccountsStore { return records } + private enum RecordMatch { + case existing(Int) + case newRecord + case ambiguous + } + + /// Claude's account UUID can appear either alone or with its organization UUID. Treat those as + /// one account only when the user's complete persisted and incoming history proves exactly one + /// possible organization; two different explicit organizations are always separate accounts. + private static func matchingRecord( + for observation: AccountObservation, + in records: [ProviderAccountRecord], + observations: [AccountObservation] + ) -> RecordMatch { + guard observation.family == "claude" else { + if let index = records.firstIndex(where: { + $0.family == observation.family && $0.identityKey == observation.identityKey + }) { + return .existing(index) + } + return .newRecord + } + + guard let observed = ClaudeIdentity(observation.identityKey) else { return .ambiguous } + let familyRecords = records.enumerated().filter { _, record in + record.family == observation.family + && ClaudeIdentity(record.identityKey)?.user == observed.user + } + let known = familyRecords.flatMap { _, record in + ([record.identityKey] + (record.identityAliases ?? [])).compactMap(ClaudeIdentity.init) + } + observations.compactMap { candidate -> ClaudeIdentity? in + guard candidate.family == observation.family, + let identity = ClaudeIdentity(candidate.identityKey), + identity.user == observed.user + else { return nil } + return identity + } + guard let resolved = ClaudeIdentity.canonical(observed, among: known) else { return .ambiguous } + if let exact = familyRecords.first(where: { _, record in + ClaudeIdentity(record.identityKey)?.matchesExactly(observed) == true + }) { + return .existing(exact.offset) + } + + let compatible = familyRecords.filter { _, record in + guard let existing = ClaudeIdentity(record.identityKey), + let canonical = ClaudeIdentity.canonical(existing, among: known) + else { return false } + return canonical == resolved + } + guard compatible.count == 1 else { + return compatible.isEmpty ? .newRecord : .ambiguous + } + return .existing(compatible[0].offset) + } + + func record(for cardID: String) -> ProviderAccountRecord? { + records.first { $0.id == cardID } + } + + /// Claude runtimes follow their permanent account-card ids, while the single Codex runtime + /// still keeps its historical bare id when another recorded account takes the default home. + /// Resolve that one presentation alias to its current verified owner, never to the old record. + func runtimeRecord(for cardID: String) -> ProviderAccountRecord? { + let family = ProviderAccountID.family(of: cardID) + if !ProviderAccountID.isAccountCard(cardID), + ProviderAccountID.metadataByFamily[family]?.bareRuntimeResolution == .currentDefaultSource + { + return defaultBadgeHolder(family: family) + } + return record(for: cardID) + } + + /// The contextual default title, shared by baked providers, visible cards, API output, and the + /// Customize name placeholder. Stable identity suffixes distinguish duplicate organization names. + func derivedDisplayName(cardID: String) -> String? { + guard let record = runtimeRecord(for: cardID) else { return nil } + let familyRecords = records.filter { + $0.family == record.family && !$0.removedTombstone + } + let identifiesBareAccount = familyRecords.count > 1 + let proposed = record.derivedDisplayName( + identifyingBareAccount: identifiesBareAccount + ) + let collides = familyRecords.contains { sibling in + guard sibling.id != record.id else { return false } + let siblingName = sibling.customLabel?.nilIfEmpty + ?? sibling.derivedDisplayName(identifyingBareAccount: identifiesBareAccount) + return siblingName == proposed + } + guard collides else { return proposed } + return "\(proposed) · \(ProviderAccountID.hash8(record.identityKey).prefix(4))" + } + + func resolvedDisplayName(cardID: String) -> String? { + guard let record = runtimeRecord(for: cardID) else { return nil } + return record.customLabel?.nilIfEmpty ?? derivedDisplayName(cardID: cardID) + } + + var resolvedDisplayNamesByCardID: [String: String] { + Dictionary(uniqueKeysWithValues: records.compactMap { record in + resolvedDisplayName(cardID: record.id).map { (record.id, $0) } + }) + } + + func rename(cardID: String, to name: String?) { + guard let record = runtimeRecord(for: cardID), + let index = records.firstIndex(where: { $0.id == record.id }) + else { return } + let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + guard records[index].customLabel != trimmed else { return } + records[index].customLabel = trimmed + persist() + } + /// The record currently holding a family's default badge, if any. func defaultBadgeHolder(family: String) -> ProviderAccountRecord? { records.first { record in @@ -153,10 +390,34 @@ final class ProviderAccountsStore { } } + /// A logged-out or unreadable default home no longer belongs to its previous account. Retain + /// the account and every other source, but remove its stale home edge and default badge so a + /// future source cannot inherit ownership from an old observation. + func clearDefaultSource(family: String) { + var changed = false + for index in records.indices where records[index].family == family { + let existing = records[index].sources + let remaining = existing + .filter { $0.kind != .defaultHome } + .map { source in + var source = source + source.holdsDefaultSource = false + return source + } + guard remaining != existing else { continue } + records[index].sources = remaining + changed = true + } + if changed { persist() } + } + /// The bare family id when free (the migration-killing rule: the first account observed at the /// default home IS the existing card), else an identity-derived `family@` id. - private static func availableID(for observation: Observation, in records: [ProviderAccountRecord]) -> String { - if !records.contains(where: { $0.id == observation.family }) { return observation.family } + private static func availableID(for observation: AccountObservation, in records: [ProviderAccountRecord]) -> String { + let observedAtDefaultHome = observation.sources.contains { $0.kind == .defaultHome } + if observedAtDefaultHome, !records.contains(where: { $0.id == observation.family }) { + return observation.family + } let derived = ProviderAccountID.make(family: observation.family, identityKey: observation.identityKey) guard records.contains(where: { $0.id == derived }) else { return derived } // A hash-prefix collision between two distinct identities of one family; salt until free. @@ -178,4 +439,54 @@ final class ProviderAccountsStore { } defaults.set(data, forKey: Self.storageKey) } + + /// Development builds before the registry split may already have written Desktop/config-dir + /// sources into v1, which older releases decode as an exhaustive default-home-only enum. Keep + /// v2 authoritative, but repair that legacy mirror once so downgrading cannot wipe its records. + private func repairLegacyMirrorIfNeeded(_ data: Data) { + if (try? JSONDecoder().decode([LegacyAccountRecord].self, from: data)) != nil { + return + } + guard !records.isEmpty else { return } + let projected = records.map { record in + LegacyAccountRecord( + id: record.id, + family: record.family, + identityKey: record.identityKey, + label: record.label, + sources: record.sources.compactMap { source in + guard source.kind == .defaultHome else { return nil } + return LegacyAccountSource( + kind: .defaultHome, + anchor: source.anchor, + holdsDefaultSource: source.holdsDefaultSource + ) + }, + removedTombstone: record.removedTombstone + ) + } + do { + defaults.set(try JSONEncoder().encode(projected), forKey: Self.legacyStorageKey) + AppLog.info(.config, "repaired the legacy provider-account mirror for downgrade compatibility") + } catch { + AppLog.error(.config, "failed to repair the legacy provider-account mirror: \(error.localizedDescription)") + } + } + + private struct LegacyAccountRecord: Codable { + var id: String + var family: String + var identityKey: String + var label: String? + var sources: [LegacyAccountSource] + var removedTombstone: Bool + } + + private struct LegacyAccountSource: Codable { + enum Kind: String, Codable { case defaultHome } + + var kind: Kind + var anchor: String? + var holdsDefaultSource: Bool + } } diff --git a/Sources/OpenUsage/Stores/ProviderEnablementStore.swift b/Sources/OpenUsage/Stores/ProviderEnablementStore.swift index 9b81a1789..f079afc31 100644 --- a/Sources/OpenUsage/Stores/ProviderEnablementStore.swift +++ b/Sources/OpenUsage/Stores/ProviderEnablementStore.swift @@ -25,6 +25,7 @@ final class ProviderEnablementStore { private static let disabledStorageKey = "openusage.disabledProviders.v1" private static let enabledStorageKey = "openusage.enabledProviders.v1" private static let knownStorageKey = "openusage.knownProviders.v1" + private static let pendingDetectionStorageKey = "openusage.pendingProviderDetection.v1" /// Posted when the enabled-provider set actually changes. The refresh loop listens for this to wake /// early and fetch a newly-enabled provider promptly, instead of waiting out the full interval — @@ -53,6 +54,9 @@ final class ProviderEnablementStore { /// Every provider ID this install has ever seen (see the type comment). Seeded by the v2 settings /// migration or `FirstRunSeeder`, then grown by `registerKnownProviders`. private(set) var knownIDs: Set + /// Credential checks that were registered but have not finished. Account graph replacement can + /// cancel their owning task, so the next graph resumes them without mistaking "known" for done. + private(set) var pendingDetectionIDs: Set private let defaults: UserDefaults init(defaults: UserDefaults = .standard) { @@ -65,6 +69,7 @@ final class ProviderEnablementStore { self.disabledIDs = Set(defaults.stringArray(forKey: Self.disabledStorageKey) ?? []) } self.knownIDs = Set(defaults.stringArray(forKey: Self.knownStorageKey) ?? []) + self.pendingDetectionIDs = Set(defaults.stringArray(forKey: Self.pendingDetectionStorageKey) ?? []) } func isEnabled(_ id: String) -> Bool { @@ -89,6 +94,9 @@ final class ProviderEnablementStore { guard disabledIDs != before else { return } defaults.set(Array(disabledIDs), forKey: Self.disabledStorageKey) } + // A real user choice owns this provider from now on, even if a credential check started + // before the toggle and reports a login after an account graph replacement. + finishProviderDetection([id]) // Clear the backoff BEFORE the wake notification, so the refresh it triggers actually probes the // just-enabled provider instead of skipping it as recently-failed. if enabled { onProviderEnabled?(id) } @@ -111,6 +119,24 @@ final class ProviderEnablementStore { return new } + func markProviderDetectionPending(_ ids: Set) { + updatePendingDetection(pendingDetectionIDs.union(ids)) + } + + func finishProviderDetection(_ ids: Set) { + updatePendingDetection(pendingDetectionIDs.subtracting(ids)) + } + + private func updatePendingDetection(_ pending: Set) { + guard pending != pendingDetectionIDs else { return } + pendingDetectionIDs = pending + if pending.isEmpty { + defaults.removeObject(forKey: Self.pendingDetectionStorageKey) + } else { + defaults.set(Array(pending), forKey: Self.pendingDetectionStorageKey) + } + } + func seedEnabledProviders(_ ids: Set) { let newlyEnabled = ids.filter { !isEnabled($0) } let changed = enabledIDs != ids diff --git a/Sources/OpenUsage/Stores/TelemetryRecorder.swift b/Sources/OpenUsage/Stores/TelemetryRecorder.swift index 5a8d21ae4..79bd34fc6 100644 --- a/Sources/OpenUsage/Stores/TelemetryRecorder.swift +++ b/Sources/OpenUsage/Stores/TelemetryRecorder.swift @@ -69,6 +69,9 @@ final class TelemetryRecorder { guard store.enabled else { return } guard outcome == .refreshed || outcome == .failed else { return } + // Account-card identifiers contain a stable account-derived hash. Analytics only describe + // provider families, so normalize before either persistence or transmission. + let providerID = ProviderAccountID.family(of: providerID) let today = Self.dayString(now()) var counters = store.providerCounters() // Roll a stale prior-day counter over to its own event before accumulating today's. @@ -115,10 +118,10 @@ final class TelemetryRecorder { "install_id": store.installID, "app_version": AppInfo.version, "os_version": ProcessInfo.processInfo.operatingSystemVersionString, - "enabled_providers": config.enabledProviders, - "enabled_metric_ids": config.enabledMetricIDs, - "pinned_metric_ids": config.pinnedMetricIDs, - "expanded_metric_ids": config.expandedMetricIDs, + "enabled_providers": Self.unique(config.enabledProviders.map(ProviderAccountID.family(of:))), + "enabled_metric_ids": Self.familyMetricIDs(config.enabledMetricIDs), + "pinned_metric_ids": Self.familyMetricIDs(config.pinnedMetricIDs), + "expanded_metric_ids": Self.familyMetricIDs(config.expandedMetricIDs), "menu_bar_style": config.menuBarStyle ]) sink.flush() @@ -131,7 +134,7 @@ final class TelemetryRecorder { private static func providerRollupProperties(providerID: String, counter: ProviderDailyCounter) -> [String: Any] { var properties: [String: Any] = [ - "provider_id": providerID, + "provider_id": ProviderAccountID.family(of: providerID), "success_count": counter.success, "failure_count": counter.failure, "error_categories": counter.errors, @@ -150,6 +153,21 @@ final class TelemetryRecorder { return properties } + private static func familyMetricIDs(_ metricIDs: [String]) -> [String] { + unique(metricIDs.map { metricID in + guard let separator = metricID.firstIndex(of: ".") else { + return ProviderAccountID.family(of: metricID) + } + let providerID = String(metricID[.. [String] { + var seen: Set = [] + return values.filter { seen.insert($0).inserted } + } + /// Local-calendar `yyyy-MM-dd`. Local (not UTC) so "every day" matches the user's perception; the /// calendar is injectable for tests. static func dayString(_ date: Date, calendar: Calendar = .current) -> String { diff --git a/Sources/OpenUsage/Stores/WidgetDataStore.swift b/Sources/OpenUsage/Stores/WidgetDataStore.swift index 69d335b4e..d7a08b60c 100644 --- a/Sources/OpenUsage/Stores/WidgetDataStore.swift +++ b/Sources/OpenUsage/Stores/WidgetDataStore.swift @@ -33,11 +33,13 @@ final class WidgetDataStore { /// Quota-notification preferences (three independent triggers). Injected; `nil` disables /// notifications entirely (tests and previews that don't wire it). private let notificationSettings: (@MainActor () -> NotificationSettingsStore)? - /// Card id → the account identity currently signed in there, resolved once at launch by - /// `ProviderAccountAssembly`. Drives the snapshot cache's account stamp: writes record the - /// producer, and launch loads only paint an entry whose stamp matches. A card absent here has an - /// unresolved identity this launch (or isn't account-aware) — its cache behaves as it always did. - private let providerIdentityKeys: [String: String] + /// Card id → the account identity currently signed in there. Launch discovery seeds verified + /// identities; a successful Codex refresh can later prove a keychain-backed account without + /// adding a launch-time keychain read. Cache stamps and cloud routing use the current value. + @ObservationIgnored private var providerIdentityKeys: [String: String] + /// Resolves account-card names from the live account registry, so a rename reaches notifications + /// without being copied into cached snapshots or immutable provider values. + private let resolveDisplayName: (@MainActor (String) -> String?)? /// Where a fired milestone is delivered: `(idPrefix, title, subtitle, body) -> Bool`. The Bool is /// whether it was actually delivered (authorized + scheduled); on false the caller leaves the /// milestone un-marked so it retries next pass. Injected so tests can record posts without a live @@ -101,6 +103,9 @@ final class WidgetDataStore { /// Wired by `ICloudUsageSyncStore`; debounced there so a concurrent provider batch produces one file. @ObservationIgnored var onLocalHistoryChanged: (@MainActor () -> Void)? @ObservationIgnored private var peerHistoryDocuments: [UsageHistoryDocument] = [] + /// Verified accounts found only on another Mac. They contribute to Total Spend but never create + /// local provider cards or borrow a local account's identity. + private(set) var remoteOnlySpend: [(provider: Provider, snapshot: ProviderSnapshot)] = [] /// Global meter style: whether every bounded tile (and the menu-bar value) renders as "used" or /// "left/remaining". Persisted so the choice survives relaunch; defaults to `.remaining`. @@ -143,7 +148,8 @@ final class WidgetDataStore { providerRefreshTimeout: TimeInterval = WidgetDataStore.defaultProviderRefreshTimeout, notificationSettings: (@MainActor () -> NotificationSettingsStore)? = nil, postNotification: (@MainActor (String, String, String, String) async -> Bool)? = nil, - providerIdentityKeys: [String: String] = [:] + providerIdentityKeys: [String: String] = [:], + resolveDisplayName: (@MainActor (String) -> String?)? = nil ) { precondition(slowProviderRefreshThreshold >= 0) precondition(providerRefreshTimeout > 0) @@ -163,6 +169,7 @@ final class WidgetDataStore { await AppNotifications.shared.post(idPrefix: idPrefix, title: title, subtitle: subtitle, body: body) } self.providerIdentityKeys = providerIdentityKeys + self.resolveDisplayName = resolveDisplayName self.meterStyle = defaults.enumValue(forKey: Self.meterStyleKey, default: .remaining) self.resetDisplayMode = defaults.enumValue(forKey: Self.resetDisplayModeKey, default: .relative) self.alwaysShowPacing = defaults.bool(forKey: Self.alwaysShowPacingKey) @@ -256,7 +263,9 @@ final class WidgetDataStore { metrics: metrics, toggles: toggles, now: now, - providerName: { [providersByID] id in providersByID[id]?.provider.displayName ?? id }, + providerName: { [providersByID, resolveDisplayName] id in + resolveDisplayName?(id) ?? providersByID[id]?.provider.displayName ?? id + }, post: postNotification ) } @@ -310,6 +319,8 @@ final class WidgetDataStore { refreshingProviderIDs.insert(providerID) defer { refreshingProviderIDs.remove(providerID) } let start = monotonicNow() + let accountIdentityReporter = provider as? any AccountIdentityReporting + let previousAccountOwnershipToken = accountIdentityReporter?.accountOwnershipContinuityToken // A provider that never returns would otherwise hold the in-flight entry — and the spinner — // forever. Past the deadline, stop waiting and treat it as any other failed refresh. guard var snapshot = await ProviderRefreshDeadline.snapshot( @@ -351,6 +362,27 @@ final class WidgetDataStore { } // Recovered: drop any backoff so the provider resumes the normal cadence immediately. failureRetryAfter[providerID] = nil + + if let accountIdentityReporter { + if let verifiedIdentity = accountIdentityReporter.verifiedAccountIdentityKey { + if providerIdentityKeys[providerID] != verifiedIdentity { + // A snapshot loaded while this identity was unknown, or produced by a different + // account, must never supply carry-forward history to the newly verified account. + localSnapshots.removeValue(forKey: providerID) + providerIdentityKeys[providerID] = verifiedIdentity + AppLog.info(.config, "accounts: \(providerID) identity updated from its successful credential") + } + } else if let previousOwnershipToken = previousAccountOwnershipToken, + previousOwnershipToken != accountIdentityReporter.accountOwnershipContinuityToken + { + // Missing account metadata is harmless while the same credential still succeeds, + // but a different identityless credential may belong to another account entirely. + localSnapshots.removeValue(forKey: providerID) + providerIdentityKeys.removeValue(forKey: providerID) + AppLog.warn(.config, "accounts: \(providerID) credential changed without a verified identity; history quarantined") + } + } + // A provider can refresh its live limits successfully while its optional local log/CSV scan // produces no result. Keep only the last-good normalized history in that case; the new plan, // limits, warnings, and timestamp still win. A non-nil empty history remains authoritative and @@ -414,9 +446,18 @@ final class WidgetDataStore { func localHistoryDocument(deviceID: String, deviceName: String, updatedAt: Date = Date()) -> UsageHistoryDocument { var providers: [String: ProviderUsageHistory] = [:] + var identities: [String: String] = [:] for (providerID, descriptor) in registry.historyDescriptorsByProvider where descriptor.scope == .machineLocal && isProviderEnabled(providerID) { if let history = localSnapshots[providerID]?.usageHistory { + let family = ProviderAccountID.family(of: providerID) + if ProviderAccountID.families.contains(family) { + guard let identity = providerIdentityKeys[providerID] else { + AppLog.warn(.config, "sync: omitting unresolved account history for \(providerID)") + continue + } + identities[providerID] = identity + } providers[providerID] = history } } @@ -424,13 +465,15 @@ final class WidgetDataStore { deviceID: deviceID, deviceName: deviceName, updatedAt: updatedAt, - providers: providers + providers: providers, + identities: identities.isEmpty ? nil : identities ) } private func rebuildRenderedSnapshots() { guard !peerHistoryDocuments.isEmpty else { snapshots = localSnapshots + remoteOnlySpend = [] return } let renderDate = now() @@ -439,12 +482,26 @@ final class WidgetDataStore { ) { result, entry in if isProviderEnabled(entry.key) { result[entry.key] = entry.value } } + let remapped = PeerHistoryRemapper.remap( + documents: peerHistoryDocuments, + localIdentityByCardID: providerIdentityKeys, + localAccountCardIDs: Set(registry.providers.map(\.id)) + ) + if !remapped.quarantined.isEmpty { + AppLog.warn(.config, "sync: quarantined \(remapped.quarantined.count) peer account histories with unresolved or ambiguous ownership") + } let merged = UsageHistoryAggregator.merged( localSnapshots: localSnapshots, - peerDocuments: peerHistoryDocuments, + peerHistories: remapped.histories, descriptors: enabledDescriptors, now: renderDate ) + remoteOnlySpend = Self.renderRemoteOnlySpend( + remapped.remoteOnly, + registry: registry, + isProviderEnabled: isProviderEnabled, + now: renderDate + ) var rendered = localSnapshots for (providerID, history) in merged { guard let descriptor = registry.historyDescriptorsByProvider[providerID], @@ -466,6 +523,45 @@ final class WidgetDataStore { snapshots = rendered } + private static func renderRemoteOnlySpend( + _ remoteOnly: [PeerHistoryRemapper.RemoteOnlyHistory], + registry: WidgetRegistry, + isProviderEnabled: @MainActor (String) -> Bool, + now: Date + ) -> [(provider: Provider, snapshot: ProviderSnapshot)] { + remoteOnly.compactMap { entry in + guard let familyProvider = registry.providers.first(where: { provider in + ProviderAccountID.family(of: provider.id) == entry.family + && isProviderEnabled(provider.id) + && registry.historyDescriptorsByProvider[provider.id]?.scope == .machineLocal + }), + let descriptor = registry.historyDescriptorsByProvider[familyProvider.id] + else { return nil } + + let history = UsageHistoryAggregator.mergeHistories(entry.histories, now: now) + guard !history.series.daily.isEmpty else { return nil } + + let provider = Provider( + id: "\(entry.family)@peer-\(ProviderAccountID.hash8(entry.identityKey))", + displayName: entry.cardID, + icon: familyProvider.icon + ) + let empty = ProviderSnapshot( + providerID: provider.id, + displayName: provider.displayName, + lines: [], + refreshedAt: now + ) + let snapshot = UsageHistorySnapshotRenderer.render( + local: empty, + history: history, + descriptor: descriptor, + now: now + ) + return (provider, snapshot) + } + } + /// The provider's latest refresh error, or `nil` when its last refresh succeeded. func errorMessage(for providerID: String) -> String? { providerErrors[providerID] diff --git a/Sources/OpenUsage/Stores/WidgetRegistry.swift b/Sources/OpenUsage/Stores/WidgetRegistry.swift index b0cfceb89..2cc340810 100644 --- a/Sources/OpenUsage/Stores/WidgetRegistry.swift +++ b/Sources/OpenUsage/Stores/WidgetRegistry.swift @@ -45,13 +45,25 @@ struct WidgetRegistry: Sendable { } /// Saved order filtered to installed providers, with newly introduced providers appended in the - /// canonical registry order. Shared by the dashboard, local API, and one-shot CLI. + /// canonical registry order — except account cards (`claude@ab12cd34`), which slot in right + /// after their family's group so a newly discovered account appears next to its siblings + /// instead of at the end of the dashboard. Shared by the dashboard, local API, and one-shot CLI. func orderedProviderIDs(savedOrder: [String]) -> [String] { let defaults = providers.map(\.id) let known = Set(defaults) let saved = savedOrder.filter { known.contains($0) } let savedIDs = Set(saved) - return saved + defaults.filter { !savedIDs.contains($0) } + var result = saved + for id in defaults where !savedIDs.contains(id) { + let family = ProviderAccountID.family(of: id) + if ProviderAccountID.isAccountCard(id), + let anchor = result.lastIndex(where: { ProviderAccountID.family(of: $0) == family }) { + result.insert(id, at: result.index(after: anchor)) + } else { + result.append(id) + } + } + return result } var limitDescriptorsByProvider: [String: [WidgetDescriptor]] { diff --git a/Sources/OpenUsage/Support/ShareCardRenderer.swift b/Sources/OpenUsage/Support/ShareCardRenderer.swift index 9aae39a4c..1bfad5fba 100644 --- a/Sources/OpenUsage/Support/ShareCardRenderer.swift +++ b/Sources/OpenUsage/Support/ShareCardRenderer.swift @@ -63,12 +63,15 @@ enum ShareCardRenderer { /// rows read density via `@AppStorage`, so the saved value is swapped to `.regular` for the duration /// of the render and restored on exit (synchronously), keeping the exported card consistent without /// disturbing the live popover. + /// `displayName` carries the live card title (a rename can land mid-session, after the + /// `Provider`'s own name was baked at launch); `nil` falls back to the baked name. @discardableResult static func share( group: ProviderGroup, dataStore: WidgetDataStore, layout: LayoutStore, - appearance: ColorScheme + appearance: ColorScheme, + displayName: String? = nil ) -> Bool { let isExpanded = layout.isProviderExpanded(group.provider.id) let alwaysRows = group.alwaysShownWidgets.compactMap { widget -> WidgetData? in @@ -85,7 +88,8 @@ enum ShareCardRenderer { plan: dataStore.plan(for: group.provider.id), rows: rows, appearance: appearance, - expandBoundaryIndex: isExpanded ? alwaysRows.count : nil + expandBoundaryIndex: isExpanded ? alwaysRows.count : nil, + displayNameOverride: displayName ) return renderAndCopy(view, label: group.provider.id, layout: layout) } diff --git a/Sources/OpenUsage/Support/TotalSpendAggregator.swift b/Sources/OpenUsage/Support/TotalSpendAggregator.swift index d8e599536..0a5324d58 100644 --- a/Sources/OpenUsage/Support/TotalSpendAggregator.swift +++ b/Sources/OpenUsage/Support/TotalSpendAggregator.swift @@ -65,6 +65,10 @@ enum TotalSpendMetric: String, CaseIterable, Identifiable, Sendable { /// plus whether the dollars are a local estimate (log-scanned providers) or measured (Cursor's CSV). struct TotalSpendSlice: Identifiable, Equatable { let provider: Provider + /// The card title, resolved by the aggregation's caller through the one name resolver — so the + /// legend, the ranking tie-break, and the share-card export (which renders outside the SwiftUI + /// environment and can't resolve for itself) all show the same live name. + let title: String let amountUSD: Double let tokenCount: Double let estimated: Bool @@ -82,6 +86,8 @@ struct TotalSpendSlice: Identifiable, Equatable { /// and ranks the legend, plus the formatted value surfaces read through `MetricFormatter`. struct TotalSpendProjectedSlice: Identifiable, Equatable { let provider: Provider + /// The already-resolved card title (see `TotalSpendSlice.title`) — what the legend renders. + let title: String let displayAmount: Double let estimated: Bool @@ -129,11 +135,16 @@ struct TotalSpend: Equatable { let ranked = included.sorted { lhs, rhs in if lhs.display != rhs.display { return lhs.display > rhs.display } - return lhs.slice.provider.displayName.localizedStandardCompare(rhs.slice.provider.displayName) == .orderedAscending + return lhs.slice.title.localizedStandardCompare(rhs.slice.title) == .orderedAscending } let projected = ranked.map { - TotalSpendProjectedSlice(provider: $0.slice.provider, displayAmount: $0.display, estimated: $0.slice.estimated) + TotalSpendProjectedSlice( + provider: $0.slice.provider, + title: $0.slice.title, + displayAmount: $0.display, + estimated: $0.slice.estimated + ) } let center: Double @@ -163,10 +174,14 @@ struct TotalSpend: Equatable { enum TotalSpendAggregator { /// The total for one period across `providers` (pass them in display order; ties keep it). /// Slices keep provider display order input only as a stable traversal; metric projection re-ranks. + /// `title` resolves each provider's card title — the live card passes the account-registry + /// resolver so slices carry renames; the default is the baked derived name for callers without + /// registry access (tests). static func total( for period: TotalSpendPeriod, providers: [Provider], - snapshots: [String: ProviderSnapshot] + snapshots: [String: ProviderSnapshot], + title: (Provider) -> String = { $0.displayName } ) -> TotalSpend { let slices = providers.compactMap { provider -> TotalSpendSlice? in guard let snapshot = snapshots[provider.id], @@ -182,6 +197,7 @@ enum TotalSpendAggregator { return TotalSpendSlice( provider: provider, + title: title(provider), amountUSD: max(amount, 0), tokenCount: max(tokens, 0), estimated: dollars.contains(where: \.estimated) diff --git a/Sources/OpenUsage/Support/TotalSpendPalette.swift b/Sources/OpenUsage/Support/TotalSpendPalette.swift new file mode 100644 index 000000000..1f62635d7 --- /dev/null +++ b/Sources/OpenUsage/Support/TotalSpendPalette.swift @@ -0,0 +1,136 @@ +import AppKit +import SwiftUI + +/// Stable brand tints for the Total Spend ring and legend. Colors follow account identity rather +/// than rank, so period changes, renamed cards, and local/remote transitions never shuffle them. +enum TotalSpendPalette { + /// Account families retain their recognizable brand hue while each stable account id gets its + /// own nearby shade. Keep these hexes identical to the bare-card entries below. + private static let accountBrandHex: [String: UInt32] = [ + "claude": 0xDE7356, + "codex": 0x10A37F + ] + + private static let byProviderID: [String: Color] = [ + "claude": hex(0xDE7356), // Claude terracotta + "codex": hex(0x10A37F), // OpenAI green (#10A37F) + "cursor": dynamic(light: 0x13120A, dark: 0xF5F5F7), // brand black (#13120A), flipped near-white in dark mode + "grok": dynamic(light: 0x8E8E93, dark: 0x98989D), // brand black, offset to gray next to Cursor + "opencode": dynamic(light: 0x6E6E73, dark: 0xAEAEB2), // OpenCode — grayscale brand, medium gray + "openrouter": hex(0x6467F2), // OpenRouter indigo + "antigravity": hex(0x4285F4), // Google blue + "copilot": hex(0xA855F7), // Copilot purple + "amp": hex(0xF34E3F), + "factory": dynamic(light: 0x48484A, dark: 0xC7C7CC), + "kimi": hex(0x0A66FF), + "minimax": hex(0xF5433C), + "zai": dynamic(light: 0x2D2D2D, dark: 0xD1D1D6) + ] + + /// Deterministic backstop hues for a provider that ships without a palette entry — keyed off the + /// provider ID (not rank), so the color holds steady across periods and launches. + private static let fallback: [Color] = [ + hex(0x34C759), hex(0x5856D6), hex(0xFF2D55), hex(0xA2845E) + ] + + static func color(for providerID: String) -> Color { + if let brand = byProviderID[providerID] { return brand } + if let components = accountComponents(for: providerID) { + return Color( + hue: components.hue, + saturation: components.saturation, + brightness: components.brightness + ) + } + let stableHash = providerID.unicodeScalars.reduce(0) { ($0 &* 31 &+ Int($1.value)) & 0xFFFF } + return fallback[stableHash % fallback.count] + } + + struct AccountColorComponents: Equatable { + let hue: Double + let saturation: Double + let brightness: Double + } + + /// Peer-only accounts carry an extra presentation prefix, but the identity-derived hash after + /// it matches the eventual local card. Normalize that prefix so signing in locally keeps its + /// ring color. FNV-1a is deliberately stable across processes, unlike Swift's randomized Hasher. + static func accountComponents(for providerID: String) -> AccountColorComponents? { + guard let separator = providerID.firstIndex(of: "@"), + let brandHex = accountBrandHex[String(providerID[..> 1) & 0x7FFF) / Double(0x7FFF) + let hueOffset = direction * (0.035 + hueSpread * 0.055) + let hue = (brand.hue + hueOffset + 1).truncatingRemainder(dividingBy: 1) + let saturationSpread = Double((hash >> 16) & 0xFFFF) / Double(0xFFFF) + let brightnessSpread = Double((hash >> 32) & 0xFFFF) / Double(0xFFFF) + let saturation = min(0.92, max(0.50, brand.saturation * (0.82 + saturationSpread * 0.25))) + let brightness = min(0.94, max(0.62, brand.brightness * (0.78 + brightnessSpread * 0.32))) + + return AccountColorComponents(hue: hue, saturation: saturation, brightness: brightness) + } + + private static func hueSaturationBrightness(for value: UInt32) -> AccountColorComponents { + let red = Double((value >> 16) & 0xFF) / 255 + let green = Double((value >> 8) & 0xFF) / 255 + let blue = Double(value & 0xFF) / 255 + let brightness = max(red, max(green, blue)) + let minimum = min(red, min(green, blue)) + let delta = brightness - minimum + guard delta > 0 else { + return AccountColorComponents(hue: 0, saturation: 0, brightness: brightness) + } + + let rawHue: Double + if brightness == red { + rawHue = (green - blue) / delta + } else if brightness == green { + rawHue = 2 + (blue - red) / delta + } else { + rawHue = 4 + (red - green) / delta + } + let hue = (rawHue / 6 + 1).truncatingRemainder(dividingBy: 1) + return AccountColorComponents( + hue: hue, + saturation: delta / brightness, + brightness: brightness + ) + } + + private static func hex(_ value: UInt32) -> Color { + Color( + red: Double((value >> 16) & 0xFF) / 255, + green: Double((value >> 8) & 0xFF) / 255, + blue: Double(value & 0xFF) / 255 + ) + } + + /// A light/dark-adaptive color, for brands whose mark is pure black — invisible on a dark card + /// unless flipped. + private static func dynamic(light: UInt32, dark: UInt32) -> Color { + Color(nsColor: NSColor(name: nil) { appearance in + let value = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? dark : light + return NSColor( + red: CGFloat((value >> 16) & 0xFF) / 255, + green: CGFloat((value >> 8) & 0xFF) / 255, + blue: CGFloat(value & 0xFF) / 255, + alpha: 1 + ) + }) + } +} diff --git a/Sources/OpenUsage/Views/CustomizeProviderDetailView.swift b/Sources/OpenUsage/Views/CustomizeProviderDetailView.swift index f77854281..60fd684b3 100644 --- a/Sources/OpenUsage/Views/CustomizeProviderDetailView.swift +++ b/Sources/OpenUsage/Views/CustomizeProviderDetailView.swift @@ -28,6 +28,9 @@ struct CustomizeProviderDetailView: View { var body: some View { if let group = layout.customizeDetail(for: providerID) { VStack(alignment: .leading, spacing: density.sectionSpacing) { + if container.canRename(providerID) { + CardNameSection(providerID: providerID) + } metricSections(group) .simultaneousGesture(metricDragGesture()) if let keyProvider = container.apiKeyProviders.first(where: { $0.provider.id == providerID }) { @@ -180,6 +183,50 @@ struct CustomizeProviderDetailView: View { } } +/// The card-name editor shown at the top of an account card's Customize detail (Claude/Codex cards +/// with an account record). The field holds the user's rename; the placeholder shows the derived +/// name the card falls back to, so clearing the field reads as "back to the default". Commits on +/// Return and when focus leaves the field — never per keystroke, so half-typed names don't persist. +private struct CardNameSection: View { + let providerID: String + @Environment(AppContainer.self) private var container + @AppStorage(DensitySetting.key) private var density = DensitySetting.regular + @State private var draft = "" + @FocusState private var isFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: density.headerToCardSpacing) { + Text("Name") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + TextField(placeholder, text: $draft) + .textFieldStyle(.plain) + .focused($isFocused) + .onSubmit { commit() } + .padding(.horizontal, 12) + .padding(.vertical, density.controlRowPadding) + .cardSurface() + } + .onAppear { draft = record?.customLabel ?? "" } + .onChange(of: isFocused) { _, focused in + if !focused { commit() } + } + } + + private var record: ProviderAccountRecord? { + container.accounts.runtimeRecord(for: providerID) + } + + private var placeholder: String { + container.accounts.derivedDisplayName(cardID: providerID) ?? "" + } + + private func commit() { + container.accounts.rename(cardID: providerID, to: draft) + } +} + /// The star (menu-bar pin) control on a metric row — always visible: an outline star when not /// starred, a filled accent star when starred. Tapping it pops a transient confirmation pill (green /// "Starred for menu bar" / "Removed from menu bar"); a denied tap over the per-provider cap shakes diff --git a/Sources/OpenUsage/Views/HeaderView.swift b/Sources/OpenUsage/Views/HeaderView.swift index 6c5900897..5f6dedf70 100644 --- a/Sources/OpenUsage/Views/HeaderView.swift +++ b/Sources/OpenUsage/Views/HeaderView.swift @@ -23,6 +23,7 @@ import SwiftUI /// and fire while the menu is open, so the monitor and the items never double-fire. ⌘Q (Quit) is /// unowned elsewhere, so it rides its menu item directly. struct HeaderView: View { + @Environment(AppContainer.self) private var container @Environment(LayoutStore.self) private var layout @Environment(WidgetDataStore.self) private var dataStore @Environment(UpdaterController.self) private var updater @@ -132,7 +133,7 @@ struct HeaderView: View { .disabled(true) } else { ForEach(groups) { group in - Button(group.provider.displayName) { shareCard(group) } + Button(container.displayName(for: group.provider)) { shareCard(group) } } } } label: { @@ -150,7 +151,8 @@ struct HeaderView: View { group: group, dataStore: dataStore, layout: layout, - appearance: colorScheme + appearance: colorScheme, + displayName: container.displayName(for: group.provider) ) } diff --git a/Sources/OpenUsage/Views/PopoverTopBar.swift b/Sources/OpenUsage/Views/PopoverTopBar.swift index 7ce5e6aa7..066e0dfb1 100644 --- a/Sources/OpenUsage/Views/PopoverTopBar.swift +++ b/Sources/OpenUsage/Views/PopoverTopBar.swift @@ -10,6 +10,9 @@ struct PopoverTopBar: View { @Binding var isPresentingResetAllConfirm: Bool + /// Read for the live card name, so a renamed card's Customize detail title follows the rename. + @Environment(AppContainer.self) private var container + @ViewBuilder var body: some View { switch layout.screen { @@ -43,7 +46,9 @@ struct PopoverTopBar: View { } private var customizeTitle: String { - layout.customizeProviderID.flatMap { layout.provider(id: $0)?.displayName } ?? "Customize" + layout.customizeProviderID.flatMap { id in + layout.provider(id: id).map { container.displayName(for: $0) } + } ?? "Customize" } private func customizeBack() { @@ -102,7 +107,7 @@ struct PopoverTopBar: View { .glassButtonStyle() .buttonBorderShape(.circle) .controlSize(.large) - .hoverTooltip("Reset \(layout.provider(id: providerID)?.displayName ?? providerID)") + .hoverTooltip("Reset \(layout.provider(id: providerID).map { container.displayName(for: $0) } ?? providerID)") .accessibilityLabel("Reset") } diff --git a/Sources/OpenUsage/Views/ProviderListRow.swift b/Sources/OpenUsage/Views/ProviderListRow.swift index db9fccaf7..41c733b61 100644 --- a/Sources/OpenUsage/Views/ProviderListRow.swift +++ b/Sources/OpenUsage/Views/ProviderListRow.swift @@ -15,6 +15,8 @@ struct ProviderListRow: View { var onOpen: () -> Void = {} @AppStorage(DensitySetting.key) private var density = DensitySetting.regular + /// Read for the live card name, so a rename re-titles the Customize row without a relaunch. + @Environment(AppContainer.self) private var container var body: some View { HStack(spacing: 10) { @@ -29,7 +31,7 @@ struct ProviderListRow: View { ProviderIcon(source: provider.icon) .frame(width: 18, height: 18) VStack(alignment: .leading, spacing: 0) { - Text(provider.displayName) + Text(container.displayName(for: provider)) .font(.system(size: density.headerPointSize, weight: .semibold)) .foregroundStyle(.primary) .lineLimit(1) @@ -54,7 +56,7 @@ struct ProviderListRow: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityLabel("Open \(provider.displayName)") + .accessibilityLabel("Open \(container.displayName(for: provider))") } .padding(.horizontal, 12) .padding(.vertical, density.controlRowPadding) diff --git a/Sources/OpenUsage/Views/ProviderSectionHeader.swift b/Sources/OpenUsage/Views/ProviderSectionHeader.swift index a079bfaf6..a16ce7cec 100644 --- a/Sources/OpenUsage/Views/ProviderSectionHeader.swift +++ b/Sources/OpenUsage/Views/ProviderSectionHeader.swift @@ -28,6 +28,9 @@ struct ProviderSectionHeader: View { /// Header type and icon track the density setting like the rows do, so Compact shrinks the /// whole section anatomy — not just the rows under it. @AppStorage(DensitySetting.key) private var density = DensitySetting.regular + /// Read for the live card name: a rename lands in the account registry and re-titles the header + /// without a relaunch (the `Provider`'s own name is baked at launch). + @Environment(AppContainer.self) private var container /// Party easter egg: pulse the provider mark. Off by default everywhere else. @Environment(\.popoverPartyMode) private var partyMode @State private var isHovered = false @@ -61,7 +64,7 @@ struct ProviderSectionHeader: View { // Name + plan keep their width and stay on one line; under width pressure (a long plan // name like "Super Grok Heavy") the lower-priority stale tag truncates first instead of // wrapping the name to a second line. - Text(provider.displayName) + Text(container.displayName(for: provider)) .font(.system(size: density.headerPointSize, weight: .semibold)) .foregroundStyle(.primary) .lineLimit(1) @@ -95,7 +98,7 @@ struct ProviderSectionHeader: View { Spacer(minLength: 8) if let onCopyScreenshot { CopyFeedbackButton( - accessibilityLabel: "Copy \(provider.displayName) Screenshot", + accessibilityLabel: "Copy \(container.displayName(for: provider)) Screenshot", isRevealed: isHovered, action: onCopyScreenshot ) diff --git a/Sources/OpenUsage/Views/ShareCardView.swift b/Sources/OpenUsage/Views/ShareCardView.swift index e1041a01f..77faad42a 100644 --- a/Sources/OpenUsage/Views/ShareCardView.swift +++ b/Sources/OpenUsage/Views/ShareCardView.swift @@ -20,6 +20,10 @@ struct ShareCardView: View { /// neighbor-aware condensing treats the expand caret as a hard boundary the way the live dashboard /// does. `nil` when the provider is collapsed (no expanded section). var expandBoundaryIndex: Int? = nil + /// The live card title when it differs from the launch-baked `provider.displayName` (a rename can + /// land mid-session). Passed explicitly — this view renders in an `ImageRenderer`, outside the + /// app's environment, so it can't read the account registry itself. + var displayNameOverride: String? = nil /// Authored card width in points. The renderer multiplies this by `ShareCardRenderer.scale` for the /// PNG's pixel width; the height is whatever the rows add up to (flexible). @@ -42,7 +46,7 @@ struct ShareCardView: View { ProviderIcon(source: provider.icon, inset: 0.04) .frame(width: 22, height: 22) HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(provider.displayName) + Text(displayNameOverride ?? provider.displayName) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.primary) .lineLimit(1) diff --git a/Sources/OpenUsage/Views/TotalSpendCard.swift b/Sources/OpenUsage/Views/TotalSpendCard.swift index 54cd338b4..022f59aef 100644 --- a/Sources/OpenUsage/Views/TotalSpendCard.swift +++ b/Sources/OpenUsage/Views/TotalSpendCard.swift @@ -11,6 +11,7 @@ import SwiftUI struct TotalSpendCard: View { @Environment(LayoutStore.self) private var layout @Environment(WidgetDataStore.self) private var dataStore + @Environment(AppContainer.self) private var container @Environment(\.colorScheme) private var colorScheme @Namespace private var pickerNamespace @@ -36,7 +37,23 @@ struct TotalSpendCard: View { } private var total: TotalSpend { - TotalSpendAggregator.total(for: period, providers: providers, snapshots: dataStore.snapshots) + // Accounts that live only on other Macs (synced, no card here) count toward the total and + // get their own legend slice ("claude@ab12cd34") — the number should be the whole truth + // even when a login isn't set up on this machine. + var aggregatedProviders = providers + var aggregatedSnapshots = dataStore.snapshots + for entry in dataStore.remoteOnlySpend { + aggregatedProviders.append(entry.provider) + aggregatedSnapshots[entry.provider.id] = entry.snapshot + } + // Titles resolve here — the one place with registry access — so the legend AND the share + // export (rendered outside the environment) carry live renames. + return TotalSpendAggregator.total( + for: period, + providers: aggregatedProviders, + snapshots: aggregatedSnapshots, + title: { container.displayName(for: $0) } + ) } private var projection: TotalSpendProjection { @@ -104,7 +121,8 @@ struct TotalSpendCard: View { /// hardcoded list, so disabling a provider (or a new spend provider shipping) can't make the /// tooltip lie about what the total reflects. private var infoTooltip: String { - let names = providers.map(\.displayName) + let names = providers.map { container.displayName(for: $0) } + + dataStore.remoteOnlySpend.map(\.provider.displayName) return "Only includes \(names.formatted(.list(type: .and)))." } @@ -332,7 +350,7 @@ struct TotalSpendRingContent: View { Circle() .fill(TotalSpendPalette.color(for: slice.provider.id)) .frame(width: 8, height: 8) - Text(slice.provider.displayName) + Text(slice.title) .font(.system(size: density.supportingPointSize)) .foregroundStyle(.primary) .lineLimit(1) @@ -367,61 +385,3 @@ struct TotalSpendRingContent: View { } } } - -/// Stable per-provider brand tints for the Total Spend ring and legend — the one place the app maps -/// a provider to a color, so the chart, legend, and share card always agree. Colors are keyed by -/// provider ID only (never by rank or position), so a provider keeps its color across period -/// switches, re-sorts, and launches. Hexes come from the legacy edition's per-plugin `brandColor` -/// values; brands whose color is plain black (Cursor, Grok) get adaptive near-black/near-white -/// dynamic colors so they read on both appearances without both landing on the same gray. -enum TotalSpendPalette { - private static let byProviderID: [String: Color] = [ - "claude": hex(0xDE7356), // Claude terracotta - "codex": hex(0x10A37F), // OpenAI green (#10A37F) - "cursor": dynamic(light: 0x13120A, dark: 0xF5F5F7), // brand black (#13120A), flipped near-white in dark mode - "grok": dynamic(light: 0x8E8E93, dark: 0x98989D), // brand black, offset to gray next to Cursor - "opencode": dynamic(light: 0x6E6E73, dark: 0xAEAEB2), // OpenCode — grayscale brand, medium gray - "openrouter": hex(0x6467F2), // OpenRouter indigo - "antigravity": hex(0x4285F4), // Google blue - "copilot": hex(0xA855F7), // Copilot purple - "amp": hex(0xF34E3F), - "factory": dynamic(light: 0x48484A, dark: 0xC7C7CC), - "kimi": hex(0x0A66FF), - "minimax": hex(0xF5433C), - "zai": dynamic(light: 0x2D2D2D, dark: 0xD1D1D6) - ] - - /// Deterministic backstop hues for a provider that ships without a palette entry — keyed off the - /// provider ID (not rank), so the color holds steady across periods and launches. - private static let fallback: [Color] = [ - hex(0x34C759), hex(0x5856D6), hex(0xFF2D55), hex(0xA2845E) - ] - - static func color(for providerID: String) -> Color { - if let brand = byProviderID[providerID] { return brand } - let stableHash = providerID.unicodeScalars.reduce(0) { ($0 &* 31 &+ Int($1.value)) & 0xFFFF } - return fallback[stableHash % fallback.count] - } - - private static func hex(_ value: UInt32) -> Color { - Color( - red: Double((value >> 16) & 0xFF) / 255, - green: Double((value >> 8) & 0xFF) / 255, - blue: Double(value & 0xFF) / 255 - ) - } - - /// A light/dark-adaptive color, for brands whose mark is pure black — invisible on a dark card - /// unless flipped. - private static func dynamic(light: UInt32, dark: UInt32) -> Color { - Color(nsColor: NSColor(name: nil) { appearance in - let value = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? dark : light - return NSColor( - red: CGFloat((value >> 16) & 0xFF) / 255, - green: CGFloat((value >> 8) & 0xFF) / 255, - blue: CGFloat(value & 0xFF) / 255, - alpha: 1 - ) - }) - } -} diff --git a/Sources/OpenUsage/Views/WidgetGroupedListView.swift b/Sources/OpenUsage/Views/WidgetGroupedListView.swift index 0fe233d3f..b6174e6c3 100644 --- a/Sources/OpenUsage/Views/WidgetGroupedListView.swift +++ b/Sources/OpenUsage/Views/WidgetGroupedListView.swift @@ -20,6 +20,8 @@ struct WidgetGroupedListView: View { @State private var frameStore = ReorderFrameStore() @State private var activeProviderID: String? @State private var activeMetricID: String? + @State private var renameCardID: String? + @State private var renameDraft = "" @AppStorage(DensitySetting.key) private var density = DensitySetting.regular var body: some View { @@ -33,6 +35,24 @@ struct WidgetGroupedListView: View { .frame(maxWidth: .infinity, alignment: .leading) .onPreferenceChange(ReorderFramePreferenceKey.self) { frameStore.frames = $0 } .animation(Motion.spring, value: layout.displayGroups.map(\.provider.id)) + .alert("Rename Card", isPresented: isRenamePresented) { + TextField("Name", text: $renameDraft) + Button("Rename") { + if let renameCardID { + container.accounts.rename(cardID: renameCardID, to: renameDraft) + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Leave the name empty to go back to the default.") + } + } + + private var isRenamePresented: Binding { + Binding( + get: { renameCardID != nil }, + set: { if !$0 { renameCardID = nil } } + ) } private func section(_ group: ProviderGroup) -> some View { @@ -57,15 +77,22 @@ struct WidgetGroupedListView: View { .padding(.horizontal, 8) .highPriorityGesture(providerDragGesture(for: group)) .contextMenu { + let name = container.displayName(for: group.provider) // Hides the whole provider section (the Customize provider list brings it back). Mirrors // the per-metric "Hide" but one level up, so the verb order reads the same on a header as a row. - Button("Hide \(group.provider.displayName)") { + Button("Hide \(name)") { container.enablement.setEnabled(false, for: group.provider.id) } Divider() - Button("Refresh \(group.provider.displayName)") { + Button("Refresh \(name)") { Task { await dataStore.refresh(providerID: group.provider.id, force: true) } } + if container.canRename(group.provider.id) { + Button("Rename…") { + renameDraft = container.accounts.runtimeRecord(for: group.provider.id)?.customLabel ?? "" + renameCardID = group.provider.id + } + } Button("Customize…") { openCustomize(for: group.provider.id) } @@ -85,7 +112,8 @@ struct WidgetGroupedListView: View { group: group, dataStore: dataStore, layout: layout, - appearance: colorScheme + appearance: colorScheme, + displayName: container.displayName(for: group.provider) ) } @@ -261,7 +289,7 @@ struct WidgetGroupedListView: View { } Divider() if let provider = layout.provider(id: providerID) { - Button("Refresh \(provider.displayName)") { + Button("Refresh \(container.displayName(for: provider))") { Task { await dataStore.refresh(providerID: providerID, force: true) } } } diff --git a/Tests/OpenUsageTests/AccountRuntimeLifecycleTests.swift b/Tests/OpenUsageTests/AccountRuntimeLifecycleTests.swift new file mode 100644 index 000000000..49932409e --- /dev/null +++ b/Tests/OpenUsageTests/AccountRuntimeLifecycleTests.swift @@ -0,0 +1,202 @@ +import KeyboardShortcuts +import Network +import XCTest +@testable import OpenUsage + +@MainActor +final class LocalUsageServerTests: XCTestCase { + func testReplacementWaitsForOldListenerCancellationBeforeStarting() async throws { + let first = FakeLocalUsageListener() + first.automaticallyFinishesCancellation = false + let replacement = FakeLocalUsageListener() + let retired = LocalUsageServer(state: Self.emptyState, makeListener: { _ in first }) + let successor = LocalUsageServer(state: Self.emptyState, makeListener: { _ in replacement }) + retired.start() + XCTAssertEqual(first.startCount, 1) + + let handoff = Task { @MainActor in + await retired.stop() + successor.start() + } + try await waitUntil { first.cancelCount == 1 } + XCTAssertEqual(replacement.startCount, 0, "the old socket must be released before rebinding") + + first.send(.cancelled) + await handoff.value + + XCTAssertEqual(replacement.startCount, 1) + XCTAssertEqual(replacement.cancelCount, 0) + await successor.stop() + XCTAssertEqual(replacement.cancelCount, 1) + } + + func testOccupiedPortDisablesListenerWithoutRetrying() async throws { + let listener = FakeLocalUsageListener() + var attempts = 0 + let server = LocalUsageServer(state: Self.emptyState, makeListener: { _ in + attempts += 1 + return listener + }) + server.start() + listener.send(.failed(.posix(.EADDRINUSE))) + try await waitUntil { listener.cancelCount == 1 } + + XCTAssertEqual(attempts, 1, "an externally occupied port disables the optional API") + await server.stop() + } + + func testAcceptedConnectionCannotServeAfterStopOrListenerReplacement() { + let originalGeneration = UUID() + let replacementGeneration = UUID() + let cases: [(Bool, UUID?, Bool)] = [ + (true, originalGeneration, true), (false, originalGeneration, false), + (true, nil, false), (true, replacementGeneration, false), + ] + for (running, listenerGeneration, expected) in cases { + XCTAssertEqual(LocalUsageServer.canServeConnection( + isRunning: running, listenerGeneration: listenerGeneration, + connectionGeneration: originalGeneration + ), expected) + } + } + + private nonisolated static func emptyState() -> LocalUsageAPI.State { + LocalUsageAPI.State(enabledOrderedIDs: [], knownIDs: [], snapshots: [:]) + } + + private func waitUntil(timeout: Duration = .seconds(2), condition: @escaping @MainActor () -> Bool) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if condition() { return } + try await Task.sleep(for: .milliseconds(5)) + } + XCTFail("Condition was not met before timeout") + } +} + +@MainActor +private final class FakeLocalUsageListener: LocalUsageListening { + private var stateHandler: (@Sendable (NWListener.State) -> Void)? + private(set) var startCount = 0 + private(set) var cancelCount = 0 + var automaticallyFinishesCancellation = true + + func setStateHandler(_ handler: (@Sendable (NWListener.State) -> Void)?) { stateHandler = handler } + func setConnectionHandler(_ handler: (@Sendable (NWConnection) -> Void)?) {} + func start(queue: DispatchQueue) { startCount += 1 } + + func cancel() { + cancelCount += 1 + if automaticallyFinishesCancellation { send(.cancelled) } + } + + func send(_ state: NWListener.State) { stateHandler?(state) } +} + +@MainActor +final class AccountDiscoveryThreadingTests: XCTestCase { + func testAccountFilesystemScansRunOffTheMainThread() async { + let prepared = await AppContainer.prepareAccountDiscovery( + configScan: { ClaudeConfigDirDiscovery.Result(notes: [Thread.isMainThread ? "main" : "background"]) }, + coworkScan: { ClaudeCoworkDiscovery.Result(notes: [Thread.isMainThread ? "main" : "background"]) } + ) + + XCTAssertEqual(prepared.config.notes, ["background"]) + XCTAssertEqual(prepared.cowork.notes, ["background"]) + } + + func testCancelledCoworkScanQuarantinesItsPartialResult() async { + let sandbox = URL(fileURLWithPath: "/Users/dev/cowork/.claude") + let result = await Task.detached { + ClaudeCoworkDiscovery( + files: FakeFiles([:]), + homeDirectory: { URL(fileURLWithPath: "/Users/dev") }, + listSandboxes: { _ in + withUnsafeCurrentTask { task in task?.cancel() } + return [sandbox] + } + ).run() + }.value + + XCTAssertTrue(result.truncated) + XCTAssertTrue(result.sandboxes.isEmpty) + } + + func testCancellationAfterConfigScanSkipsCoworkDiscovery() async { + let prepared = await AppContainer.prepareAccountDiscovery( + configScan: { + withUnsafeCurrentTask { task in task?.cancel() } + return ClaudeConfigDirDiscovery.Result(notes: ["config scanned"]) + }, + coworkScan: { ClaudeCoworkDiscovery.Result(notes: ["cowork should not run"]) } + ) + + XCTAssertEqual(prepared.config.notes, ["config scanned"]) + XCTAssertTrue(prepared.cowork.truncated) + } +} + +@MainActor +final class StatusItemShortcutLifecycleTests: XCTestCase { + func testRemovingControllerHandlerPreservesShortcutAndAllowsReplacement() { + let name = KeyboardShortcuts.Name("OpenUsageTests.StatusItemShortcut.\(UUID().uuidString)") + let shortcut = KeyboardShortcuts.Shortcut(.f19, modifiers: [.command, .control, .option, .shift]) + defer { + KeyboardShortcuts.removeHandler(for: name) + KeyboardShortcuts.reset(name) + } + + KeyboardShortcuts.setShortcut(shortcut, for: name) + KeyboardShortcuts.onKeyUp(for: name) {} + + KeyboardShortcuts.removeHandler(for: name) + XCTAssertFalse(KeyboardShortcuts.isEnabled(for: name)) + XCTAssertEqual(KeyboardShortcuts.getShortcut(for: name), shortcut) + + KeyboardShortcuts.onKeyUp(for: name) {} + XCTAssertTrue(KeyboardShortcuts.isEnabled(for: name)) + XCTAssertEqual(KeyboardShortcuts.getShortcut(for: name), shortcut) + } +} + +@MainActor +final class WidgetAccountIdentityCapabilityTests: XCTestCase { + func testAccountIdentityCapabilityStampsAndQuarantinesAnyReportingProvider() async { + let runtime = IdentityReportingProvider() + let suiteName = "OpenUsageTests.PortableAccountIdentity.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + let cache = ProviderSnapshotCache(userDefaults: defaults, storageKey: "snapshots", ttl: 600) + let store = WidgetDataStore( + registry: WidgetRegistry(providers: [runtime.provider], descriptors: []), + providers: [runtime], cache: cache, defaults: defaults + ) + + await store.refresh(providerID: runtime.provider.id, force: true) + XCTAssertEqual(cache.producedByIdentityKey(providerID: runtime.provider.id), "account-a") + + runtime.nextIdentity = (nil, Data("credential-b".utf8)) + await store.refresh(providerID: runtime.provider.id, force: true) + XCTAssertNil(cache.producedByIdentityKey(providerID: runtime.provider.id)) + } +} + +@MainActor +private final class IdentityReportingProvider: ProviderRuntime, AccountIdentityReporting { + let provider = Provider(id: "portable-account", displayName: "Portable", icon: .providerMark("codex")) + let widgetDescriptors: [WidgetDescriptor] = [] + var verifiedAccountIdentityKey: String? = "account-a" + var accountOwnershipContinuityToken: Data? = Data("credential-a".utf8) + var nextIdentity: (key: String?, token: Data)? + + func refresh() async -> ProviderSnapshot { + if let nextIdentity { + verifiedAccountIdentityKey = nextIdentity.key + accountOwnershipContinuityToken = nextIdentity.token + } + return ProviderSnapshot(providerID: provider.id, displayName: provider.displayName, lines: []) + } + + func hasLocalCredentials() async -> Bool { true } +} diff --git a/Tests/OpenUsageTests/AntigravityLayoutTests.swift b/Tests/OpenUsageTests/AntigravityLayoutTests.swift index 7cd7892cf..1c0192bc1 100644 --- a/Tests/OpenUsageTests/AntigravityLayoutTests.swift +++ b/Tests/OpenUsageTests/AntigravityLayoutTests.swift @@ -60,10 +60,10 @@ final class AntigravityLayoutTests: XCTestCase { "a metric the user already lived with is never silently tucked away") } - func testSavedGeminiFlashStateIsFilteredEverywhere() { - // `antigravity.geminiFlash` no longer exists (owner-approved: its layout state drops with no - // migration). Every load path filters unknown IDs against the registry, so stale saved state - // self-heals. + func testSavedGeminiFlashStateStaysInvisibleWhileItsTombstonesAreRetained() { + // `antigravity.geminiFlash` no longer exists, so live registry lookups keep it out of the UI. + // Its saved state remains as a harmless tombstone because an unknown descriptor can also be a + // temporarily absent account card, whose customization must return on the next launch. let defaults = makeDefaults("FlashFilter") saveStored([ PlacedWidget(descriptorID: "antigravity.geminiPro"), @@ -80,8 +80,13 @@ final class AntigravityLayoutTests: XCTestCase { XCTAssertFalse(store.isMetricEnabled("antigravity.geminiFlash")) XCTAssertFalse(store.orderedSupportedMetrics(for: "antigravity").map(\.id).contains("antigravity.geminiFlash")) - // The saved pin set is respected exactly (dead ID dropped, no weekly pin auto-added). - XCTAssertEqual(store.pinnedMetricIDs, ["antigravity.geminiPro"]) + XCTAssertFalse(store.isPinned("antigravity.geminiFlash"), "the dead pin stays invisible") + XCTAssertTrue( + store.pinnedMetricIDs.contains("antigravity.geminiFlash"), + "…but its tombstone is retained for a possible return" + ) + XCTAssertTrue(store.isPinned("antigravity.geminiPro")) + XCTAssertFalse(store.isPinned("antigravity.geminiWeekly"), "an existing pin set gains no new defaults") } func testAbsentPinsKeyAdoptsGeminiWeeklyPinOnUpgrade() { diff --git a/Tests/OpenUsageTests/ClaudeAccountIsolationTests.swift b/Tests/OpenUsageTests/ClaudeAccountIsolationTests.swift index d953c3042..3db4f3d97 100644 --- a/Tests/OpenUsageTests/ClaudeAccountIsolationTests.swift +++ b/Tests/OpenUsageTests/ClaudeAccountIsolationTests.swift @@ -134,6 +134,82 @@ final class ClaudeAccountIsolationTests: XCTestCase { XCTAssertEqual(usageRequests(fixture.http).count, 2) } + func testAccountBoundRuntimeNeverPublishesADifferentLoginAfterMidRequestSwap() async { + let accountA = credentials(access: "account-a", refresh: "refresh-a", plan: "pro") + let accountB = credentials(access: "account-b", refresh: "refresh-b", plan: "max") + let identityPath = "/tmp/claude/.claude.json" + let files = FakeFiles([ + path: accountA, + identityPath: Self.identity(uuid: "account-a", organization: "personal"), + ]) + let fixture = makeFixture( + files: files, + expectedIdentityKey: "account-a|personal" + ) { [path] request in + if request.headers["Authorization"] == "Bearer account-a" { + files.files[path] = accountB + files.files[identityPath] = Self.identity(uuid: "account-b", organization: "work") + return Self.usageResponse(percent: 25) + } + return Self.usageResponse(percent: 75) + } + + let snapshot = await fixture.provider.refresh() + + XCTAssertNil(sessionUsage(snapshot), "another account's limits must never be published on the bound card") + XCTAssertNotNil(snapshot.errorCategory) + XCTAssertFalse(usageRequests(fixture.http).contains { + $0.headers["Authorization"] == "Bearer account-b" + }) + } + + func testAccountBoundRuntimeRejectsAnotherOrganizationForTheSameClaudeUser() async { + let files = FakeFiles([ + path: credentials(access: "team-token", refresh: "team-refresh", plan: "team"), + "/tmp/claude/.claude.json": Self.identity(uuid: "shared-user", organization: "team"), + ]) + let fixture = makeFixture( + files: files, + expectedIdentityKey: "shared-user|personal" + ) { _ in + XCTFail("a credential owned by another organization must never reach the usage endpoint") + return Self.usageResponse(percent: 80) + } + + let snapshot = await fixture.provider.refresh() + + XCTAssertNil(sessionUsage(snapshot)) + XCTAssertNotNil(snapshot.errorCategory) + XCTAssertTrue(fixture.http.requests.isEmpty) + } + + func testAccountBoundRuntimeStillAcceptsTokenRotationForTheSameAccount() async { + let files = FakeFiles([ + path: credentials( + access: "old-access", refresh: "old-refresh", plan: "pro", expiresAt: 1 + ), + "/tmp/claude/.claude.json": Self.identity(uuid: "account-a", organization: "personal"), + ]) + let fixture = makeFixture( + files: files, + expectedIdentityKey: "account-a|personal" + ) { request in + if request.url.absoluteString.hasSuffix("/v1/oauth/token") { + return HTTPResponse( + statusCode: 200, + headers: [:], + body: Data(#"{"access_token":"new-access","refresh_token":"new-refresh","expires_in":3600}"#.utf8) + ) + } + return Self.usageResponse(percent: 25) + } + + let snapshot = await fixture.provider.refresh() + + XCTAssertEqual(sessionUsage(snapshot), 25) + XCTAssertTrue(files.files[path]?.contains("new-refresh") == true) + } + func testHigherPriorityLoginAddedDuringUsageRequestWins() async { let accountA = credentials(access: "account-a", refresh: "refresh-a", plan: "pro") let accountB = credentials(access: "account-b", refresh: "refresh-b", plan: "max") @@ -232,6 +308,34 @@ final class ClaudeAccountIsolationTests: XCTestCase { ) } + + func testSubscriptionDowngradeUpdatesPlanWithoutChangingAccountOrBillingLookup() async { + let identityPath = "/tmp/claude/.claude.json" + let files = FakeFiles([ + identityPath: + #"{"oauthAccount":{"accountUuid":"ACCOUNT-A","organizationUuid":"PERSONAL"}}"#, + path: + #"{"claudeAiOauth":{"accessToken":"same-account-token","subscriptionType":"max","rateLimitTier":"default_claude_max_20x","scopes":["user:profile"]}}"#, + ]) + let fixture = makeFixture(files: files, expectedIdentityKey: "account-a|personal") { + _ in Self.usageResponse(percent: 25) + } + + let maxSnapshot = await fixture.provider.refresh() + XCTAssertEqual(maxSnapshot.plan, "Max 20x") + + files.files[path] = + #"{"claudeAiOauth":{"accessToken":"same-account-token","subscriptionType":"pro","rateLimitTier":"default_claude_pro","scopes":["user:profile"]}}"# + let downgradedSnapshot = await fixture.provider.refresh() + + XCTAssertEqual(downgradedSnapshot.plan, "Pro") + XCTAssertEqual(downgradedSnapshot.providerID, maxSnapshot.providerID) + XCTAssertEqual(fixture.http.requests.count, 2) + XCTAssertTrue(fixture.http.requests.allSatisfy { + $0.url.absoluteString == "https://api.anthropic.com/api/oauth/usage" + }) + } + private struct Fixture { var provider: ClaudeProvider var files: FakeFiles @@ -248,6 +352,7 @@ final class ClaudeAccountIsolationTests: XCTestCase { private func makeFixture( files: FakeFiles, keychain: any KeychainAccessing = FakeKeychain(), + expectedIdentityKey: String? = nil, handler: @escaping @Sendable (HTTPRequest) async throws -> HTTPResponse ) -> Fixture { let http = RoutingHTTPClient(handler: handler) @@ -263,7 +368,8 @@ final class ClaudeAccountIsolationTests: XCTestCase { usageClient: ClaudeUsageClient(httpClient: http), logUsageScanner: ClaudeLogFixture.scanner(home: nil), now: { now }, - pricing: { TestPricing.bundled } + pricing: { TestPricing.bundled }, + expectedIdentityKey: expectedIdentityKey ), files: files, http: http @@ -279,6 +385,10 @@ final class ClaudeAccountIsolationTests: XCTestCase { #"{"claudeAiOauth":{"accessToken":"\#(access)","refreshToken":"\#(refresh)","expiresAt":\#(expiresAt),"subscriptionType":"\#(plan)","scopes":["user:profile"]}}"# } + private nonisolated static func identity(uuid: String, organization: String) -> String { + #"{"oauthAccount":{"accountUuid":"\#(uuid)","organizationUuid":"\#(organization)"}}"# + } + private nonisolated static func usageResponse(percent: Double) -> HTTPResponse { HTTPResponse( statusCode: 200, diff --git a/Tests/OpenUsageTests/ClaudeConfigDirDiscoveryTests.swift b/Tests/OpenUsageTests/ClaudeConfigDirDiscoveryTests.swift new file mode 100644 index 000000000..85b1c16ff --- /dev/null +++ b/Tests/OpenUsageTests/ClaudeConfigDirDiscoveryTests.swift @@ -0,0 +1,266 @@ +import XCTest +@testable import OpenUsage + +/// A discovered account needs both a verified identity and its own file or scoped keychain login. +final class ClaudeConfigDirDiscoveryTests: XCTestCase { + private let home = URL(fileURLWithPath: "/Users/dev") + + private func discover( + _ files: [String: String], paths: [String], + environment: [String: String] = [:], services: [String: String] = [:] + ) -> ClaudeConfigDirDiscovery.Result { + ClaudeConfigDirDiscovery( + environment: FakeEnvironment(environment), files: FakeFiles(files), + keychain: ServiceKeychain(values: services), homeDirectory: { [home] in home }, + listSubdirectories: { url in + paths.map(URL.init(fileURLWithPath:)).filter { + $0.deletingLastPathComponent().path == url.path + } + } + ).run() + } + + func testFileAndScopedKeychainCredentialsRequireTheirOwnIdentity() throws { + let filePath = "/Users/dev/.claude-work" + let keyedPath = "/Users/dev/.claude-alt" + let literal = "~/.claude-alt" + let service = ClaudeAuthStore.scopedKeychainServiceName( + forConfigDirLiteral: literal, environment: FakeEnvironment() + ) + let result = discover([ + filePath + "/.claude.json": + #"{"oauthAccount":{"accountUuid":"WORK","emailAddress":"work@example.com","organizationName":"Sunstory"}}"#, + filePath + "/.credentials.json": #"{"claudeAiOauth":{"accessToken":"work"}}"#, + keyedPath + "/.claude.json": #"{"oauthAccount":{"accountUuid":"KEYED"}}"#, + ], paths: [filePath, keyedPath], services: [service: "present"]) + + let work = try XCTUnwrap(result.findings.first { $0.identityKey == "work" }) + XCTAssertEqual(work.label, "work@example.com (Sunstory)") + XCTAssertEqual(work.anchorPath, filePath) + XCTAssertEqual(result.findings.first { $0.identityKey == "keyed" }?.keychainLiteral, literal) + } + + func testIdentityAndCredentialCannotBeBorrowedFromDifferentDirectories() { + let identityOnly = "/Users/dev/.claude-identity" + let credentialOnly = "/Users/dev/.claude-anon" + let result = discover([ + identityOnly + "/.claude.json": #"{"oauthAccount":{"accountUuid":"ACCOUNT"}}"#, + credentialOnly + "/.credentials.json": #"{"claudeAiOauth":{"accessToken":"other"}}"#, + ], paths: [identityOnly, credentialOnly]) + + XCTAssertTrue(result.findings.isEmpty) + XCTAssertEqual(result.notes.count, 1) + } + + func testDefaultHomesRespectConfiguredOverrideAndNeverProduceDuplicateCards() { + let configured = "/Users/dev/.claude-main" + let xdg = "/Users/dev/.config/claude" + let files = [ + configured + "/.claude.json": #"{"oauthAccount":{"accountUuid":"MAIN"}}"#, + configured + "/.credentials.json": #"{"claudeAiOauth":{"accessToken":"main"}}"#, + xdg + "/.claude.json": #"{"oauthAccount":{"accountUuid":"XDG"}}"#, + xdg + "/.credentials.json": #"{"claudeAiOauth":{"accessToken":"xdg"}}"#, + ] + XCTAssertEqual( + discover( + files, paths: [configured, xdg], + environment: ["CLAUDE_CONFIG_DIR": "~/.claude-main"] + ).findings.map(\.identityKey), + ["xdg"] + ) + XCTAssertEqual(discover(files, paths: [configured, xdg]).findings.map(\.identityKey), ["main"]) + } +} + +/// Every sandbox contributes its exact account identity, or remains explicitly unassigned. +final class ClaudeCoworkDiscoveryTests: XCTestCase { + private let sandboxA = URL(fileURLWithPath: "/Users/dev/cowork/a/.claude") + private let sandboxB = URL(fileURLWithPath: "/Users/dev/cowork/b/.claude") + + private func discover( + files: [String: String], roots: [URL], timeBudget: TimeInterval = 3 + ) -> ClaudeCoworkDiscovery.Result { + ClaudeCoworkDiscovery( + files: FakeFiles(files), homeDirectory: { URL(fileURLWithPath: "/Users/dev") }, + listSandboxes: { _ in roots }, timeBudget: timeBudget + ).run() + } + + func testEverySandboxPreservesItsIdentityOrExplicitlyRemainsUnassigned() throws { + let missing = URL(fileURLWithPath: "/Users/dev/cowork/missing/.claude") + let result = discover(files: [ + sandboxA.path + "/.claude.json": + #"{"oauthAccount":{"accountUuid":"WORK","emailAddress":"work@example.com","organizationUuid":"ORG","organizationName":"Sunstory"}}"#, + sandboxB.path + "/.claude.json": #"{"oauthAccount":{}}"#, + ], roots: [sandboxA, sandboxB, missing]) + + let verified = try XCTUnwrap(result.sandboxes.first) + XCTAssertEqual(verified.root, sandboxA) + XCTAssertEqual(verified.identityKey, "work|org") + XCTAssertEqual(verified.organization, "org") + XCTAssertEqual(verified.label, "work@example.com (Sunstory)") + XCTAssertEqual(result.sandboxes.map(\.root), [sandboxA, sandboxB, missing]) + XCTAssertNil(result.sandboxes[1].identityKey) + XCTAssertNil(result.sandboxes[2].identityKey) + } + + func testTruncatedWalkIsExplicitAndNormalBudgetHandlesHundredsOfSandboxes() { + let truncated = discover(files: [:], roots: [sandboxA, sandboxB], timeBudget: -1) + XCTAssertTrue(truncated.truncated) + XCTAssertTrue(truncated.sandboxes.isEmpty) + XCTAssertEqual(truncated.notes.count, 1) + + let roots = (0..<250).map { URL(fileURLWithPath: "/Users/dev/cowork/\($0)/.claude") } + let files = Dictionary(uniqueKeysWithValues: roots.enumerated().map { index, root in + ( + root.path + "/.claude.json", + #"{"oauthAccount":{"accountUuid":"ACCOUNT-\#(index % 3)","organizationUuid":"ORG-\#(index % 3)"}}"# + ) + }) + let complete = discover(files: files, roots: roots) + XCTAssertFalse(complete.truncated) + XCTAssertEqual(complete.sandboxes.count, roots.count) + XCTAssertEqual(Set(complete.sandboxes.compactMap(\.identityKey)), [ + "account-0|org-0", "account-1|org-1", "account-2|org-2", + ]) + } + + func testCoworkRootsOverrideExcludesAnotherAccountsUsageFromTheScanner() async throws { + let now = Date() + let timestamp = OpenUsageISO8601.string(from: now) + let home = try ClaudeLogFixture.makeUserHome( + claudeFiles: [ + "project/terminal.jsonl": ClaudeLogFixture.usageLine( + timestamp: timestamp, input: 100, output: 50, costUSD: 0.25, + messageID: "terminal", requestID: "terminal" + ), + ], + coworkSessions: [ + "group/sub/local_mine": ["workspace/session.jsonl": ClaudeLogFixture.usageLine( + timestamp: timestamp, input: 10, output: 5, costUSD: 0.05, + messageID: "mine", requestID: "mine" + )], + "group/sub/local_theirs": ["workspace/session.jsonl": ClaudeLogFixture.usageLine( + timestamp: timestamp, input: 90_000, output: 10, costUSD: 9.99, + messageID: "theirs", requestID: "theirs" + )], + ] + ) + let root = home.appendingPathComponent( + "Library/Application Support/Claude/local-agent-mode-sessions/group/sub/local_mine/.claude" + ) + let scanner = ClaudeLogUsageScanner( + environment: FakeEnvironment(), homeDirectory: { home }, + incrementalScanner: IncrementalJSONLScanner(), + coworkRootsOverride: [root] + ) + let scanned = await scanner.scan(now: now, pricing: TestPricing.bundled) + let result = try XCTUnwrap(scanned) + XCTAssertEqual(result.series.daily.count, 1) + XCTAssertEqual(result.series.daily[0].totalTokens, 165) + XCTAssertEqual(result.series.daily[0].costUSD ?? 0, 0.30, accuracy: 1e-9) + } +} + +/// Incomplete walks never own logs, and every known or observed account still constrains auth. +@MainActor +final class ClaudeCoworkPartitionTests: ClaudeAssemblyTestCase { + func testTruncatedCoworkWalkSeparatesDesktopAuthFromUnverifiedLogs() throws { + let scenarios: [(organization: String?, otherAccount: Bool, policy: ClaudeDesktopAccessPolicy)] = [ + ("org-a", true, .pinned("org-a")), + ("org-a", false, .pinned("org-a")), + (nil, false, .activeOrganization), + ] + + for scenario in scenarios { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + let configPath = "/Users/dev/.claude-work" + let findings: [ClaudeConfigDirDiscovery.Finding] = scenario.otherAccount ? [ + .init( + identityKey: "account-b|org-b", label: nil, + anchorPath: configPath, keychainLiteral: configPath + ), + ] : [] + let assembly = ProviderAccountAssembly.make( + observer: makeClaudeObserver( + claudeState("account-a", organization: scenario.organization) + ), + accountsStore: store, + preparedDiscovery: PreparedProviderAccountDiscovery( + config: .init(findings: findings), + cowork: .init(sandboxes: [], truncated: true) + ) + ) + + let card = try XCTUnwrap(assembly.claudeCards.first { $0.credential == .defaultHome }) + XCTAssertEqual(assembly.claudeCards.count, scenario.otherAccount ? 2 : 1) + XCTAssertEqual(card.coworkRootsOverride, []) + XCTAssertEqual(card.desktopAccess, scenario.policy) + let runtime = try claudeRuntime(for: assembly) + XCTAssertEqual(runtime.authStore.desktopAccessPolicy, scenario.policy) + XCTAssertEqual( + runtime.authStore.allowsUnpinnedStandardDesktopFallback, + scenario.policy == .activeOrganization + ) + } + } + + func testTruncatedCoworkWalkCountsHistoricalAndRemovedAccountOwnership() throws { + for removed in [false, true] { + let store = try makeClaudeStore( + identity: "account-a", otherIdentity: "account-b|org-b", removed: removed + ) + let assembly = ProviderAccountAssembly.make( + observer: makeClaudeObserver(claudeState("account-a")), + accountsStore: store, + preparedDiscovery: PreparedProviderAccountDiscovery( + config: .init(), cowork: .init(sandboxes: [], truncated: true) + ) + ) + + let card = try XCTUnwrap(assembly.claudeCards.first) + XCTAssertEqual(assembly.claudeCards.count, 1) + XCTAssertEqual(card.desktopAccess, .denied) + XCTAssertEqual(card.coworkRootsOverride, []) + XCTAssertFalse(try claudeRuntime(for: assembly).authStore.allowsUnpinnedStandardDesktopFallback) + } + } + + func testTruncatedCoworkObservationsQuarantineOtherUsersAndAmbiguousOrganizations() throws { + let scenarios: [(identity: String, observed: [String])] = [ + ("account-a", ["account-b|org-b"]), + ("account-a", ["account-a|org-personal", "account-a|org-work"]), + ("account-a|org-shared", ["account-b|org-shared"]), + ] + + for scenario in scenarios { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + let parts = scenario.identity.split(separator: "|").map(String.init) + let sandboxes = scenario.observed.enumerated().map { index, identity in + ClaudeCoworkDiscovery.Sandbox( + root: URL(fileURLWithPath: "/Users/dev/cowork/\(index)/.claude"), + identityKey: identity, + organization: ClaudeIdentity(identity)?.organization + ) + } + let assembly = ProviderAccountAssembly.make( + observer: makeClaudeObserver( + claudeState(parts[0], organization: parts.count == 2 ? parts[1] : nil) + ), + accountsStore: store, + preparedDiscovery: PreparedProviderAccountDiscovery( + config: .init(), cowork: .init(sandboxes: sandboxes, truncated: true) + ) + ) + + let card = try XCTUnwrap(assembly.claudeCards.first) + XCTAssertEqual(assembly.claudeCards.count, 1) + XCTAssertEqual(card.coworkRootsOverride, []) + XCTAssertEqual(card.desktopAccess, .denied) + XCTAssertEqual(store.records.count, 1, "partial discoveries cannot create cards") + let runtime = try claudeRuntime(for: assembly) + XCTAssertNil(runtime.authStore.standardDesktopOrganization) + XCTAssertFalse(runtime.authStore.allowsUnpinnedStandardDesktopFallback) + } + } +} diff --git a/Tests/OpenUsageTests/ClaudeDesktopAccountIsolationTests.swift b/Tests/OpenUsageTests/ClaudeDesktopAccountIsolationTests.swift new file mode 100644 index 000000000..4918c9152 --- /dev/null +++ b/Tests/OpenUsageTests/ClaudeDesktopAccountIsolationTests.swift @@ -0,0 +1,345 @@ +import XCTest +@testable import OpenUsage + +/// Desktop tokens name an organization, so every historical user and source still constrains auth. +@MainActor +final class ClaudeDesktopAccountIsolationTests: ClaudeAssemblyTestCase { + private func makeCoworkDiscovery(_ files: [String: String]) -> ClaudeCoworkDiscovery { + makeCoworkDiscovery( + files: files, + sandboxes: files.keys.map { URL(fileURLWithPath: $0).deletingLastPathComponent().path } + ) + } + + func testCompleteDiscoveryRequiresPinnedOwnershipAroundHistoricalAccounts() throws { + let scenarios: [(organization: String?, expected: ClaudeDesktopAccessPolicy)] = [ + (nil, .denied), + ("org-a", .pinned("org-a")), + ] + for scenario in scenarios { + let identity = scenario.organization.map { "account-a|\($0)" } ?? "account-a" + let store = try makeClaudeStore(identity: identity, otherIdentity: "account-b|org-b") + let assembly = ProviderAccountAssembly.make( + observer: makeClaudeObserver( + claudeState("account-a", organization: scenario.organization) + ), + accountsStore: store, + coworkDiscovery: makeCoworkDiscovery([:]) + ) + + XCTAssertEqual(assembly.claudeCards.map(\.identityKey), [identity]) + let runtime = try claudeRuntime(for: assembly) + XCTAssertEqual(runtime.authStore.desktopAccessPolicy, scenario.expected) + XCTAssertFalse(runtime.authStore.allowsUnpinnedStandardDesktopFallback) + } + } + + func testOrglessDefaultKeepsItsVerifiedDesktopPinWhenAnotherAccountExists() throws { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + let config = "/Users/dev/.claude-work" + let ownSandbox = "/Users/dev/cowork/personal/.claude" + let assembly = ProviderAccountAssembly.make( + observer: makeClaudeObserver(claudeState("account-a")), + accountsStore: store, + claudeDiscovery: makeDiscovery( + files: [ + config + "/.claude.json": claudeState("account-b", organization: "org-b"), + config + "/.credentials.json": + #"{"claudeAiOauth":{"accessToken":"work-token"}}"#, + ], + subdirectories: [config] + ), + coworkDiscovery: makeCoworkDiscovery([ + ownSandbox + "/.claude.json": claudeState("account-a", organization: "org-a"), + ]) + ) + + XCTAssertEqual(assembly.claudeCards.count, 2) + let card = try XCTUnwrap(assembly.claudeCards.first { $0.credential == .defaultHome }) + XCTAssertEqual(card.identityKey, "account-a") + XCTAssertEqual(card.desktopAccess, .pinned("org-a")) + XCTAssertFalse(card.allowsUnscopedKeychainFallback) + let runtime = try claudeRuntime(for: assembly) + XCTAssertEqual(runtime.authStore.desktopAccessPolicy, .pinned("org-a")) + XCTAssertEqual(runtime.authStore.standardDesktopOrganization, "org-a") + } + + func testDifferentOrglessConfigUserDisablesOrganizationPinnedDesktopAuth() throws { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + let config = "/Users/dev/.claude-work" + let assembly = ProviderAccountAssembly.make( + observer: makeClaudeObserver(claudeState("account-a", organization: "org-a")), + accountsStore: store, + claudeDiscovery: makeDiscovery( + files: [ + config + "/.claude.json": claudeState("account-b"), + config + "/.credentials.json": + #"{"claudeAiOauth":{"accessToken":"work-token"}}"#, + ], + subdirectories: [config] + ), + coworkDiscovery: makeCoworkDiscovery([:]) + ) + + XCTAssertEqual(Set(assembly.claudeCards.map(\.identityKey)), ["account-a|org-a", "account-b"]) + let card = try XCTUnwrap(assembly.claudeCards.first { $0.credential == .defaultHome }) + XCTAssertEqual(card.desktopAccess, .denied) + XCTAssertEqual(try claudeRuntime(for: assembly).authStore.desktopAccessPolicy, .denied) + } + + func testOrganizationlessUsersOnEitherSideQuarantineDesktopOwnershipAndSpend() throws { + for (defaultIdentity, coworkIdentity) in [ + ("account-b", "account-a|org-a"), ("account-a|org-a", "account-b"), + ] { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + let root = "/Users/dev/cowork/other/.claude" + let defaultParts = defaultIdentity.split(separator: "|").map(String.init) + let coworkParts = coworkIdentity.split(separator: "|").map(String.init) + let assembly = ProviderAccountAssembly.make( + observer: makeClaudeObserver(claudeState( + defaultParts[0], organization: defaultParts.count > 1 ? defaultParts[1] : nil + )), + accountsStore: store, + coworkDiscovery: makeCoworkDiscovery([ + root + "/.claude.json": claudeState( + coworkParts[0], organization: coworkParts.count > 1 ? coworkParts[1] : nil + ), + ]), + hasDesktopCredentialMaterial: { true } + ) + + let card = try XCTUnwrap(assembly.claudeCards.first) + XCTAssertEqual(assembly.claudeCards.map(\.identityKey), [defaultIdentity]) + XCTAssertEqual(store.records.map(\.identityKey), [defaultIdentity]) + XCTAssertEqual(card.coworkRootsOverride, []) + XCTAssertEqual(card.desktopAccess, .denied) + XCTAssertEqual(try claudeRuntime(for: assembly).authStore.desktopAccessPolicy, .denied) + } + } + + func testTombstonedUserSharingOrganizationDisablesPinnedDesktopAuth() throws { + let store = try makeClaudeStore( + identity: "account-a|org-shared", + otherIdentity: "account-b|org-shared", + removed: true + ) + let assembly = ProviderAccountAssembly.make( + observer: makeClaudeObserver(claudeState("account-a", organization: "org-shared")), + accountsStore: store, + coworkDiscovery: makeCoworkDiscovery([:]) + ) + + XCTAssertEqual(try XCTUnwrap(assembly.claudeCards.first).desktopAccess, .denied) + let runtime = try claudeRuntime(for: assembly) + XCTAssertNil(runtime.authStore.standardDesktopOrganization) + XCTAssertFalse(runtime.authStore.allowsUnpinnedStandardDesktopFallback) + } + + func testSingleAccountsUniqueOrganizationAliasDoesNotBlockDesktopFallback() throws { + let store = try makeClaudeStore(identity: "account-a", aliases: ["account-a|org-a"]) + let assembly = ProviderAccountAssembly.make( + observer: makeClaudeObserver(claudeState("account-a")), + accountsStore: store, + coworkDiscovery: makeCoworkDiscovery([:]) + ) + + XCTAssertEqual(assembly.claudeCards.map(\.identityKey), ["account-a"]) + let runtime = try claudeRuntime(for: assembly) + XCTAssertEqual(runtime.authStore.standardDesktopOrganization, "org-a") + XCTAssertTrue(runtime.authStore.allowsUnpinnedStandardDesktopFallback) + } + + func testDifferentDesktopUsersSharingAnOrganizationCannotBorrowTokensOrLogs() throws { + for includesVerifiedRoot in [false, true] { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + let ownRoot = "/Users/dev/cowork/current/.claude" + let otherRoot = "/Users/dev/cowork/other/.claude" + var files = [ + otherRoot + "/.claude.json": claudeState("account-b", organization: "org-shared"), + ] + if includesVerifiedRoot { + files[ownRoot + "/.claude.json"] = claudeState( + "account-a", organization: "org-shared" + ) + } + let assembly = ProviderAccountAssembly.make( + observer: makeClaudeObserver( + claudeState("account-a", organization: "org-shared") + ), + accountsStore: store, + coworkDiscovery: makeCoworkDiscovery(files), + hasDesktopCredentialMaterial: { true } + ) + + let card = try XCTUnwrap(assembly.claudeCards.first) + XCTAssertEqual(assembly.claudeCards.count, 1) + XCTAssertEqual(card.identityKey, "account-a|org-shared") + XCTAssertEqual(card.coworkRootsOverride?.map(\.path), includesVerifiedRoot ? [ownRoot] : []) + XCTAssertEqual(card.desktopAccess, .denied) + XCTAssertFalse(store.records.contains { $0.identityKey == "account-b|org-shared" }) + XCTAssertEqual(try claudeRuntime(for: assembly).authStore.desktopAccessPolicy, .denied) + } + } + + func testPersistedUserSharingCoworkOrganizationQuarantinesDesktopOnlyCard() throws { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + _ = ProviderAccountAssembly.make( + observer: makeClaudeObserver(claudeState("account-a", organization: "org-shared")), + accountsStore: store + ) + let root = "/Users/dev/cowork/other/.claude" + let assembly = ProviderAccountAssembly.make( + observer: makeClaudeObserver(claudeState("account-c", organization: "org-other")), + accountsStore: store, + coworkDiscovery: makeCoworkDiscovery([ + root + "/.claude.json": claudeState("account-b", organization: "org-shared"), + ]), + hasDesktopCredentialMaterial: { true } + ) + + XCTAssertEqual(assembly.claudeCards.map(\.identityKey), ["account-c|org-other"]) + XCTAssertFalse(store.records.contains { $0.identityKey == "account-b|org-shared" }) + } + +} + +/// Login changes hide unavailable cards while retaining account identities, names, and ownership. +@MainActor +final class ClaudeAccountLifecycleTests: ClaudeAssemblyTestCase { + func testUnresolvedDefaultLogoutKeepsVerifiedDesktopAndConfigAccounts() throws { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + _ = ProviderAccountAssembly.make( + observer: makeClaudeObserver(claudeState("account-a", organization: "org-a")), + accountsStore: store + ) + let config = "/Users/dev/.claude-work" + let sandbox = "/Users/dev/cowork/personal/.claude" + let assembly = ProviderAccountAssembly.make( + observer: makeClaudeObserver(#"{"oauthAccount":null}"#), + accountsStore: store, + claudeDiscovery: makeDiscovery( + files: [ + config + "/.claude.json": claudeState("account-b", organization: "org-b"), + config + "/.credentials.json": #"{"claudeAiOauth":{"accessToken":"work"}}"#, + ], + subdirectories: [config] + ), + coworkDiscovery: makeCoworkDiscovery( + files: [sandbox + "/.claude.json": claudeState("account-a", organization: "org-a")], + sandboxes: [sandbox] + ), + hasDesktopCredentialMaterial: { true } + ) + + XCTAssertEqual(assembly.claudeCards.count, 2) + XCTAssertEqual( + assembly.claudeCards.first { $0.id == "claude" }?.credential, + .desktop(organization: "org-a") + ) + XCTAssertEqual( + assembly.claudeCards.first { $0.identityKey == "account-b|org-b" }?.credential, + .configDir(path: config, keychainLiteral: config) + ) + XCTAssertFalse(assembly.allowsUnboundClaudeFallback) + XCTAssertNil(store.defaultBadgeHolder(family: "claude")) + } + + func testUnresolvedDefaultNeverResurrectsKnownAccountsAndPreservesNames() throws { + let defaults = makeScratchDefaults() + let store = ProviderAccountsStore(defaults: defaults) + _ = ProviderAccountAssembly.make( + observer: makeClaudeObserver(claudeState("account-a", organization: "org-a")), + accountsStore: store + ) + store.rename(cardID: "claude", to: "Personal") + let loggedOut = ProviderAccountAssembly.make( + observer: makeClaudeObserver(#"{"oauthAccount":null}"#), + accountsStore: store + ) + + XCTAssertTrue(loggedOut.claudeCards.isEmpty) + XCTAssertFalse(loggedOut.allowsUnboundClaudeFallback) + XCTAssertNil(store.defaultBadgeHolder(family: "claude")) + XCTAssertEqual(store.record(for: "claude")?.sources, []) + XCTAssertEqual(store.resolvedDisplayName(cardID: "claude"), "Personal") + XCTAssertFalse(ProviderCatalog.make(claude: loggedOut.claudeRuntimePlan).contains { + $0 is ClaudeProvider + }) + XCTAssertEqual( + ProviderAccountsStore(defaults: defaults).record(for: "claude")?.identityKey, + "account-a|org-a" + ) + } + + func testNeverIdentifiedClaudeAccountKeepsLegacySpendOnlyFallback() throws { + let assembly = ProviderAccountAssembly.make( + observer: makeClaudeObserver(#"{"oauthAccount":null}"#), + accountsStore: ProviderAccountsStore(defaults: makeScratchDefaults()) + ) + + XCTAssertTrue(assembly.allowsUnboundClaudeFallback) + let runtime = try XCTUnwrap( + ProviderCatalog.make(claude: assembly.claudeRuntimePlan) + .compactMap { $0 as? ClaudeProvider }.first + ) + XCTAssertEqual(runtime.provider.id, "claude") + XCTAssertNil(runtime.expectedIdentityKey) + } + + func testDesktopLogoutHidesHistoricalAccountsUntilCredentialsReturn() throws { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + let observer = makeClaudeObserver(claudeState("account-a", organization: "org-a")) + let root = "/Users/dev/cowork/work/.claude" + let cowork = makeCoworkDiscovery( + files: [root + "/.claude.json": claudeState("account-b", organization: "org-b")], + sandboxes: [root] + ) + let signedIn = ProviderAccountAssembly.make( + observer: observer, accountsStore: store, coworkDiscovery: cowork, + hasDesktopCredentialMaterial: { true } + ) + let desktopID = try XCTUnwrap(signedIn.claudeCards.first { $0.id != "claude" }?.id) + let signedOut = ProviderAccountAssembly.make( + observer: observer, accountsStore: store, coworkDiscovery: cowork, + hasDesktopCredentialMaterial: { false } + ) + + XCTAssertEqual(signedOut.claudeCards.map(\.id), ["claude"]) + XCTAssertNotNil(store.record(for: desktopID)) + let restored = ProviderAccountAssembly.make( + observer: observer, accountsStore: store, coworkDiscovery: cowork, + hasDesktopCredentialMaterial: { true } + ) + XCTAssertEqual(restored.claudeCards.first { $0.id != "claude" }?.id, desktopID) + } + + func testOneDefaultAndThreeDesktopOrganizationsBecomeFourIndependentCards() throws { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + let identities = [ + ("shared-user", "org-work"), + ("shared-user", "org-client"), + ("other-user", "org-other"), + ] + let roots = identities.indices.map { "/Users/dev/cowork/\($0)/.claude" } + let files = Dictionary(uniqueKeysWithValues: zip(roots, identities).map { root, identity in + (root + "/.claude.json", claudeState(identity.0, organization: identity.1)) + }) + let assembly = ProviderAccountAssembly.make( + observer: makeClaudeObserver( + claudeState("shared-user", organization: "org-personal") + ), + accountsStore: store, + coworkDiscovery: makeCoworkDiscovery(files: files, sandboxes: roots), + hasDesktopCredentialMaterial: { true } + ) + + let expected = Set(["shared-user|org-personal"] + identities.map { "\($0.0)|\($0.1)" }) + XCTAssertEqual(assembly.claudeCards.count, 4) + XCTAssertEqual(Set(assembly.claudeCards.map(\.identityKey)), expected) + XCTAssertEqual(Set(assembly.claudeCards.map(\.id)).count, 4) + XCTAssertEqual(Set( + ProviderCatalog.make(claude: assembly.claudeRuntimePlan) + .compactMap { ($0 as? ClaudeProvider)?.expectedIdentityKey } + ), expected) + } +} diff --git a/Tests/OpenUsageTests/ClaudeDesktopAuthStoreTests.swift b/Tests/OpenUsageTests/ClaudeDesktopAuthStoreTests.swift index e663be237..92f7c25f2 100644 --- a/Tests/OpenUsageTests/ClaudeDesktopAuthStoreTests.swift +++ b/Tests/OpenUsageTests/ClaudeDesktopAuthStoreTests.swift @@ -386,6 +386,96 @@ final class ClaudeDesktopAuthStoreTests: XCTestCase { XCTAssertEqual(httpClient.requests.count, 1) } + + func testDesktopOnlyScopeLoadsExactlyItsPinnedOrg() throws { + let fixture = try makeFixture( + activeOrganization: organization, + v2: [ + cacheKey(organization: organization): tokenEntry("active-org-token", expiresIn: 3_600), + cacheKey(organization: otherOrganization): tokenEntry("cowork-org-token", expiresIn: 3_600) + ] + ) + let now = now + let authStore = ClaudeAuthStore( + environment: FakeEnvironment(), + files: fixture.files, + keychain: FakeKeychain(), + desktop: fixture.store, + scope: .desktopOnly(organization: otherOrganization), + now: { now } + ) + + let load = authStore.loadCredentialSet() + + XCTAssertEqual(load.candidates.map(\.oauth.accessToken), ["cowork-org-token"]) + XCTAssertEqual(load.candidates.first?.source, .desktop) + XCTAssertEqual(load.desktopStatus, .available) + } + + func testStandardStorePinsDesktopFallbackInsteadOfFollowingAnotherCardsActiveOrg() throws { + // Desktop's ACTIVE org is another card's account (a Cowork login). The default card's + // fallback must read its own org's token, never follow the active org. + let fixture = try makeFixture( + activeOrganization: otherOrganization, + v2: [ + cacheKey(organization: organization): tokenEntry("default-org-token", expiresIn: 3_600), + cacheKey(organization: otherOrganization): tokenEntry("cowork-card-token", expiresIn: 3_600) + ] + ) + let now = now + let authStore = ClaudeAuthStore( + environment: FakeEnvironment(["CLAUDE_CONFIG_DIR": "/tmp/claude"]), + files: fixture.files, + keychain: FakeKeychain(), + desktop: fixture.store, + standardDesktopOrganization: organization, + allowsUnpinnedStandardDesktopFallback: false, + now: { now } + ) + + let load = authStore.loadCredentialSet() + + XCTAssertEqual(load.candidates.first?.oauth.accessToken, "default-org-token") + XCTAssertFalse(load.candidates.contains { $0.oauth.accessToken == "cowork-card-token" }) + XCTAssertEqual(load.desktopStatus, .available) + } + + @MainActor + func testDesktopOnlyFootprintTracksLogoutAndRestoredLoginWithoutSafeStorageAccess() async throws { + let fixture = try makeFixture( + activeOrganization: organization, + v2: [ + cacheKey(organization: organization): + tokenEntry("desktop-token", expiresIn: 3_600), + ], + requiresInteraction: true + ) + let now = now + let store = ClaudeAuthStore( + environment: FakeEnvironment(), + files: fixture.files, + keychain: FakeKeychain(), + desktop: fixture.store, + scope: .desktopOnly(organization: organization), + now: { now } + ) + let provider = ClaudeProvider(authStore: store) + let configPath = home + .appendingPathComponent("Library/Application Support/Claude/config.json").path + let encryptedConfig = try XCTUnwrap(fixture.files.files[configPath]) + + let signedIn = await provider.hasLocalCredentials() + XCTAssertTrue(signedIn) + fixture.files.files.removeValue(forKey: configPath) + let signedOut = await provider.hasLocalCredentials() + XCTAssertFalse(signedOut) + + fixture.files.files[configPath] = encryptedConfig + let restored = await provider.hasLocalCredentials() + XCTAssertTrue(restored) + XCTAssertTrue(fixture.keyReader.calls.isEmpty) + } + private func makeFixture( activeOrganization: String, v2: [String: Any], diff --git a/Tests/OpenUsageTests/ClaudeScopedAuthStoreTests.swift b/Tests/OpenUsageTests/ClaudeScopedAuthStoreTests.swift new file mode 100644 index 000000000..7f625b7f5 --- /dev/null +++ b/Tests/OpenUsageTests/ClaudeScopedAuthStoreTests.swift @@ -0,0 +1,113 @@ +import XCTest +@testable import OpenUsage + +/// Account-bound credential scopes never inherit another login, organization, or ambient token. +final class ClaudeScopedAuthStoreTests: XCTestCase { + private let scope = ClaudeCredentialScope.configDir( + path: "/Users/dev/.claude-work", keychainLiteral: "~/.claude-work" + ) + + func testScopedAndDesktopAccountsRejectUnrelatedCredentialsAndEnvironmentTokens() { + let environment = FakeEnvironment(["CLAUDE_CODE_OAUTH_TOKEN": "ambient-token"]) + let files = FakeFiles([ + "~/.claude/.credentials.json": #"{"claudeAiOauth":{"accessToken":"default-at"}}"#, + "/Users/dev/.claude-work/.credentials.json": + #"{"claudeAiOauth":{"accessToken":"work-at"}}"#, + ]) + let scoped = ClaudeAuthStore( + environment: environment, files: files, keychain: ServiceKeychain(), scope: scope + ) + let expectedService = ClaudeAuthStore.scopedKeychainServiceName( + forConfigDirLiteral: "~/.claude-work", environment: environment + ) + let own = scoped.loadCredentialSet() + XCTAssertEqual(scoped.keychainServiceCandidates(), [expectedService]) + XCTAssertEqual(own.candidates.map(\.oauth.accessToken), ["work-at"]) + XCTAssertEqual(own.desktopStatus, .notChecked) + + let desktop = ClaudeAuthStore( + environment: environment, files: files, keychain: ServiceKeychain(), + scope: .desktopOnly(organization: "org-a") + ) + XCTAssertTrue(desktop.keychainServiceCandidates().isEmpty) + let isolated = desktop.loadCredentialSet() + XCTAssertTrue(isolated.candidates.isEmpty) + XCTAssertEqual(isolated.desktopStatus, .notFound) + } + + @MainActor + func testCoworkPartitionDisablesOnlyUnpinnedFallback() throws { + let partitioned = ProviderCatalog.make(claude: ClaudeRuntimePlan(defaultCoworkRoots: [])) + let scoped = try XCTUnwrap(partitioned.compactMap { $0 as? ClaudeProvider }.first) + let unpartitioned = try XCTUnwrap( + ProviderCatalog.make().compactMap { $0 as? ClaudeProvider }.first + ) + XCTAssertFalse(scoped.authStore.allowsUnpinnedStandardDesktopFallback) + XCTAssertTrue(unpartitioned.authStore.allowsUnpinnedStandardDesktopFallback) + } + + func testEveryCredentialScopeRequiresItsExactSourceAndOrganization() { + let state = #"{"oauthAccount":{"accountUuid":"ACCOUNT-A","organizationUuid":"ORG-A"}}"# + let cases: [(ClaudeCredentialScope, [String: String], [String: String])] = [ + ( + scope, [:], + ["/Users/dev/.claude-work/.claude.json": state, "~/.claude.json": + #"{"oauthAccount":{"accountUuid":"ACCOUNT-B","organizationUuid":"ORG-B"}}"#] + ), + (.standard, ["CLAUDE_CONFIG_DIR": "/tmp/claude"], ["/tmp/claude/.claude.json": state]), + (.standard, [:], ["~/.claude.json": state]), + (.desktopOnly(organization: "ORG-A"), [:], [:]), + ] + for (scope, environment, states) in cases { + let store = ClaudeAuthStore( + environment: FakeEnvironment(environment), files: FakeFiles(states), + keychain: ServiceKeychain(), scope: scope + ) + XCTAssertTrue(store.matchesIdentity("account-a|org-a")) + XCTAssertFalse(store.matchesIdentity("account-a|org-b")) + XCTAssertFalse(store.matchesIdentity("account-a")) + } + } + + func testDefaultIdentityNeverGuessesMissingOrganizationOrMissingState() { + let path = "/tmp/claude/.claude.json" + let files = FakeFiles([path: #"{"oauthAccount":{"accountUuid":"ACCOUNT-A"}}"#]) + let store = ClaudeAuthStore( + environment: FakeEnvironment(["CLAUDE_CONFIG_DIR": "/tmp/claude"]), + files: files, keychain: ServiceKeychain() + ) + XCTAssertTrue(store.matchesIdentity("account-a")) + XCTAssertFalse(store.matchesIdentity("account-a|org-a")) + files.files.removeValue(forKey: path) + XCTAssertFalse(store.matchesIdentity("account-a")) + } + + func testConfiguredAccountCannotFallBackToAnotherAccountsBareKeychainItem() { + let environment = FakeEnvironment(["CLAUDE_CONFIG_DIR": "/tmp/claude-work"]) + let store = ClaudeAuthStore( + environment: environment, files: FakeFiles(), + keychain: ServiceKeychain(values: [ + "Claude Code-credentials": #"{"claudeAiOauth":{"accessToken":"other"}}"#, + ]), + allowsUnpinnedStandardDesktopFallback: false + ) + XCTAssertEqual(store.keychainServiceCandidates(), [ + ClaudeAuthStore.scopedKeychainServiceName( + forConfigDirLiteral: "/tmp/claude-work", environment: environment + ), + ]) + XCTAssertTrue(store.loadCredentialSet().candidates.isEmpty) + } + + @MainActor + func testAccountRuntimeScopesEveryWidgetToItsStableCardIdentifier() { + let provider = ClaudeProvider(provider: ClaudeProvider.makeProvider( + id: "claude@deadbeef", displayName: "Claude — Work" + )) + XCTAssertEqual(provider.provider.id, "claude@deadbeef") + XCTAssertEqual(provider.provider.displayName, "Claude — Work") + XCTAssertTrue(provider.widgetDescriptors.allSatisfy { + $0.id.hasPrefix("claude@deadbeef.") + }) + } +} diff --git a/Tests/OpenUsageTests/CodexAccountCloudSyncTests.swift b/Tests/OpenUsageTests/CodexAccountCloudSyncTests.swift new file mode 100644 index 000000000..a2293b421 --- /dev/null +++ b/Tests/OpenUsageTests/CodexAccountCloudSyncTests.swift @@ -0,0 +1,402 @@ +import XCTest +@testable import OpenUsage + +/// Keychain-mode Codex cannot expose an account during prompt-free launch discovery. Its first +/// successful refresh already owns the selected credential, so cloud history learns the verified +/// account without another keychain read or any cross-account history carry-forward. +@MainActor +final class CodexAccountCloudSyncTests: XCTestCase { + let instant = Date(timeIntervalSince1970: 1_800_000_000) + + func testKeychainAccountsRequireVerifiedMetadataOrJWTClaimsWithoutExtraReads() async throws { + let cases: [(name: String, credential: String, expected: String?)] = [ + ("explicit account metadata", try authJSON(accountID: " KEYCHAIN-ACCOUNT "), "keychain-account"), + ("ID-token account claim", try authJSON( + accountID: nil, idToken: makeIDToken(accountID: "JWT-ACCOUNT") + ), "jwt-account"), + ("access-token account claim", try authJSON( + accessToken: makeIDToken(accountID: "ACCESS-TOKEN-ACCOUNT"), accountID: nil + ), "access-token-account"), + ("identityless credential is quarantined", try authJSON(accountID: nil), nil) + ] + for entry in cases { + let fixture = try makeFixture(keychainAuth: entry.credential, includeHistory: true) + XCTAssertEqual(fixture.keychain.readCount, 0, "\(entry.name): launch remains prompt-free") + + let outcome = await fixture.store.refresh(providerID: "codex", force: true) + + XCTAssertEqual(outcome, .refreshed, entry.name) + XCTAssertEqual(fixture.keychain.readCount, 1, "\(entry.name): no extra keychain read") + XCTAssertEqual(fixture.provider.lastSuccessfulIdentityKey, entry.expected, entry.name) + XCTAssertEqual(fixture.cache.producedByIdentityKey(providerID: "codex"), entry.expected, entry.name) + let document = fixture.store.localHistoryDocument(deviceID: "this-mac", deviceName: "This Mac") + XCTAssertEqual(document.providers["codex"] != nil, entry.expected != nil, entry.name) + XCTAssertEqual(document.identities?["codex"], entry.expected, entry.name) + } + } + + func testRejectedFileFallbackPublishesOnlyAnIndependentlyVerifiedKeychainOwner() async throws { + for accountID in ["ACCOUNT-B", nil] as [String?] { + let scenario = accountID == nil ? "identityless fallback is quarantined" : "verified keychain wins" + let http = RoutingHTTPClient { request in + if request.headers["Authorization"] == "Bearer rejected-file-token" { + return HTTPResponse(statusCode: 401, headers: [:], body: Data()) + } + return HTTPResponse(statusCode: 200, headers: [:], body: Data("{}".utf8)) + } + let fixture = try makeFixture( + keychainAuth: authJSON(accessToken: "winning-keychain-token", accountID: accountID), + fileAuth: authJSON(accessToken: "rejected-file-token", accountID: "ACCOUNT-A"), + includeHistory: true, initialIdentity: "account-a", cachedHistory: historicalUsage(), http: http + ) + XCTAssertNotNil(fixture.provider.lastSuccessfulCredentialFingerprint, scenario) + XCTAssertEqual(fixture.keychain.readCount, 0, scenario) + + let outcome = await fixture.store.refresh(providerID: "codex", force: true) + + let expected = accountID?.lowercased() + XCTAssertEqual(outcome, .refreshed, scenario) + XCTAssertEqual(fixture.provider.lastSuccessfulIdentityKey, expected, scenario) + XCTAssertEqual(fixture.cache.producedByIdentityKey(providerID: "codex"), expected, scenario) + let document = fixture.store.localHistoryDocument(deviceID: "this-mac", deviceName: "This Mac") + XCTAssertEqual(document.identities?["codex"], expected, scenario) + XCTAssertEqual(document.providers["codex"] != nil, expected != nil, scenario) + XCTAssertEqual(fixture.keychain.readCount, 1, scenario) + } + } + + func testFirstRefreshPreservesLaunchOwnershipOnlyWhenFileCredentialLineageMatches() async throws { + let history = historicalUsage() + let cases: [(name: String, file: String?, files: [String: String]?, owner: String, + expected: String?, keychainReads: Int, mutateFile: Bool)] = [ + ("same identified file loses metadata", try authJSON(accessToken: "file-token", accountID: "ACCOUNT-A"), + nil, "account-a", "account-a", 0, true), + ("earlier identityless file cannot claim later identified account", nil, [ + "~/.config/codex/auth.json": try authJSON(accessToken: "identityless-first", accountID: nil), + "~/.codex/auth.json": try authJSON(accessToken: "identified-second", accountID: "ACCOUNT-A") + ], "account-a", nil, 0, false), + ("launch-known account survives first keychain metadata miss", nil, nil, + "launch-account", "launch-account", 1, false) + ] + for entry in cases { + let fixture = try makeFixture( + keychainAuth: authJSON(accountID: nil), fileAuth: entry.file, fileAuthCandidates: entry.files, + includeHistory: false, initialIdentity: entry.owner, cachedHistory: history + ) + let initialFingerprint = fixture.provider.lastSuccessfulCredentialFingerprint + XCTAssertEqual(initialFingerprint != nil, entry.file != nil || entry.files != nil, entry.name) + if entry.mutateFile { + fixture.files.files["/fixture-codex/auth.json"] = try authJSON(accessToken: "file-token", accountID: nil) + } + + let outcome = await fixture.store.refresh(providerID: "codex", force: true) + + XCTAssertEqual(outcome, .refreshed, entry.name) + XCTAssertNil(fixture.provider.lastSuccessfulIdentityKey, entry.name) + XCTAssertEqual(fixture.cache.producedByIdentityKey(providerID: "codex"), entry.expected, entry.name) + XCTAssertEqual(fixture.store.localSnapshots["codex"]?.usageHistory, + entry.expected == nil ? nil : history, entry.name) + XCTAssertEqual(fixture.keychain.readCount, entry.keychainReads, entry.name) + XCTAssertNotNil(fixture.provider.lastSuccessfulCredentialFingerprint, entry.name) + } + } + + func testAccountSwitchByMetadataOrAccessTokenClaimNeverInheritsCachedHistory() async throws { + let cases = [ + ("account metadata", try authJSON(accountID: "ACCOUNT-B")), + ("access-token claim", try authJSON(accessToken: makeIDToken(accountID: "ACCOUNT-B"), accountID: nil)) + ] + for (name, credential) in cases { + let fixture = try makeFixture( + keychainAuth: credential, includeHistory: false, initialIdentity: "account-a", + cachedHistory: historicalUsage() + ) + + let outcome = await fixture.store.refresh(providerID: "codex", force: true) + + XCTAssertEqual(outcome, .refreshed, name) + XCTAssertEqual(fixture.provider.lastSuccessfulIdentityKey, "account-b", name) + XCTAssertEqual(fixture.cache.producedByIdentityKey(providerID: "codex"), "account-b", name) + XCTAssertNil(fixture.store.localSnapshots["codex"]?.usageHistory, name) + } + } + + func testIdentitylessCredentialsPreserveHistoryOnlyWithVerifiedTokenLineage() async throws { + let history = historicalUsage() + let cases: [(name: String, initialAccess: String, initialRefresh: String?, nextAccess: String, + nextRefresh: String?, preserves: Bool)] = [ + ("same access token", "same-token", nil, "same-token", nil, true), + ("rotated access with shared refresh token", "old-access", "stable-refresh", "new-access", "stable-refresh", true), + ("unrelated access and refresh tokens", "account-a", "refresh-a", "account-b", "refresh-b", false) + ] + + for entry in cases { + let fixture = try makeFixture( + keychainAuth: authJSON( + accessToken: entry.initialAccess, refreshToken: entry.initialRefresh, accountID: "ACCOUNT-A" + ), + includeHistory: false, initialIdentity: "account-a", cachedHistory: history + ) + _ = await fixture.store.refresh(providerID: "codex", force: true) + let initialFingerprint = fixture.provider.lastSuccessfulCredentialFingerprint + fixture.keychain.value = try authJSON( + accessToken: entry.nextAccess, refreshToken: entry.nextRefresh, accountID: nil + ) + + let outcome = await fixture.store.refresh(providerID: "codex", force: true) + + XCTAssertEqual(outcome, .refreshed, entry.name) + XCTAssertNil(fixture.provider.lastSuccessfulIdentityKey, entry.name) + XCTAssertEqual( + fixture.cache.producedByIdentityKey(providerID: "codex"), + entry.preserves ? "account-a" : nil, entry.name + ) + XCTAssertEqual(fixture.store.localSnapshots["codex"]?.usageHistory, + entry.preserves ? history : nil, entry.name) + XCTAssertEqual( + fixture.provider.lastSuccessfulCredentialFingerprint == initialFingerprint, + entry.preserves, entry.name + ) + let document = fixture.store.localHistoryDocument(deviceID: "this-mac", deviceName: "This Mac") + XCTAssertEqual(document.providers["codex"], entry.preserves ? history : nil, entry.name) + XCTAssertEqual(document.identities?["codex"], entry.preserves ? "account-a" : nil, entry.name) + XCTAssertEqual(fixture.keychain.readCount, 2, entry.name) + } + } + + func testOwnedOAuthRotationPreservesHistoryForProactiveAndUnauthorizedRefresh() async throws { + let history = historicalUsage() + for unauthorized in [false, true] { + let scenario = unauthorized ? "401 retry" : "proactive stale-token refresh" + let http = RoutingHTTPClient { request in + if request.url == CodexUsageClient.refreshURL { + return HTTPResponse(statusCode: 200, headers: [:], body: Data( + #"{"access_token":"rotated-access","refresh_token":"rotated-refresh"}"#.utf8 + )) + } + if unauthorized, request.headers["Authorization"] == "Bearer rejected-access" { + return HTTPResponse(statusCode: 401, headers: [:], body: Data()) + } + return HTTPResponse(statusCode: 200, headers: [:], body: Data("{}".utf8)) + } + let fixture = try makeFixture( + keychainAuth: authJSON( + accessToken: "original-access", refreshToken: "original-refresh", accountID: "ACCOUNT-A" + ), + includeHistory: false, initialIdentity: "account-a", cachedHistory: history, http: http + ) + _ = await fixture.store.refresh(providerID: "codex", force: true) + let initialFingerprint = fixture.provider.lastSuccessfulCredentialFingerprint + fixture.keychain.value = try authJSON( + accessToken: unauthorized ? "rejected-access" : "original-access", + refreshToken: "original-refresh", accountID: nil, + lastRefresh: unauthorized ? nil : OpenUsageISO8601.string( + from: instant.addingTimeInterval(-9 * 24 * 60 * 60) + ) + ) + + let outcome = await fixture.store.refresh(providerID: "codex", force: true) + + XCTAssertEqual(outcome, .refreshed, scenario) + XCTAssertTrue(http.requests.contains { $0.url == CodexUsageClient.refreshURL }, scenario) + XCTAssertNil(fixture.provider.lastSuccessfulIdentityKey, scenario) + XCTAssertEqual(fixture.provider.lastSuccessfulCredentialFingerprint, initialFingerprint, scenario) + XCTAssertEqual(fixture.cache.producedByIdentityKey(providerID: "codex"), "account-a", scenario) + XCTAssertEqual(fixture.store.localSnapshots["codex"]?.usageHistory, history, scenario) + } + } + + func testReloadedUnrelatedIdentitylessCredentialBreaksTrustedLineage() async throws { + let history = historicalUsage() + let fixture = try makeFixture( + keychainAuth: authJSON( + accessToken: "account-a-access", + refreshToken: "account-a-refresh", + accountID: "ACCOUNT-A" + ), + includeHistory: false, + initialIdentity: "account-a", + cachedHistory: history + ) + _ = await fixture.store.refresh(providerID: "codex", force: true) + + let replacedCredential = try authJSON( + accessToken: "different-account-access", + refreshToken: "different-account-refresh", + accountID: nil + ) + fixture.keychain.value = replacedCredential + fixture.keychain.queuedReadValues = [ + try authJSON( + accessToken: "account-a-access", + refreshToken: "account-a-refresh", + accountID: nil, + lastRefresh: OpenUsageISO8601.string(from: instant.addingTimeInterval(-9 * 24 * 60 * 60)) + ), + replacedCredential + ] + + let outcome = await fixture.store.refresh(providerID: "codex", force: true) + + XCTAssertEqual(outcome, .refreshed) + XCTAssertNil(fixture.provider.lastSuccessfulIdentityKey) + XCTAssertNil(fixture.cache.producedByIdentityKey(providerID: "codex")) + XCTAssertNil(fixture.store.localSnapshots["codex"]?.usageHistory) + XCTAssertNil( + fixture.store.localHistoryDocument(deviceID: "this-mac", deviceName: "This Mac") + .providers["codex"] + ) + } + +} + +extension CodexAccountCloudSyncTests { + struct Fixture { + var provider: CodexProvider + var store: WidgetDataStore + var cache: ProviderSnapshotCache + var keychain: CountingCodexCloudKeychain + var files: FakeFiles + } + + func historicalUsage() -> ProviderUsageHistory { + ProviderUsageHistory( + series: DailyUsageSeries(daily: [ + DailyUsageEntry(date: "2000-01-01", totalTokens: 987_654_321, costUSD: 1234) + ]), + modelUsage: nil, + unknownModelsByDay: [:] + ) + } + + func makeFixture( + keychainAuth: String, + fileAuth: String? = nil, + fileAuthCandidates: [String: String]? = nil, + includeHistory: Bool, + initialIdentity: String? = nil, + cachedHistory: ProviderUsageHistory? = nil, + http: (any HTTPClient)? = nil + ) throws -> Fixture { + let now = instant + let timestamp = OpenUsageISO8601.string(from: now) + let home = includeHistory + ? try CodexLogFixture.makeHome(files: [ + "sessions/rollout.jsonl": [ + CodexLogFixture.turnContext(timestamp: timestamp, model: "gpt-5.2"), + CodexLogFixture.tokenCount( + timestamp: timestamp, + last: CodexLogFixture.usage(input: 100, output: 50) + ) + ].joined(separator: "\n") + ]) + : nil + if let home { + addTeardownBlock { try? FileManager.default.removeItem(at: home) } + } + let keychain = CountingCodexCloudKeychain(value: keychainAuth) + let files = FakeFiles(fileAuthCandidates ?? fileAuth.map { ["/fixture-codex/auth.json": $0] } ?? [:]) + let client = http ?? FakeHTTPClient(response: HTTPResponse( + statusCode: 200, + headers: [:], + body: Data("{}".utf8) + )) + let provider = CodexProvider( + authStore: CodexAuthStore( + environment: FakeEnvironment(fileAuthCandidates == nil ? ["CODEX_HOME": "/fixture-codex"] : [:]), + files: files, + keychain: keychain, + now: { now } + ), + usageClient: CodexUsageClient(http: client), + logUsageScanner: CodexLogFixture.scanner(home: home), + now: { now }, + pricing: { TestPricing.bundled } + ) + let defaults = try makeDefaults() + let cache = ProviderSnapshotCache( + userDefaults: defaults, + storageKey: "codex-cloud-snapshots", + ttl: 600, + now: { now } + ) + if let cachedHistory { + cache.store( + ProviderSnapshot( + providerID: "codex", + displayName: "Codex", + lines: [.progress(label: "Session", used: 10, limit: 100, format: .percent)], + refreshedAt: now, + usageHistory: cachedHistory + ), + producedByIdentityKey: initialIdentity + ) + } + let store = WidgetDataStore( + registry: WidgetRegistry.from([provider]), + providers: [provider], + cache: cache, + defaults: defaults, + providerIdentityKeys: initialIdentity.map { ["codex": $0] } ?? [:] + ) + return Fixture(provider: provider, store: store, cache: cache, keychain: keychain, files: files) + } + + func authJSON( + accessToken: String = "keychain-token", + refreshToken: String? = nil, + accountID: String?, + idToken: String? = nil, + lastRefresh: String? = nil + ) throws -> String { + var tokens: [String: Any] = ["access_token": accessToken] + if let refreshToken { tokens["refresh_token"] = refreshToken } + if let accountID { tokens["account_id"] = accountID } + if let idToken { tokens["id_token"] = idToken } + var auth: [String: Any] = ["tokens": tokens] + if let lastRefresh { auth["last_refresh"] = lastRefresh } + let data = try JSONSerialization.data(withJSONObject: auth) + return String(decoding: data, as: UTF8.self) + } + + func makeIDToken(accountID: String) throws -> String { + let data = try JSONSerialization.data(withJSONObject: [ + "https://api.openai.com/auth": ["chatgpt_account_id": accountID] + ]) + let payload = data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return "header.\(payload).signature" + } + + private func makeDefaults() throws -> UserDefaults { + let suiteName = "OpenUsageTests.CodexAccountCloudSync.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + addTeardownBlock { defaults.removePersistentDomain(forName: suiteName) } + return defaults + } +} + +final class CountingCodexCloudKeychain: KeychainAccessing, @unchecked Sendable { + var value: String? + var queuedReadValues: [String] = [] + private(set) var readCount = 0 + + init(value: String?) { + self.value = value + } + + func readGenericPassword(service: String) throws -> String? { + readCount += 1 + if !queuedReadValues.isEmpty { + return queuedReadValues.removeFirst() + } + return value + } + + func writeGenericPassword(service: String, value: String) throws { + self.value = value + } +} diff --git a/Tests/OpenUsageTests/FirstRunSeederTests.swift b/Tests/OpenUsageTests/FirstRunSeederTests.swift index ff14ab39e..8e5f0174f 100644 --- a/Tests/OpenUsageTests/FirstRunSeederTests.swift +++ b/Tests/OpenUsageTests/FirstRunSeederTests.swift @@ -95,6 +95,42 @@ final class FirstRunSeederTests: XCTestCase { XCTAssertEqual(enablement.enabledIDs, ["claude", "cursor"]) } + func testCancelledFirstRunResumesAdditivelyWithoutUndoingUserChoices() async throws { + let defaults = makeDefaults("cancelled-first-run-resume") + let staleEnablement = ProviderEnablementStore(defaults: defaults) + let onboarding = OnboardingStore(defaults: makeDefaults("cancelled-first-run-onboarding")) + let providers: [ProviderRuntime] = [ + stub("claude", hasCredentials: true), + stub("codex", hasCredentials: true), + stub("cursor", hasCredentials: false), + stub("grok", hasCredentials: true), + stub("devin", hasCredentials: true), + stub("windsurf", hasCredentials: false), + ] + let oldTask = try XCTUnwrap(FirstRunSeeder.seedIfNeeded( + isFreshInstall: true, providers: providers, enablement: staleEnablement, onboarding: onboarding + )) + + let replacementEnablement = ProviderEnablementStore(defaults: defaults) + replacementEnablement.setEnabled(false, for: "codex") + replacementEnablement.setEnabled(true, for: "windsurf") + replacementEnablement.setEnabled(true, for: "grok") + replacementEnablement.setEnabled(false, for: "grok") + oldTask.cancel() + await oldTask.value + XCTAssertEqual(replacementEnablement.enabledIDs, ["claude", "cursor", "windsurf"]) + + let resumedTask = try XCTUnwrap(NewProviderSeeder.reconcileIfNeeded( + providers: providers, enablement: replacementEnablement + )) + await resumedTask.value + + XCTAssertEqual(replacementEnablement.enabledIDs, ["claude", "cursor", "devin", "windsurf"]) + XCTAssertFalse(replacementEnablement.isEnabled("codex")) + XCTAssertFalse(replacementEnablement.isEnabled("grok")) + XCTAssertTrue(replacementEnablement.pendingDetectionIDs.isEmpty) + } + // MARK: - Reset All reseed func testReseedOverwritesCurrentChoicesWithDetectedSet() async { diff --git a/Tests/OpenUsageTests/ICloudUsageSyncStoreTests.swift b/Tests/OpenUsageTests/ICloudUsageSyncStoreTests.swift index 4377b4ef6..9c32c1b08 100644 --- a/Tests/OpenUsageTests/ICloudUsageSyncStoreTests.swift +++ b/Tests/OpenUsageTests/ICloudUsageSyncStoreTests.swift @@ -6,7 +6,7 @@ final class ICloudUsageSyncStoreTests: XCTestCase { func testEnableWritesLoadsAndDisableDeletesThisMac() async throws { let defaults = makeDefaults("enable-disable") let fileStore = RecordingHistoryFileStore() - let sync = makeSync(defaults: defaults, fileStore: fileStore, writeDebounce: .milliseconds(10)) + let sync = makeSync(defaults, fileStore: fileStore, writeDebounce: .milliseconds(10)) sync.enabled = true try await waitUntil { await fileStore.writeCount == 1 && sync.displayedDocuments.count == 1 } @@ -22,7 +22,7 @@ final class ICloudUsageSyncStoreTests: XCTestCase { func testAdjacentHistoryChangesDebounceToOneWrite() async throws { let defaults = makeDefaults("debounce") let fileStore = RecordingHistoryFileStore() - let sync = makeSync(defaults: defaults, fileStore: fileStore, writeDebounce: .milliseconds(20)) + let sync = makeSync(defaults, fileStore: fileStore, writeDebounce: .milliseconds(20)) sync.enabled = true try await waitUntil { await fileStore.writeCount == 1 } @@ -36,10 +36,134 @@ final class ICloudUsageSyncStoreTests: XCTestCase { XCTAssertEqual(writeCount, 2) } + func testAccountGraphShutdownCancelsDebouncedWriteWithoutDisablingOrDeleting() async throws { + let defaults = makeDefaults("account-graph-shutdown-debounce") + let fileStore = RecordingHistoryFileStore() + let sync = makeSync(defaults, fileStore: fileStore, writeDebounce: .milliseconds(30)) + sync.enabled = true + try await waitUntil { await fileStore.writeCount == 1 && !sync.isSyncing } + + sync.scheduleWrite() + sync.shutdownForAccountGraphReload() + sync.scheduleWrite() + try await Task.sleep(for: .milliseconds(80)) + + let writeCount = await fileStore.writeCount + let documents = await fileStore.documents + let deletedDeviceIDs = await fileStore.deletedDeviceIDs + XCTAssertEqual(writeCount, 1, "the retired graph cannot publish a queued or newly scheduled write") + XCTAssertEqual(documents.map(\.deviceID), [sync.deviceID], "graph reload keeps this Mac's existing file") + XCTAssertTrue(deletedDeviceIDs.isEmpty, "graph reload is not the same as opting out of iCloud") + XCTAssertTrue(sync.enabled) + XCTAssertTrue(defaults.bool(forKey: "openusage.icloudSync.enabled.v1")) + } + + func testAccountGraphShutdownCancelsInFlightWriteWithoutReplacingExistingFile() async throws { + let defaults = makeDefaults("account-graph-shutdown-in-flight") + let deviceIDStore = MemoryDeviceIDStore() + let expectedDeviceID = UUID().uuidString.lowercased() + try deviceIDStore.writeDeviceID(expectedDeviceID) + let existingDocument = UsageHistoryDocument( + deviceID: expectedDeviceID, + deviceName: "Previous Account Graph", + updatedAt: Date(timeIntervalSince1970: 123), + providers: [:] + ) + let fileStore = RecordingHistoryFileStore(seedDocuments: [existingDocument]) + let sync = makeSync(defaults, fileStore: fileStore, deviceIDStore: deviceIDStore) + + await fileStore.holdNextWrite() + sync.enabled = true + try await waitUntil { await fileStore.writeInFlight } + + sync.shutdownForAccountGraphReload() + await fileStore.releaseWrite() + try await waitUntil { !(await fileStore.writeInFlight) && !sync.isSyncing } + + let documents = await fileStore.documents + let deletedDeviceIDs = await fileStore.deletedDeviceIDs + XCTAssertEqual(documents, [existingDocument], "a canceled stale graph must not replace the current device file") + XCTAssertTrue(deletedDeviceIDs.isEmpty, "only the replacement graph owns subsequent writes") + XCTAssertTrue(sync.enabled) + XCTAssertNil(sync.serviceError, "cancellation is an intentional shutdown, not an iCloud failure") + } + + func testCanceledCoordinatedAccessorCannotOverwriteReplacementAccountDocument() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("openusage-sync-coordination-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: directory) } + let documentURL = directory.appendingPathComponent("device.json") + let currentAccount = Data("replacement-account".utf8) + try currentAccount.write(to: documentURL) + + let fileStore = ICloudUsageHistoryFileStore() + let retiredWriter = Task.detached { + try await fileStore.coordinatedWrite( + Data("retired-account".utf8), + to: documentURL, + beforeWriting: { + withUnsafeCurrentTask { task in task?.cancel() } + } + ) + } + + do { + try await retiredWriter.value + XCTFail("a graph retired while waiting for file coordination must not commit") + } catch is CancellationError { + // Expected: cancellation is checked after the coordinated accessor begins. + } + XCTAssertEqual(try Data(contentsOf: documentURL), currentAccount) + } + + func testDisableImmediatelyBeforeGraphReloadStillDeletesThisMac() async throws { + let defaults = makeDefaults("disable-immediate-account-graph-reload") + let fileStore = RecordingHistoryFileStore() + let deviceIDStore = MemoryDeviceIDStore() + let retired = makeSync(defaults, fileStore: fileStore, deviceIDStore: deviceIDStore) + retired.enabled = true + try await waitUntil { await fileStore.writeCount == 1 && !retired.isSyncing } + + retired.enabled = false + retired.shutdownForAccountGraphReload() + let replacement = makeSync(defaults, fileStore: fileStore, deviceIDStore: deviceIDStore) + + XCTAssertFalse(replacement.enabled) + try await waitUntil { await fileStore.deletedDeviceIDs.contains(retired.deviceID) } + let documents = await fileStore.documents + XCTAssertFalse(documents.contains { $0.deviceID == retired.deviceID }) + } + + func testReenabledReplacementKeepsThisMacAfterInterruptedDisable() async throws { + let defaults = makeDefaults("disable-reload-reenable") + let fileStore = RecordingHistoryFileStore() + let deviceIDStore = MemoryDeviceIDStore() + let retired = makeSync(defaults, fileStore: fileStore, deviceIDStore: deviceIDStore) + retired.enabled = true + try await waitUntil { await fileStore.writeCount == 1 && !retired.isSyncing } + + await fileStore.holdNextDelete() + retired.enabled = false + retired.shutdownForAccountGraphReload() + try await waitUntil { await fileStore.deleteIsHeld } + let replacement = makeSync(defaults, fileStore: fileStore, deviceIDStore: deviceIDStore) + replacement.enabled = true + + for _ in 0..<10 { await Task.yield() } + let writesBeforeDeleteCompletes = await fileStore.writeCount + XCTAssertEqual(writesBeforeDeleteCompletes, 1, "replacement waits for the retired graph's deletion") + await fileStore.releaseDelete() + try await waitUntil { await fileStore.writeCount == 2 && !replacement.isSyncing } + let documents = await fileStore.documents + XCTAssertEqual(documents.map(\.deviceID), [replacement.deviceID]) + XCTAssertTrue(replacement.enabled) + } + func testDisableDeletesWriteThatWasAlreadyInFlight() async throws { let defaults = makeDefaults("disable-in-flight-write") let fileStore = RecordingHistoryFileStore() - let sync = makeSync(defaults: defaults, fileStore: fileStore) + let sync = makeSync(defaults, fileStore: fileStore) // Hold the enable write open so disable can race it deliberately, instead of hoping an // 80ms sleep is still in flight when the test flips the toggle on a loaded CI runner. @@ -66,7 +190,7 @@ final class ICloudUsageSyncStoreTests: XCTestCase { func testUnavailableStoreSurfacesFriendlyError() async throws { let defaults = makeDefaults("unavailable") let fileStore = RecordingHistoryFileStore(unavailable: true) - let sync = makeSync(defaults: defaults, fileStore: fileStore) + let sync = makeSync(defaults, fileStore: fileStore) sync.enabled = true try await waitUntil { sync.serviceError != nil && !sync.isSyncing } @@ -87,7 +211,7 @@ final class ICloudUsageSyncStoreTests: XCTestCase { seedDocuments: [peer], invalidFileMessages: ["broken.json: invalid value"] ) - let sync = makeSync(defaults: defaults, fileStore: fileStore) + let sync = makeSync(defaults, fileStore: fileStore) sync.enabled = true try await waitUntil { sync.invalidFileMessages.count == 1 } @@ -99,7 +223,7 @@ final class ICloudUsageSyncStoreTests: XCTestCase { func testBackgroundReloadShowsSyncActivity() async throws { let defaults = makeDefaults("background-sync-activity") let fileStore = RecordingHistoryFileStore() - let sync = makeSync(defaults: defaults, fileStore: fileStore, writeDebounce: .milliseconds(10)) + let sync = makeSync(defaults, fileStore: fileStore, writeDebounce: .milliseconds(10)) sync.enabled = true try await waitUntil { @@ -125,9 +249,9 @@ final class ICloudUsageSyncStoreTests: XCTestCase { firstDefaults.set(expectedID, forKey: "openusage.icloudSync.deviceID.v1") let deviceIDStore = MemoryDeviceIDStore() - let first = makeSync(defaults: firstDefaults, fileStore: RecordingHistoryFileStore(), deviceIDStore: deviceIDStore) + let first = makeSync(firstDefaults, deviceIDStore: deviceIDStore) let resetDefaults = makeDefaults("identity-after-reset") - let afterReset = makeSync(defaults: resetDefaults, fileStore: RecordingHistoryFileStore(), deviceIDStore: deviceIDStore) + let afterReset = makeSync(resetDefaults, deviceIDStore: deviceIDStore) XCTAssertEqual(first.deviceID, expectedID) XCTAssertEqual(afterReset.deviceID, expectedID) @@ -153,19 +277,18 @@ final class ICloudUsageSyncStoreTests: XCTestCase { } private func makeSync( - defaults: UserDefaults, - fileStore: RecordingHistoryFileStore, + _ defaults: UserDefaults, + fileStore: RecordingHistoryFileStore = RecordingHistoryFileStore(), deviceIDStore: MemoryDeviceIDStore = MemoryDeviceIDStore(), writeDebounce: Duration = .seconds(3) ) -> ICloudUsageSyncStore { - let dataStore = WidgetDataStore( - registry: WidgetRegistry(providers: [], descriptors: []), - providers: [], - cache: ProviderSnapshotCache(userDefaults: defaults, storageKey: "snapshots"), - defaults: defaults - ) - return ICloudUsageSyncStore( - dataStore: dataStore, + ICloudUsageSyncStore( + dataStore: WidgetDataStore( + registry: WidgetRegistry(providers: [], descriptors: []), + providers: [], + cache: ProviderSnapshotCache(userDefaults: defaults, storageKey: "snapshots"), + defaults: defaults + ), defaults: defaults, fileStore: fileStore, deviceIDStore: deviceIDStore, @@ -217,8 +340,11 @@ private actor RecordingHistoryFileStore: UsageHistoryFileStoring { private(set) var writeInFlight = false private var shouldHoldNextLoad = false private var shouldHoldNextWrite = false + private var shouldHoldNextDelete = false private var loadGate: CheckedContinuation? private var writeGate: CheckedContinuation? + private var deleteGate: CheckedContinuation? + var deleteIsHeld: Bool { deleteGate != nil } init( unavailable: Bool = false, @@ -245,6 +371,7 @@ private actor RecordingHistoryFileStore: UsageHistoryFileStoring { func write(_ document: UsageHistoryDocument) async throws { if unavailable { throw ICloudUsageSyncError.unavailable } + try Task.checkCancellation() writeCount += 1 writeInFlight = true defer { writeInFlight = false } @@ -254,12 +381,17 @@ private actor RecordingHistoryFileStore: UsageHistoryFileStoring { writeGate = continuation } } + try Task.checkCancellation() documents.removeAll { $0.deviceID == document.deviceID } documents.append(document) } func delete(deviceID: String) async throws { if unavailable { throw ICloudUsageSyncError.unavailable } + if shouldHoldNextDelete { + shouldHoldNextDelete = false + await withCheckedContinuation { deleteGate = $0 } + } deletedDeviceIDs.append(deviceID) documents.removeAll { $0.deviceID == deviceID } } @@ -272,6 +404,9 @@ private actor RecordingHistoryFileStore: UsageHistoryFileStoring { shouldHoldNextWrite = true } + func holdNextDelete() { shouldHoldNextDelete = true } + func releaseDelete() { deleteGate?.resume(); deleteGate = nil } + func releaseLoad() { loadGate?.resume() loadGate = nil diff --git a/Tests/OpenUsageTests/LayoutAccountTests.swift b/Tests/OpenUsageTests/LayoutAccountTests.swift new file mode 100644 index 000000000..f5dc5ba70 --- /dev/null +++ b/Tests/OpenUsageTests/LayoutAccountTests.swift @@ -0,0 +1,106 @@ +import XCTest +@testable import OpenUsage + +@MainActor +final class LayoutAccountTests: XCTestCase { + private func accountLayoutRegistry(_ providers: [Provider], suffixes: [String]) -> WidgetRegistry { + WidgetRegistry(providers: providers, descriptors: providers.flatMap { provider in + suffixes.map { suffix in + let id = "\(provider.id).\(suffix)" + return WidgetDescriptor( + id: id, providerID: provider.id, metricLabel: id, + sample: WidgetData(title: id, icon: provider.icon, kind: .percent, used: 0, limit: 100) + ) + } + }) + } + + func testProviderReorderPreservesAnAbsentAccountCardSlot() { + let defaults = makeDefaults("ReorderAbsentAccountCard") + let storageKey = "layout" + let hidden = "claude@hidden" + let persistence = LayoutPersistence(defaults: defaults, storageKey: storageKey) + persistence.saveProviderOrder(["claude", hidden, "cursor"]) + let store = LayoutStore(registry: .mock, defaults: defaults, storageKey: storageKey) + + XCTAssertTrue(store.reorderProvider(dragged: "cursor", target: "claude")) + XCTAssertEqual(Array(store.providerOrder.prefix(3)), ["cursor", hidden, "claude"]) + XCTAssertEqual(persistence.loadProviderOrder()?.contains(hidden), true) + } + + func testTranslatedDefaultsSeedAnAccountCardTheFirstTimeItAppears() { + let claude = Provider(id: "claude", displayName: "Claude", icon: .providerMark("claude")) + let work = Provider(id: "claude@work", displayName: "Claude — Work", icon: .providerMark("claude")) + let suffixes = ["session", "weekly", "fable", "sonnet"] + let registry = accountLayoutRegistry([claude, work], suffixes: suffixes) + let defaults = makeDefaults("AccountCardSeeding") + let familyDefaults = suffixes.dropLast().map { "claude.\($0)" } + saveStored(familyDefaults.map { PlacedWidget(descriptorID: $0) }, forKey: "layout", in: defaults) + defaults.set(suffixes.map { "claude.\($0)" }, forKey: "layout.seededDefaults") + + let store = LayoutStore( + registry: registry, defaults: defaults, storageKey: "layout", defaultMetricIDs: familyDefaults, + defaultExpandedMetricIDs: ["claude.sonnet"] + ) + + for suffix in suffixes.dropLast() { + XCTAssertTrue(store.isMetricEnabled("claude@work.\(suffix)")) + } + XCTAssertFalse(store.isPinned("claude@work.fable")) + XCTAssertFalse(store.expandedMetricIDs.contains("claude@work.fable")) + XCTAssertEqual(store.orderedSupportedMetrics(for: "claude@work").map(\.id), suffixes.map { "claude@work.\($0)" }) + XCTAssertFalse(store.isMetricEnabled("claude.sonnet")) + XCTAssertTrue(store.defaultExpandedOnEnableIDs.contains("claude@work.sonnet")) + } + + func testAccountCustomizationSurvivesAbsenceAnUnrelatedEditAndGraphRebuild() { + let claude = Provider(id: "claude", displayName: "Claude", icon: .providerMark("claude")) + let work = Provider(id: "claude@ab12cd34", displayName: "Claude — Work", icon: .providerMark("claude")) + func registry(includingWork: Bool) -> WidgetRegistry { + self.accountLayoutRegistry(includingWork ? [claude, work] : [claude], suffixes: ["session", "weekly"]) + } + let defaults = makeDefaults("AccountGraphRebuild") + func load(includingWork: Bool) -> LayoutStore { + LayoutStore( + registry: registry(includingWork: includingWork), defaults: defaults, storageKey: "layout", + defaultMetricIDs: ["claude.session", "claude.weekly"], defaultPinnedMetricIDs: [], + defaultExpandedMetricIDs: ["claude.weekly"] + ) + } + + let first = load(includingWork: true) + first.setMetricEnabled("claude@ab12cd34.weekly", false) + first.setPinned(true, for: "claude@ab12cd34.session") + XCTAssertTrue(first.setProviderExpanded(true, for: "claude@ab12cd34")) + XCTAssertTrue(first.reorderProvider(dragged: "claude@ab12cd34", target: "claude")) + + let temporarilyAbsent = load(includingWork: false) + XCTAssertFalse(temporarilyAbsent.isMetricEnabled("claude@ab12cd34.session")) + XCTAssertTrue(temporarilyAbsent.pinnedMetricIDs.contains("claude@ab12cd34.session")) + XCTAssertTrue(temporarilyAbsent.expandedProviderIDs.contains("claude@ab12cd34")) + XCTAssertEqual(temporarilyAbsent.providerOrder.first, "claude@ab12cd34") + + temporarilyAbsent.setMetricEnabled("claude.weekly", false) + temporarilyAbsent.setPinned(true, for: "claude.session") + + let restored = load(includingWork: true) + XCTAssertTrue(restored.isMetricEnabled("claude@ab12cd34.session")) + XCTAssertFalse(restored.isMetricEnabled("claude@ab12cd34.weekly")) + XCTAssertTrue(restored.isPinned("claude@ab12cd34.session")) + XCTAssertTrue(restored.isProviderExpanded("claude@ab12cd34")) + XCTAssertEqual(restored.orderedProviderIDs().first, "claude@ab12cd34") + XCTAssertFalse(restored.isMetricEnabled("claude.weekly")) + XCTAssertTrue(restored.isPinned("claude.session")) + } + + private func makeDefaults(_ name: String) -> UserDefaults { + let suiteName = "OpenUsageTests.LayoutAccount.\(name).\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } + + private func saveStored(_ value: T, forKey key: String, in defaults: UserDefaults) { + defaults.set(try! JSONEncoder().encode(value), forKey: key) + } +} diff --git a/Tests/OpenUsageTests/LayoutStoreTests.swift b/Tests/OpenUsageTests/LayoutStoreTests.swift index 7dd004886..08219a725 100644 --- a/Tests/OpenUsageTests/LayoutStoreTests.swift +++ b/Tests/OpenUsageTests/LayoutStoreTests.swift @@ -1010,14 +1010,14 @@ final class LayoutStoreTests: XCTestCase { XCTAssertTrue(store.expandedMetricIDs.contains("claude.weekly")) } - func testInvalidPersistedExpandedIDsAreDropped() { + func testUnknownPersistedExpandedIDsAreRetainedAsInvisibleTombstones() { let defaults = makeDefaults("InvalidExpand") saveStored([PlacedWidget(descriptorID: "claude.session")], forKey: "layout", in: defaults) defaults.set(["claude.session", "missing.metric"], forKey: "layout.expandedMetrics") let store = LayoutStore(registry: .mock, defaults: defaults, storageKey: "layout") XCTAssertTrue(store.expandedMetricIDs.contains("claude.session")) - XCTAssertFalse(store.expandedMetricIDs.contains("missing.metric")) + XCTAssertTrue(store.expandedMetricIDs.contains("missing.metric")) } func testDisplayGroupsPartitionEnabledMetrics() { diff --git a/Tests/OpenUsageTests/LocalUsageAPITests.swift b/Tests/OpenUsageTests/LocalUsageAPITests.swift index 74b86f436..86fad798e 100644 --- a/Tests/OpenUsageTests/LocalUsageAPITests.swift +++ b/Tests/OpenUsageTests/LocalUsageAPITests.swift @@ -129,6 +129,21 @@ final class LocalUsageAPITests: XCTestCase { XCTAssertEqual(Set(providers.keys), ["claude", "claude@ab12cd34"]) } + func testResolvedTitlesOverrideSnapshotDisplayNamesAtTheBoundary() throws { + // Snapshots always store the derived name; the boundary re-resolves against the account + // registry so API/CLI output carries renames without ever persisting them. + let state = makeState().resolvingDisplayNames(["claude": "Claude Team"]) + + let response = LocalUsageAPI.respond(method: "GET", path: "/v1/usage", state: state) + let array = try XCTUnwrap(try json(response.body) as? [[String: Any]]) + XCTAssertEqual(array.first { $0["providerId"] as? String == "claude" }?["displayName"] as? String, "Claude Team") + XCTAssertEqual( + array.first { $0["providerId"] as? String == "cursor" }?["displayName"] as? String, + "Cursor", + "cards without a record keep their baked name" + ) + } + func testMethodAndRouteErrors() throws { let state = makeState() diff --git a/Tests/OpenUsageTests/MenuBarContentTests.swift b/Tests/OpenUsageTests/MenuBarContentTests.swift index 80f78df9d..1a15eccc0 100644 --- a/Tests/OpenUsageTests/MenuBarContentTests.swift +++ b/Tests/OpenUsageTests/MenuBarContentTests.swift @@ -87,6 +87,17 @@ final class MenuBarContentTests: XCTestCase { XCTAssertEqual(content.accessibilityText, "A Session 41%, Weekly 12%") } + func testAccessibilityTextUsesTheResolvedTitle() { + // The VoiceOver summary is a human-facing name, so it goes through the caller's resolver + // (the account registry) instead of the baked provider name. + let content = MenuBarContentBuilder.build( + groups: [group("a", percent("a.m1", "Session", 41))], + data: { $0.sample }, + title: { _ in "Claude Team" } + ) + XCTAssertEqual(content.accessibilityText, "Claude Team Session 41%") + } + func testTrayLabelsShortenLongTimeWindows() { let content = MenuBarContentBuilder.build( groups: [group("a", percent("a.today", "Today", 5), percent("a.month", "Last 30 Days", 80))], diff --git a/Tests/OpenUsageTests/NewProviderSeederTests.swift b/Tests/OpenUsageTests/NewProviderSeederTests.swift index b3ae9b8de..a140f6eca 100644 --- a/Tests/OpenUsageTests/NewProviderSeederTests.swift +++ b/Tests/OpenUsageTests/NewProviderSeederTests.swift @@ -102,6 +102,46 @@ final class NewProviderSeederTests: XCTestCase { XCTAssertEqual(enableCallbacks, ["windsurf"], "the seeder must not fire a second enable") } + func testCancelledDetectionWaitsForAccountReturnAndPreservesUserChoices() async throws { + let defaults = makeDefaults("cancelled-graph-resume") + let staleEnablement = ProviderEnablementStore(defaults: defaults) + staleEnablement.seedEnabledProviders(["claude"]) + staleEnablement.registerKnownProviders(["claude", "codex"]) + let returningAccount = probe("claude@deadbeef", hasCredentials: true) + let userOwned = probe("windsurf", hasCredentials: true) + let providers: [ProviderRuntime] = [ + probe("claude", hasCredentials: true), probe("codex", hasCredentials: false), returningAccount, userOwned, + ] + let oldTask = try XCTUnwrap(NewProviderSeeder.reconcileIfNeeded( + providers: providers, enablement: staleEnablement + )) + + let replacementEnablement = ProviderEnablementStore(defaults: defaults) + replacementEnablement.setEnabled(true, for: "codex") + replacementEnablement.setEnabled(true, for: "windsurf") + replacementEnablement.setEnabled(false, for: "windsurf") + oldTask.cancel() + await oldTask.value + + XCTAssertEqual(replacementEnablement.enabledIDs, ["claude", "codex"]) + XCTAssertEqual(replacementEnablement.pendingDetectionIDs, ["claude@deadbeef"]) + XCTAssertNil(NewProviderSeeder.reconcileIfNeeded( + providers: providers.filter { $0.provider.id != "claude@deadbeef" }, enablement: replacementEnablement + )) + XCTAssertEqual(replacementEnablement.pendingDetectionIDs, ["claude@deadbeef"]) + + let resumedTask = try XCTUnwrap(NewProviderSeeder.reconcileIfNeeded( + providers: providers, enablement: replacementEnablement + )) + await resumedTask.value + + XCTAssertEqual(replacementEnablement.enabledIDs, ["claude", "codex", "claude@deadbeef"]) + XCTAssertFalse(replacementEnablement.isEnabled("windsurf")) + XCTAssertTrue(replacementEnablement.pendingDetectionIDs.isEmpty) + XCTAssertEqual(returningAccount.probeCount, 2) + XCTAssertEqual(userOwned.probeCount, 1) + } + // MARK: - Helpers private func seededStore(_ name: String, enabled: Set, known: Set) -> ProviderEnablementStore { diff --git a/Tests/OpenUsageTests/PeerHistoryIdentityTests.swift b/Tests/OpenUsageTests/PeerHistoryIdentityTests.swift new file mode 100644 index 000000000..ccb87543e --- /dev/null +++ b/Tests/OpenUsageTests/PeerHistoryIdentityTests.swift @@ -0,0 +1,310 @@ +import XCTest +@testable import OpenUsage + +/// Identity-keyed iCloud matching: the same account merges into the same card across Macs regardless +/// of which machine calls it the default and which shows it as an extra account card, and accounts +/// with no local card surface as remote-only Total Spend entries. +@MainActor +final class PeerHistoryIdentityTests: XCTestCase { + private let teamKey = "uuid-me|org-team" + private let maxKey = "uuid-me|org-max" + + func testDocumentV2AllowsAccountCardsAndV1StaysStrict() throws { + var v2 = makeDocument(providers: [ + "claude": history(day: "2026-07-16", tokens: 10, cost: 1), + "claude@ab12cd34": history(day: "2026-07-16", tokens: 20, cost: 2), + ], identities: ["claude": maxKey, "claude@ab12cd34": teamKey]) + XCTAssertNoThrow(try v2.validate()) + + v2.schema = UsageHistoryDocument.legacySchemaV1 + XCTAssertThrowsError(try v2.validate()) // account-card ids are a v2 concept + + let v1 = UsageHistoryDocument( + schema: UsageHistoryDocument.legacySchemaV1, + deviceID: "d", deviceName: "n", updatedAt: Date(), + providers: ["claude": history(day: "2026-07-16", tokens: 10, cost: 1)], + identities: nil + ) + XCTAssertNoThrow(try v1.validate()) + } + + func testRemapMatchesAccountsAcrossDefaultAndExtraCardRoles() { + // The mini↔MacBook case: mini's DEFAULT card is the Team account (claude), its extra card is + // Max. This Mac is the mirror image (default = Max, extra = Team). Every peer history must + // land on the LOCAL card with the same account. + let miniDoc = makeDocument( + deviceName: "Mac mini", + providers: [ + "claude": history(day: "2026-07-16", tokens: 100, cost: 502.34), + "claude@b2d3867d": history(day: "2026-07-16", tokens: 90, cost: 494.27), + ], + identities: ["claude": teamKey, "claude@b2d3867d": maxKey] + ) + let localMap = ["claude": maxKey, "claude@f15456b0": teamKey] + + let remapped = PeerHistoryRemapper.remap(documents: [miniDoc], localIdentityByCardID: localMap) + + XCTAssertTrue(remapped.remoteOnly.isEmpty) + let byCard = Dictionary(grouping: remapped.histories, by: { $0.cardID }) + XCTAssertEqual(byCard["claude"]?.first?.history.series.daily.first?.costUSD, 494.27, "mini's Max spend belongs to this Mac's default (Max) card") + XCTAssertEqual(byCard["claude@f15456b0"]?.first?.history.series.daily.first?.costUSD, 502.34, "mini's Team spend belongs to this Mac's Team card") + } + + func testUnsafeIdentityStatesNeverMergeOrCreateRemoteOnlySpend() { + let usage = history(day: "2026-07-16", tokens: 10, cost: 1) + let claude = makeDocument(providers: ["claude": usage], identities: ["claude": teamKey]) + let duplicate = makeDocument( + providers: ["claude": usage, "claude@extra": usage], + identities: ["claude": teamKey, "claude@extra": teamKey] + ) + let codex = makeDocument(providers: ["codex": usage], identities: ["codex": "shared"]) + let cases: [(name: String, document: UsageHistoryDocument, local: [String: String], cards: Set?, + reasons: [PeerHistoryRemapper.QuarantinedHistory.Reason])] = [ + ("unresolved local account", claude, [:], nil, [.unresolvedLocalIdentity]), + ("duplicate local account", claude, ["claude": teamKey, "claude@extra": teamKey], nil, + [.ambiguousLocalIdentity]), + ("duplicate peer account", duplicate, ["claude": teamKey], nil, + [.ambiguousPeerIdentity, .ambiguousPeerIdentity]), + ("cross-provider identity", codex, ["claude": "shared"], nil, [.unresolvedLocalIdentity]), + ("unresolved sibling", claude, ["claude": maxKey], ["claude", "claude@extra"], + [.unresolvedLocalIdentity]) + ] + for entry in cases { + let remapped = PeerHistoryRemapper.remap( + documents: [entry.document], localIdentityByCardID: entry.local, localAccountCardIDs: entry.cards + ) + XCTAssertTrue(remapped.histories.isEmpty, entry.name) + XCTAssertTrue(remapped.remoteOnly.isEmpty, entry.name) + XCTAssertEqual(remapped.quarantined.map(\.reason), entry.reasons, entry.name) + } + } + + func testLegacyHistoryQuarantinesAccountFamiliesButMergesOtherProviders() { + for cardID in ["claude", "grok"] { + let document = UsageHistoryDocument( + schema: UsageHistoryDocument.legacySchemaV1, + deviceID: "d", deviceName: "old Mac", updatedAt: Date(), + providers: [cardID: history(day: "2026-07-16", tokens: 10, cost: 1)], identities: nil + ) + let remapped = PeerHistoryRemapper.remap( + documents: [document], localIdentityByCardID: ["claude": maxKey] + ) + XCTAssertEqual(remapped.histories.map(\.cardID), cardID == "grok" ? [cardID] : [], cardID) + XCTAssertEqual(remapped.quarantined.map(\.reason), + cardID == "claude" ? [.missingPeerIdentity] : [], cardID) + XCTAssertTrue(remapped.remoteOnly.isEmpty, cardID) + } + } + + func testOrganizationAliasesMergeOnlyWhenTheirCompleteIdentityUniverseIsUnambiguous() { + func document(_ identities: [String: String]) -> UsageHistoryDocument { + makeDocument( + providers: Dictionary(uniqueKeysWithValues: identities.keys.map { + ($0, history(day: "2026-07-16", tokens: 10, cost: 1)) + }), identities: identities + ) + } + typealias Reason = PeerHistoryRemapper.QuarantinedHistory.Reason + let cases: [(name: String, documents: [UsageHistoryDocument], local: [String: String], + merged: [String], remote: [String], reasons: [Reason])] = [ + ("orgless peer", [document(["claude": "uuid-me"])], ["claude@extra": teamKey], + ["claude@extra"], [], []), + ("orgless local account", [document(["claude@extra": teamKey])], ["claude": "uuid-me"], + ["claude"], [], []), + ("orgless peer with multiple organizations", [document(["claude": "uuid-me"])], + ["claude": maxKey, "claude@extra": teamKey], [], [], [.ambiguousPeerIdentity]), + ("orgless local account with multiple peer organizations", + [document(["claude": teamKey]), document(["claude": maxKey])], ["claude": "uuid-me"], + [], [], [.ambiguousLocalIdentity, .ambiguousLocalIdentity]), + ("duplicate peer aliases", [document(["claude": "uuid-me", "claude@extra": teamKey])], + ["claude": teamKey], [], [], [.ambiguousPeerIdentity, .ambiguousPeerIdentity]), + ("remote-only aliases across devices", + [document(["claude": "uuid-other"]), document(["claude": "uuid-other|org-work"])], + ["claude": maxKey], [], ["uuid-other|org-work"], []) + ] + for entry in cases { + let remapped = PeerHistoryRemapper.remap(documents: entry.documents, localIdentityByCardID: entry.local) + XCTAssertEqual(remapped.histories.map(\.cardID), entry.merged, entry.name) + XCTAssertEqual(remapped.remoteOnly.map(\.identityKey), entry.remote, entry.name) + XCTAssertEqual(remapped.quarantined.map(\.reason), entry.reasons, entry.name) + if !entry.remote.isEmpty { + XCTAssertEqual(remapped.remoteOnly.first?.histories.count, 2, entry.name) + } + } + } + + func testLocalDocumentPublishesAccountCardsWithIdentities() { + // Preload the cache; the store's init adopts cached snapshots as its local set. The entries + // carry the same account stamp the store is launched with, or the swap guard discards them. + let cache = scratchCache() + cache.store( + snapshot(providerID: "claude", history: history(day: "2026-07-16", tokens: 10, cost: 1)), + producedByIdentityKey: maxKey + ) + cache.store( + snapshot(providerID: "claude@f15456b0", history: history(day: "2026-07-16", tokens: 20, cost: 2)), + producedByIdentityKey: teamKey + ) + let dataStore = makeDataStore("PublishDoc", cache: cache) + + let document = dataStore.localHistoryDocument(deviceID: "dev", deviceName: "This Mac") + XCTAssertEqual(document.schema, UsageHistoryDocument.currentSchema) + XCTAssertNotNil(document.providers["claude@f15456b0"], "account cards sync now") + XCTAssertEqual(document.identities?["claude"], maxKey) + XCTAssertEqual(document.identities?["claude@f15456b0"], teamKey) + XCTAssertNoThrow(try document.validate()) + } + + func testRemoteOnlyAccountFeedsTotalSpend() { + let dataStore = makeDataStore("RemoteTotal") + let today = dayKey(Date()) + let doc = makeDocument( + deviceName: "Mac mini", + providers: [ + "claude@ab12cd34": history(day: today, tokens: 1_000_000, cost: 42), + "claude@22222222": history(day: today, tokens: 20, cost: 2) + ], + identities: ["claude@ab12cd34": "uuid-other|org-x", "claude@22222222": "uuid-second|org-y"] + ) + dataStore.setPeerHistoryDocuments([doc], ownDeviceID: "this-mac") + + XCTAssertEqual(dataStore.remoteOnlySpend.count, 2) + let names = Set(dataStore.remoteOnlySpend.map(\.provider.displayName)) + XCTAssertEqual(names.count, 2, "each remote account retains its own identity-derived name") + let expectedCardID = ProviderAccountID.make(family: "claude", identityKey: "uuid-other|org-x") + XCTAssertTrue(names.contains(expectedCardID)) + + let total = TotalSpendAggregator.total( + for: .today, + providers: dataStore.remoteOnlySpend.map(\.provider), + snapshots: Dictionary(uniqueKeysWithValues: dataStore.remoteOnlySpend.map { + ($0.provider.id, $0.snapshot) + }) + ) + XCTAssertEqual(total.slices.count, 2) + XCTAssertEqual(total.slices.map(\.amountUSD).sorted(), [2, 42]) + + dataStore.clearPeerHistoryDocuments() + XCTAssertTrue(dataStore.remoteOnlySpend.isEmpty, "sync off returns Total Spend to local-only") + } + + func testRemoteOnlySpendFollowsAnyEnabledFamilyCard() { + let doc = makeDocument( + providers: ["claude@ab12cd34": history(day: dayKey(Date()), tokens: 10, cost: 1)], + identities: ["claude@ab12cd34": "uuid-other|org-x"] + ) + let cases: [(name: String, includeDefault: Bool, identities: [String: String]?, + enabled: @MainActor (String) -> Bool, expected: Int)] = [ + ("entire family disabled", true, nil, { _ in false }, 0), + ("missing bare card", false, ["claude@f15456b0": teamKey], { _ in true }, 1), + ("bare card disabled but sibling enabled", true, nil, { $0 != "claude" }, 1) + ] + for entry in cases { + let dataStore = makeDataStore( + entry.name, includeDefault: entry.includeDefault, identities: entry.identities, + isEnabled: entry.enabled + ) + dataStore.setPeerHistoryDocuments([doc], ownDeviceID: "this-mac") + XCTAssertEqual(dataStore.remoteOnlySpend.count, entry.expected, entry.name) + if entry.expected > 0 { + XCTAssertEqual(dataStore.remoteOnlySpend.first?.provider.icon, .providerMark("claude"), entry.name) + } + } + } + + func testUnresolvedLocalAccountHistoryIsNotPublished() { + let cache = scratchCache() + cache.store(snapshot( + providerID: "claude", + history: history(day: "2026-07-16", tokens: 10, cost: 1) + )) + let dataStore = makeDataStore("UnresolvedPublish", cache: cache, identities: [:]) + + let document = dataStore.localHistoryDocument(deviceID: "dev", deviceName: "This Mac") + + XCTAssertNil(document.providers["claude"]) + XCTAssertNil(document.identities) + } + + // MARK: - Fixtures + + private func makeDataStore( + _ name: String, + cache: ProviderSnapshotCache? = nil, + includeDefault: Bool = true, + identities: [String: String]? = nil, + isEnabled: @escaping @MainActor (String) -> Bool = { _ in true } + ) -> WidgetDataStore { + WidgetDataStore( + registry: makeRegistry(includeDefault: includeDefault), + providers: [], + cache: cache ?? scratchCache(), + defaults: makeScratchDefaults(name), + isProviderEnabled: isEnabled, + providerIdentityKeys: identities ?? ["claude": maxKey, "claude@f15456b0": teamKey] + ) + } + + private func makeRegistry(includeDefault: Bool = true) -> WidgetRegistry { + let claude = ClaudeProvider.makeProvider() + let extraCard = ClaudeProvider.makeProvider(id: "claude@f15456b0", displayName: "Claude — Team") + let providers = includeDefault ? [claude, extraCard] : [extraCard] + let descriptors = providers.map { + WidgetDescriptor.usageTrend(provider: $0) + .exportingHistory(scope: .machineLocal, estimatedCost: true, sourceNote: "test") + } + return WidgetRegistry(providers: providers, descriptors: descriptors) + } + + private func scratchCache() -> ProviderSnapshotCache { + ProviderSnapshotCache(userDefaults: makeScratchDefaults("Cache"), storageKey: "snapshots", ttl: 600) + } + + private func makeScratchDefaults(_ name: String) -> UserDefaults { + let suiteName = "OpenUsageTests.PeerIdentity.\(name).\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + addTeardownBlock { defaults.removePersistentDomain(forName: suiteName) } + return defaults + } + + private func makeDocument( + deviceName: String = "Peer", + providers: [String: ProviderUsageHistory], + identities: [String: String]? + ) -> UsageHistoryDocument { + UsageHistoryDocument( + deviceID: UUID().uuidString, + deviceName: deviceName, + updatedAt: Date(), + providers: providers, + identities: identities + ) + } + + private func history(day: String, tokens: Int, cost: Double) -> ProviderUsageHistory { + ProviderUsageHistory( + series: DailyUsageSeries(daily: [DailyUsageEntry(date: day, totalTokens: tokens, costUSD: cost)]), + modelUsage: nil, + unknownModelsByDay: [:] + ) + } + + private func snapshot(providerID: String, history: ProviderUsageHistory) -> ProviderSnapshot { + var snapshot = ProviderSnapshot( + providerID: providerID, + displayName: providerID, + lines: [], + refreshedAt: Date() + ) + snapshot.usageHistory = history + return snapshot + } + + private func dayKey(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } +} diff --git a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift index 1e381d8e6..24e1dcc6f 100644 --- a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift +++ b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift @@ -4,15 +4,7 @@ import XCTest /// The launch account pass end to end: observer outcomes → account registry records → the per-card /// identity map consumed by the snapshot cache stamp and the bare-id resolver. @MainActor -final class ProviderAccountAssemblyTests: XCTestCase { - private func makeScratchDefaults() -> UserDefaults { - let suiteName = "OpenUsageTests.ProviderAccountAssembly.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defaults.removePersistentDomain(forName: suiteName) - addTeardownBlock { defaults.removePersistentDomain(forName: suiteName) } - return defaults - } - +final class ProviderAccountAssemblyTests: ClaudeAssemblyTestCase { func testResolvedFamiliesFeedIdentityKeysAndTheRegistry() throws { let defaults = makeScratchDefaults() let store = ProviderAccountsStore(defaults: defaults) @@ -35,6 +27,9 @@ final class ProviderAccountAssemblyTests: XCTestCase { XCTAssertEqual(record.id, "claude") XCTAssertEqual(record.label, "dev@example.com") XCTAssertEqual(record.sources.map(\.kind), [.defaultHome]) + XCTAssertEqual(assembly.claudeCards.map(\.id), ["claude"]) + XCTAssertEqual(assembly.claudeCards.first?.credential, .defaultHome) + XCTAssertEqual(assembly.claudeCards.first?.identityKey, "acct-1") // An unresolved family claims no account: no record, no identity key. XCTAssertNil(store.defaultBadgeHolder(family: "codex")) } @@ -77,4 +72,290 @@ final class ProviderAccountAssemblyTests: XCTestCase { XCTAssertTrue(store.records.isEmpty) XCTAssertNil(defaults.data(forKey: ProviderAccountsStore.storageKey), "no observations, no write") } + + func testDefaultSwapKeepsBareAccountBoundToItsMovedConfigDirectory() throws { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + let firstObserver = makeClaudeObserver( + #"{"oauthAccount":{"accountUuid":"ACCOUNT-A","organizationUuid":"ORG-A"}}"# + ) + let first = ProviderAccountAssembly.make(observer: firstObserver, accountsStore: store) + XCTAssertEqual(first.claudeCards.map(\.id), ["claude"]) + store.rename(cardID: "claude", to: "Personal") + + let secondObserver = makeClaudeObserver( + #"{"oauthAccount":{"accountUuid":"ACCOUNT-B","organizationUuid":"ORG-B"}}"# + ) + let oldHome = "/Users/dev/.claude-personal" + let discovery = makeDiscovery( + files: [ + oldHome + "/.claude.json": + #"{"oauthAccount":{"accountUuid":"ACCOUNT-A","organizationUuid":"ORG-A"}}"#, + oldHome + "/.credentials.json": + #"{"claudeAiOauth":{"accessToken":"personal-token"}}"#, + ], + subdirectories: [oldHome] + ) + + let swapped = ProviderAccountAssembly.make( + observer: secondObserver, + accountsStore: store, + claudeDiscovery: discovery + ) + + XCTAssertEqual(swapped.claudeCards.count, 2) + let oldAccount = try XCTUnwrap(swapped.claudeCards.first { $0.id == "claude" }) + let newAccount = try XCTUnwrap(swapped.claudeCards.first { $0.id != "claude" }) + XCTAssertEqual(oldAccount.identityKey, "account-a|org-a") + XCTAssertEqual( + oldAccount.credential, + .configDir(path: oldHome, keychainLiteral: oldHome) + ) + XCTAssertEqual(newAccount.identityKey, "account-b|org-b") + XCTAssertEqual(newAccount.credential, .defaultHome) + XCTAssertEqual(store.defaultBadgeHolder(family: "claude")?.id, newAccount.id) + XCTAssertEqual(store.resolvedDisplayName(cardID: "claude"), "Personal") + XCTAssertEqual(swapped.identityKeysByCard["claude"], "account-a|org-a") + XCTAssertEqual(swapped.identityKeysByCard[newAccount.id], "account-b|org-b") + + let runtimes = ProviderCatalog.make(claude: swapped.claudeRuntimePlan) + .compactMap { $0 as? ClaudeProvider } + let oldRuntime = try XCTUnwrap(runtimes.first { $0.provider.id == "claude" }) + let newRuntime = try XCTUnwrap(runtimes.first { $0.provider.id == newAccount.id }) + XCTAssertEqual(oldRuntime.authStore.scope, .configDir(path: oldHome, keychainLiteral: oldHome)) + XCTAssertEqual(oldRuntime.expectedIdentityKey, "account-a|org-a") + XCTAssertEqual(newRuntime.authStore.scope, .standard) + XCTAssertEqual(newRuntime.expectedIdentityKey, "account-b|org-b") + } + + func testDefaultSwapKeepsBareAccountBoundToItsDesktopOrganization() throws { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + let original = makeClaudeObserver( + #"{"oauthAccount":{"accountUuid":"ACCOUNT-A","organizationUuid":"ORG-A"}}"# + ) + _ = ProviderAccountAssembly.make(observer: original, accountsStore: store) + + let replacement = makeClaudeObserver( + #"{"oauthAccount":{"accountUuid":"ACCOUNT-B","organizationUuid":"ORG-B"}}"# + ) + let oldSandbox = "/Users/dev/cowork/personal/.claude" + let cowork = makeCoworkDiscovery( + files: [ + oldSandbox + "/.claude.json": + #"{"oauthAccount":{"accountUuid":"ACCOUNT-A","organizationUuid":"ORG-A"}}"#, + ], + sandboxes: [oldSandbox] + ) + + let assembly = ProviderAccountAssembly.make( + observer: replacement, + accountsStore: store, + coworkDiscovery: cowork, + hasDesktopCredentialMaterial: { true } + ) + + let originalCard = try XCTUnwrap(assembly.claudeCards.first { $0.id == "claude" }) + let replacementCard = try XCTUnwrap(assembly.claudeCards.first { $0.id != "claude" }) + XCTAssertEqual(originalCard.credential, .desktop(organization: "org-a")) + XCTAssertEqual(originalCard.logRoots.map(\.path), [oldSandbox]) + XCTAssertEqual(replacementCard.credential, .defaultHome) + XCTAssertEqual(replacementCard.coworkRootsOverride, []) + XCTAssertEqual(store.defaultBadgeHolder(family: "claude")?.id, replacementCard.id) + } + + func testSameAccountConfigDirectoryAttachesWithoutDuplicatingItsRuntime() throws { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + let observer = makeClaudeObserver( + #"{"oauthAccount":{"accountUuid":"ACCOUNT-A","organizationUuid":"ORG-A"}}"# + ) + let sideHome = "/Users/dev/.claude-side" + let discovery = makeDiscovery( + files: [ + sideHome + "/.claude.json": + #"{"oauthAccount":{"accountUuid":"ACCOUNT-A","organizationUuid":"ORG-A"}}"#, + sideHome + "/.credentials.json": + #"{"claudeAiOauth":{"accessToken":"side-token"}}"#, + ], + subdirectories: [sideHome] + ) + + let assembly = ProviderAccountAssembly.make( + observer: observer, + accountsStore: store, + claudeDiscovery: discovery + ) + + let card = try XCTUnwrap(assembly.claudeCards.first) + XCTAssertEqual(assembly.claudeCards.count, 1) + XCTAssertEqual(card.credential, .defaultHome) + XCTAssertEqual(card.additionalLogRoots.map(\.path), [sideHome]) + XCTAssertEqual(Set(store.records[0].sources.map(\.kind)), [.defaultHome, .configDir]) + } + + func testSameUserOrganizationsStaySeparateAndUnidentifiedSandboxIsQuarantined() throws { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + let observer = makeClaudeObserver( + #"{"oauthAccount":{"accountUuid":"SAME-USER","organizationUuid":"ORG-PERSONAL"}}"# + ) + let personalRoot = "/Users/dev/cowork/personal/.claude" + let workRoot = "/Users/dev/cowork/work/.claude" + let unidentifiedRoot = "/Users/dev/cowork/unknown/.claude" + let cowork = makeCoworkDiscovery( + files: [ + personalRoot + "/.claude.json": + #"{"oauthAccount":{"accountUuid":"SAME-USER","organizationUuid":"ORG-PERSONAL"}}"#, + workRoot + "/.claude.json": + #"{"oauthAccount":{"accountUuid":"SAME-USER","organizationUuid":"ORG-WORK","organizationName":"Work"}}"#, + unidentifiedRoot + "/.claude.json": + #"{"oauthAccount":{"accountUuid":"SAME-USER"}}"#, + ], + sandboxes: [personalRoot, workRoot, unidentifiedRoot] + ) + + let assembly = ProviderAccountAssembly.make( + observer: observer, + accountsStore: store, + coworkDiscovery: cowork, + hasDesktopCredentialMaterial: { true } + ) + + XCTAssertEqual(assembly.claudeCards.count, 2) + let personal = try XCTUnwrap(assembly.claudeCards.first { $0.id == "claude" }) + let work = try XCTUnwrap(assembly.claudeCards.first { $0.id != "claude" }) + XCTAssertEqual(personal.identityKey, "same-user|org-personal") + XCTAssertEqual(personal.coworkRootsOverride?.map(\.path), [personalRoot]) + XCTAssertEqual(work.identityKey, "same-user|org-work") + XCTAssertEqual(work.credential, .desktop(organization: "org-work")) + XCTAssertEqual(work.logRoots.map(\.path), [workRoot]) + XCTAssertFalse(personal.logRoots.map(\.path).contains(unidentifiedRoot)) + XCTAssertFalse(work.logRoots.map(\.path).contains(unidentifiedRoot)) + } + + func testOrglessDefaultIsQuarantinedWhenMultipleOrganizationsExist() throws { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + let observer = makeClaudeObserver( + #"{"oauthAccount":{"accountUuid":"SAME-USER"}}"# + ) + let personal = "/Users/dev/cowork/personal/.claude" + let work = "/Users/dev/cowork/work/.claude" + let cowork = makeCoworkDiscovery( + files: [ + personal + "/.claude.json": + #"{"oauthAccount":{"accountUuid":"SAME-USER","organizationUuid":"ORG-PERSONAL"}}"#, + work + "/.claude.json": + #"{"oauthAccount":{"accountUuid":"SAME-USER","organizationUuid":"ORG-WORK"}}"#, + ], + sandboxes: [personal, work] + ) + + let assembly = ProviderAccountAssembly.make( + observer: observer, + accountsStore: store, + coworkDiscovery: cowork, + hasDesktopCredentialMaterial: { true } + ) + + XCTAssertEqual(assembly.claudeCards.count, 2) + XCTAssertFalse(assembly.claudeCards.contains { $0.credential == .defaultHome }) + XCTAssertEqual( + Set(assembly.claudeCards.map(\.identityKey)), + ["same-user|org-personal", "same-user|org-work"] + ) + XCTAssertNil(store.defaultBadgeHolder(family: "claude")) + } + +} + +/// One small filesystem/registry fixture shared by account planning, partitioning, and auth tests. +@MainActor +class ClaudeAssemblyTestCase: XCTestCase { + func makeScratchDefaults() -> UserDefaults { + let suiteName = "OpenUsageTests.ClaudeAssembly.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + addTeardownBlock { defaults.removePersistentDomain(forName: suiteName) } + return defaults + } + + func makeClaudeObserver(_ state: String?) -> DefaultAccountObserver { + var files: [String: String] = [:] + if let state { files["/Users/dev/.claude.json"] = state } + return DefaultAccountObserver( + environment: FakeEnvironment([:]), + files: FakeFiles(files), + keychain: FakeKeychain(nil), + homeDirectory: { URL(fileURLWithPath: "/Users/dev") } + ) + } + + func claudeState(_ account: String, organization: String? = nil) -> String { + let organizationField = organization.map { ",\"organizationUuid\":\"\($0)\"" } ?? "" + return "{\"oauthAccount\":{\"accountUuid\":\"\(account)\"\(organizationField)}}" + } + + func makeClaudeStore( + identity: String, + aliases: [String] = [], + otherIdentity: String? = nil, + removed: Bool = false + ) throws -> ProviderAccountsStore { + let defaults = makeScratchDefaults() + var records = [ProviderAccountRecord( + id: "claude", family: "claude", identityKey: identity, identityAliases: aliases, + label: nil, + sources: [ProviderAccountSource( + kind: .defaultHome, anchor: "/Users/dev/.claude", holdsDefaultSource: true + )] + )] + if let otherIdentity { + records.append(ProviderAccountRecord( + id: "claude@deadbeef", family: "claude", identityKey: otherIdentity, + label: nil, sources: [], removedTombstone: removed + )) + } + defaults.set(try JSONEncoder().encode(records), forKey: ProviderAccountsStore.storageKey) + return ProviderAccountsStore(defaults: defaults) + } + + func makeDiscovery( + files: [String: String], + subdirectories: [String] + ) -> ClaudeConfigDirDiscovery { + ClaudeConfigDirDiscovery( + environment: FakeEnvironment([:]), + files: FakeFiles(files), + keychain: ServiceKeychain(), + homeDirectory: { URL(fileURLWithPath: "/Users/dev") }, + listSubdirectories: { url in + subdirectories + .map { URL(fileURLWithPath: $0) } + .filter { $0.deletingLastPathComponent().path == url.path } + } + ) + } + + func makeCoworkDiscovery( + files: [String: String], + sandboxes: [String], + timeBudget: TimeInterval = 3 + ) -> ClaudeCoworkDiscovery { + ClaudeCoworkDiscovery( + files: FakeFiles(files), + homeDirectory: { URL(fileURLWithPath: "/Users/dev") }, + listSandboxes: { _ in sandboxes.map { URL(fileURLWithPath: $0) } }, + timeBudget: timeBudget + ) + } + + func claudeRuntime( + for assembly: ProviderAccountAssembly, + id: String? = nil + ) throws -> ClaudeProvider { + let runtimes = ProviderCatalog.make(claude: assembly.claudeRuntimePlan) + .compactMap { $0 as? ClaudeProvider } + let selected = runtimes.first { runtime in + if let id { return runtime.provider.id == id } + return runtime.authStore.scope == .standard + } + return try XCTUnwrap(selected) + } } diff --git a/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift b/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift index 264c25486..3a6fe496f 100644 --- a/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift +++ b/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift @@ -3,6 +3,23 @@ import XCTest @MainActor final class ProviderAccountsStoreTests: XCTestCase { + private struct LegacyMirrorRecord: Codable { + struct Source: Codable { + enum Kind: String, Codable { case defaultHome } + + var kind: Kind + var anchor: String? + var holdsDefaultSource: Bool + } + + var id: String + var family: String + var identityKey: String + var label: String? + var sources: [Source] + var removedTombstone: Bool + } + private func makeScratchDefaults() -> UserDefaults { let suiteName = "OpenUsageTests.ProviderAccounts.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! @@ -16,8 +33,8 @@ final class ProviderAccountsStoreTests: XCTestCase { identityKey: String, label: String? = nil, anchor: String = "/Users/dev/.claude" - ) -> ProviderAccountsStore.Observation { - ProviderAccountsStore.Observation( + ) -> ProviderAccountsStore.AccountObservation { + ProviderAccountsStore.AccountObservation( family: family, identityKey: identityKey, label: label, @@ -25,6 +42,21 @@ final class ProviderAccountsStoreTests: XCTestCase { ) } + private func sideObservation( + _ identityKey: String, + label: String? = nil, + kind: ProviderAccountSource.Kind = .desktop, + anchor: String? = nil, + keychainLiteral: String? = nil + ) -> ProviderAccountsStore.AccountObservation { + ProviderAccountsStore.AccountObservation( + family: "claude", identityKey: identityKey, label: label, + sources: [ProviderAccountSource( + kind: kind, anchor: anchor, holdsDefaultSource: false, keychainLiteral: keychainLiteral + )] + ) + } + func testFirstAccountOfAFamilyGetsTheBareID() { let store = ProviderAccountsStore(defaults: makeScratchDefaults()) @@ -54,6 +86,123 @@ final class ProviderAccountsStoreTests: XCTestCase { XCTAssertEqual(old?.sources.contains(where: \.holdsDefaultSource), false, "the badge is exclusive per family") } + func testRenamingCodexRuntimeUpdatesOnlyItsCurrentAccountAndSurvivesSwitchBack() throws { + let defaults = makeScratchDefaults() + let store = ProviderAccountsStore(defaults: defaults) + store.reconcile(with: [defaultHomeObservation(family: "codex", identityKey: "account-a", label: "alice@example.com")]) + store.rename(cardID: "codex", to: "Alice") + store.reconcile(with: [defaultHomeObservation(family: "codex", identityKey: "account-b", label: "bob@example.com")]) + + let active = try XCTUnwrap(store.runtimeRecord(for: "codex")) + XCTAssertEqual(active.identityKey, "account-b") + XCTAssertNotEqual(active.id, "codex", "the runtime alias keeps permanent account ids") + XCTAssertEqual(store.derivedDisplayName(cardID: "codex"), "Codex — bob@example.com") + store.rename(cardID: "codex", to: "Bob") + + let accountBID = try XCTUnwrap(store.runtimeRecord(for: "codex")?.id) + XCTAssertEqual(store.record(for: "codex")?.customLabel, "Alice") + XCTAssertEqual(store.record(for: accountBID)?.customLabel, "Bob") + XCTAssertEqual(store.resolvedDisplayName(cardID: "codex"), "Bob") + XCTAssertEqual(store.resolvedDisplayNamesByCardID["codex"], "Bob") + XCTAssertEqual(ProviderAccountsStore(defaults: defaults).record(for: accountBID)?.customLabel, "Bob") + + store.reconcile(with: [defaultHomeObservation(family: "codex", identityKey: "account-a", label: "alice@example.com")]) + + XCTAssertEqual(store.runtimeRecord(for: "codex")?.identityKey, "account-a") + XCTAssertEqual(store.resolvedDisplayName(cardID: "codex"), "Alice") + XCTAssertEqual(store.record(for: accountBID)?.customLabel, "Bob") + } + + func testUnverifiedCodexRuntimeCannotBorrowAPreviousAccountsName() { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + store.reconcile(with: [defaultHomeObservation(family: "codex", identityKey: "account-a", label: "alice@example.com")]) + store.rename(cardID: "codex", to: "Alice") + store.clearDefaultSource(family: "codex") + + XCTAssertNil(store.runtimeRecord(for: "codex")) + XCTAssertNil(store.resolvedDisplayName(cardID: "codex")) + XCTAssertNil(store.resolvedDisplayNamesByCardID["codex"]) + + store.rename(cardID: "codex", to: "Another Account") + XCTAssertEqual(store.record(for: "codex")?.customLabel, "Alice") + } + + func testOrganizationAppearingOrDisappearingPreservesTheCardAliasesAndCustomName() { + for (previous, current) in [("acct-a", "acct-a|org-work"), ("acct-a|org-work", "acct-a")] { + let defaults = makeScratchDefaults() + let store = ProviderAccountsStore(defaults: defaults) + store.reconcile(with: [defaultHomeObservation(family: "claude", identityKey: previous)]) + store.rename(cardID: "claude", to: "My Claude") + + let records = store.reconcile(with: [defaultHomeObservation(family: "claude", identityKey: current)]) + + XCTAssertEqual(records.count, 1, "\(previous) → \(current)") + XCTAssertEqual(records.first?.id, "claude") + XCTAssertEqual(records.first?.identityKey, current) + XCTAssertEqual(records.first?.identityAliases, [previous]) + XCTAssertEqual(records.first?.customLabel, "My Claude") + XCTAssertEqual(store.defaultBadgeHolder(family: "claude")?.id, "claude") + let reloaded = ProviderAccountsStore(defaults: defaults) + XCTAssertEqual(reloaded.record(for: "claude")?.identityKey, current) + XCTAssertEqual(reloaded.record(for: "claude")?.identityAliases, [previous]) + XCTAssertEqual(reloaded.resolvedDisplayName(cardID: "claude"), "My Claude") + } + } + + func testExplicitOrRememberedOrganizationsNeverShareAnAccountCard() { + for droppedOrganization in [false, true] { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + store.reconcile(with: [defaultHomeObservation(family: "claude", identityKey: "acct-a|org-work")]) + store.rename(cardID: "claude", to: "Work") + if droppedOrganization { + store.reconcile(with: [defaultHomeObservation(family: "claude", identityKey: "acct-a")]) + } + + let records = store.reconcile(with: [ + defaultHomeObservation(family: "claude", identityKey: "acct-a|org-personal") + ]) + + XCTAssertEqual(records.count, 2) + XCTAssertEqual(store.record(for: "claude")?.identityKey, + droppedOrganization ? "acct-a" : "acct-a|org-work") + XCTAssertEqual(store.record(for: "claude")?.customLabel, "Work") + XCTAssertEqual(records.first { $0.identityKey == "acct-a|org-personal" }?.id, + ProviderAccountID.make(family: "claude", identityKey: "acct-a|org-personal")) + if droppedOrganization { + XCTAssertEqual(store.record(for: "claude")?.identityAliases, ["acct-a|org-work"]) + } + } + } + + func testOrganizationLessObservationIsQuarantinedWhenMultipleOrganizationsExist() { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + store.reconcile(with: [ + defaultHomeObservation(family: "claude", identityKey: "acct-a|org-work"), + sideObservation("acct-a|org-personal"), + ]) + let original = store.records + + let records = store.reconcile(with: [defaultHomeObservation(family: "claude", identityKey: "acct-a")]) + + XCTAssertEqual(records, original, "an unidentified organization must not claim either account") + XCTAssertEqual(store.defaultBadgeHolder(family: "claude")?.identityKey, "acct-a|org-work") + } + + func testMultipleIncomingOrganizationsCannotClaimAnExistingOrganizationLessAccount() { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + store.reconcile(with: [defaultHomeObservation(family: "claude", identityKey: "acct-a")]) + + let records = store.reconcile(with: [ + defaultHomeObservation(family: "claude", identityKey: "acct-a|org-work"), + sideObservation("acct-a|org-personal"), + ]) + + XCTAssertEqual(records.count, 3) + XCTAssertEqual(store.record(for: "claude")?.identityKey, "acct-a") + XCTAssertNotNil(records.first { $0.identityKey == "acct-a|org-work" }) + XCTAssertNotNil(records.first { $0.identityKey == "acct-a|org-personal" }) + } + func testUnobservedFamilyIsLeftUntouched() { let defaults = makeScratchDefaults() let store = ProviderAccountsStore(defaults: defaults) @@ -119,6 +268,160 @@ final class ProviderAccountsStoreTests: XCTestCase { XCTAssertTrue(ProviderAccountsStore(defaults: defaults).records.isEmpty) } + func testRenamePersistsAndFollowsTheAccountAcrossSourceChanges() { + let defaults = makeScratchDefaults() + let store = ProviderAccountsStore(defaults: defaults) + store.reconcile(with: [defaultHomeObservation(family: "claude", identityKey: "acct-a", label: "old@example.com")]) + store.rename(cardID: "claude", to: " Personal ") + + let records = store.reconcile(with: [ + defaultHomeObservation(family: "claude", identityKey: "acct-b"), + sideObservation( + "acct-a", label: "new@example.com", kind: .configDir, + anchor: "/Users/dev/.claude-personal", keychainLiteral: "~/.claude-personal" + ), + ]) + + let original = records.first { $0.id == "claude" } + XCTAssertEqual(original?.identityKey, "acct-a") + XCTAssertEqual(original?.sources.map(\.kind), [.configDir]) + XCTAssertEqual(original?.sources.first?.keychainLiteral, "~/.claude-personal") + XCTAssertEqual(original?.customLabel, "Personal") + XCTAssertEqual(store.runtimeRecord(for: "claude")?.identityKey, "acct-a") + XCTAssertEqual(store.resolvedDisplayName(cardID: "claude"), "Personal") + XCTAssertEqual(store.defaultBadgeHolder(family: "claude")?.identityKey, "acct-b") + XCTAssertEqual( + ProviderAccountsStore(defaults: defaults).record(for: "claude")?.customLabel, + "Personal" + ) + store.rename(cardID: "claude", to: " ") + XCTAssertNil(store.record(for: "claude")?.customLabel) + XCTAssertEqual(store.resolvedDisplayName(cardID: "claude"), "Claude — new@example.com") + } + + func testAccountNamesKeepTheBareTitleUntilAnotherOrganizationAppears() throws { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + store.reconcile(with: [ + defaultHomeObservation( + family: "claude", + identityKey: "acct-work|org-work", + label: "rob@example.com (SUNSTORY)" + ), + ]) + + XCTAssertEqual(store.derivedDisplayName(cardID: "claude"), "Claude") + XCTAssertEqual(store.resolvedDisplayName(cardID: "claude"), "Claude") + store.reconcile(with: [ + defaultHomeObservation( + family: "claude", + identityKey: "acct-work|org-work", + label: "rob@example.com (SUNSTORY)" + ), + sideObservation("acct-personal|org-personal", label: "rob@example.com (rob@example.com's Organization)"), + ]) + + let personal = try XCTUnwrap(store.records.first { $0.id != "claude" }) + XCTAssertEqual(store.derivedDisplayName(cardID: "claude"), "Claude — SUNSTORY") + XCTAssertEqual(store.derivedDisplayName(cardID: personal.id), "Claude — Personal") + XCTAssertEqual(store.resolvedDisplayNamesByCardID[personal.id], "Claude — Personal") + store.reconcile(with: [ + sideObservation("acct-other|org-other", label: "other@example.com (SUNSTORY)"), + ]) + + let duplicate = try XCTUnwrap(store.records.first { $0.identityKey == "acct-other|org-other" }) + XCTAssertNotEqual(store.derivedDisplayName(cardID: "claude"), store.derivedDisplayName(cardID: duplicate.id)) + store.rename(cardID: "claude", to: "My Custom Name") + XCTAssertEqual(store.resolvedDisplayName(cardID: "claude"), "My Custom Name") + XCTAssertEqual(store.resolvedDisplayName(cardID: duplicate.id), "Claude — SUNSTORY") + } + + func testUnknownSourceKindsSurviveDecodeAndReconciliation() throws { + let defaults = makeScratchDefaults() + let persisted = #"[{"id":"claude","family":"claude","identityKey":"acct-a","label":"Personal","sources":[{"kind":"futureVault","anchor":"vault-a","holdsDefaultSource":false}],"removedTombstone":false}]"# + defaults.set(Data(persisted.utf8), forKey: ProviderAccountsStore.storageKey) + let store = ProviderAccountsStore(defaults: defaults) + + XCTAssertEqual(store.records.count, 1, "a forward source must not wipe the entire registry") + XCTAssertEqual(store.records[0].sources.first?.kind.rawValue, "futureVault") + + store.reconcile(with: [defaultHomeObservation(family: "claude", identityKey: "acct-a")]) + + let reloaded = ProviderAccountsStore(defaults: defaults) + XCTAssertEqual( + Set(reloaded.records[0].sources.map(\.kind.rawValue)), + ["defaultHome", "futureVault"] + ) + XCTAssertEqual(reloaded.records[0].sources.last?.anchor, "vault-a") + } + + func testConfigDirectoryOnlyAccountDoesNotClaimReservedBareID() { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + let records = store.reconcile(with: [ + sideObservation("acct-side", kind: .configDir, anchor: "/Users/dev/.claude-side") + ]) + + XCTAssertEqual(records.first?.id, ProviderAccountID.make(family: "claude", identityKey: "acct-side")) + } + + func testLegacyRegistryMigratesToV2AndRepairsDowngradeMirrorWithoutLosingNames() throws { + let defaults = makeScratchDefaults() + let unsafeLegacy = #"[{"id":"claude","family":"claude","identityKey":"acct-a|org-a","label":"a@example.com","customLabel":"Personal","sources":[{"kind":"defaultHome","anchor":"/Users/dev/.claude","holdsDefaultSource":true},{"kind":"configDir","anchor":"/Users/dev/.claude-side","holdsDefaultSource":false,"keychainLiteral":"~/.claude-side"}],"removedTombstone":false},{"id":"claude@12345678","family":"claude","identityKey":"acct-b|org-b","label":"b@example.com","customLabel":"Work","sources":[{"kind":"desktop","anchor":null,"holdsDefaultSource":false}],"removedTombstone":false}]"# + defaults.set(Data(unsafeLegacy.utf8), forKey: ProviderAccountsStore.legacyStorageKey) + + let migrated = ProviderAccountsStore(defaults: defaults) + + XCTAssertEqual(migrated.records.count, 2) + XCTAssertEqual(migrated.record(for: "claude")?.customLabel, "Personal") + XCTAssertEqual(migrated.record(for: "claude@12345678")?.customLabel, "Work") + XCTAssertNotNil(defaults.data(forKey: ProviderAccountsStore.storageKey)) + + let mirrorData = try XCTUnwrap(defaults.data(forKey: ProviderAccountsStore.legacyStorageKey)) + let downgradeRecords = try JSONDecoder().decode([LegacyMirrorRecord].self, from: mirrorData) + XCTAssertEqual(downgradeRecords.count, 2) + XCTAssertEqual(downgradeRecords[0].sources.map(\.kind), [.defaultHome]) + XCTAssertTrue(downgradeRecords[1].sources.isEmpty) + + // An old release may rewrite its own mirror; v2 remains authoritative on re-upgrade. + defaults.set(try JSONEncoder().encode([downgradeRecords[0]]), forKey: ProviderAccountsStore.legacyStorageKey) + let upgradedAgain = ProviderAccountsStore(defaults: defaults) + XCTAssertEqual(upgradedAgain.records.count, 2) + XCTAssertEqual(upgradedAgain.record(for: "claude@12345678")?.customLabel, "Work") + XCTAssertEqual(upgradedAgain.record(for: "claude@12345678")?.sources.map(\.kind), [.desktop]) + } + + func testClearingDefaultSourcePreservesAccountAndOtherSources() { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + store.reconcile(with: [ + ProviderAccountsStore.AccountObservation( + family: "claude", + identityKey: "acct-a", + label: "Personal", + sources: [ + ProviderAccountSource(kind: .defaultHome, anchor: "/Users/dev/.claude", holdsDefaultSource: true), + ProviderAccountSource(kind: .desktop, anchor: nil, holdsDefaultSource: false), + ] + ), + ]) + store.rename(cardID: "claude", to: "Saved Name") + + store.clearDefaultSource(family: "claude") + + XCTAssertNil(store.defaultBadgeHolder(family: "claude")) + XCTAssertEqual(store.record(for: "claude")?.sources.map(\.kind), [.desktop]) + XCTAssertEqual(store.record(for: "claude")?.customLabel, "Saved Name") + } + + func testClaudeIdentityNormalizesAndQuarantinesAmbiguousOrganizations() { + XCTAssertEqual(ClaudeIdentity(" USER|ORG ")?.key, "user|org") + for malformed in ["", "|org", "user|", "user|org|another", "user name"] { + XCTAssertNil(ClaudeIdentity(malformed), malformed) + } + let orgless = ClaudeIdentity("user")! + let scoped = ClaudeIdentity("user|org")! + XCTAssertEqual(ClaudeIdentity.canonical(orgless, among: [orgless, scoped]), scoped) + XCTAssertNil(ClaudeIdentity.canonical(orgless, among: [scoped, ClaudeIdentity("user|other")!])) + } + func testFamilyHelperSplitsCardIDs() { XCTAssertEqual(ProviderAccountID.family(of: "claude"), "claude") XCTAssertEqual(ProviderAccountID.family(of: "claude@ab12cd34"), "claude") diff --git a/Tests/OpenUsageTests/ProviderEnablementEnforcementTests.swift b/Tests/OpenUsageTests/ProviderEnablementEnforcementTests.swift index a86aea190..47f92c7ca 100644 --- a/Tests/OpenUsageTests/ProviderEnablementEnforcementTests.swift +++ b/Tests/OpenUsageTests/ProviderEnablementEnforcementTests.swift @@ -79,7 +79,8 @@ final class ProviderEnablementEnforcementTests: XCTestCase { cache: ProviderSnapshotCache(userDefaults: defaults, storageKey: "snapshots"), defaults: defaults, isProviderEnabled: { enablement.isEnabled($0) }, - now: { now } + now: { now }, + providerIdentityKeys: [provider.id: "account-a"] ) enablement.onChange = { store.providerEnablementDidChange() } @@ -89,7 +90,8 @@ final class ProviderEnablementEnforcementTests: XCTestCase { deviceID: "peer", deviceName: "Peer Mac", updatedAt: now, - providers: [provider.id: history(tokens: 200, cost: 2, now: now)] + providers: [provider.id: history(tokens: 200, cost: 2, now: now)], + identities: [provider.id: "account-a"] ) ], ownDeviceID: "this-mac") XCTAssertEqual(try spendTokens(store.snapshots[provider.id], label: "Today"), 300) diff --git a/Tests/OpenUsageTests/ProviderEnablementStoreTests.swift b/Tests/OpenUsageTests/ProviderEnablementStoreTests.swift index c738daea1..786cbcdaa 100644 --- a/Tests/OpenUsageTests/ProviderEnablementStoreTests.swift +++ b/Tests/OpenUsageTests/ProviderEnablementStoreTests.swift @@ -171,6 +171,24 @@ final class ProviderEnablementStoreTests: XCTestCase { wait(for: [notPosted], timeout: 0.2) } + func testPendingChecksPersistAcrossReplacementAndYieldToUserChoices() { + let defaults = makeDefaults("pending-persistence") + let original = ProviderEnablementStore(defaults: defaults) + original.seedEnabledProviders(["claude"]) + original.markProviderDetectionPending(["claude@deadbeef", "grok"]) + + let replacement = ProviderEnablementStore(defaults: defaults) + XCTAssertEqual(replacement.pendingDetectionIDs, ["claude@deadbeef", "grok"]) + + replacement.setEnabled(true, for: "grok") + XCTAssertEqual(replacement.pendingDetectionIDs, ["claude@deadbeef"]) + XCTAssertEqual(ProviderEnablementStore(defaults: defaults).pendingDetectionIDs, ["claude@deadbeef"]) + + replacement.finishProviderDetection(["claude@deadbeef"]) + XCTAssertTrue(ProviderEnablementStore(defaults: defaults).pendingDetectionIDs.isEmpty) + XCTAssertNil(defaults.object(forKey: "openusage.pendingProviderDetection.v1")) + } + private func makeDefaults(_ name: String) -> UserDefaults { let suiteName = "OpenUsageTests.Enablement.\(name).\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! diff --git a/Tests/OpenUsageTests/ShareCardRendererTests.swift b/Tests/OpenUsageTests/ShareCardRendererTests.swift index 32be94ce9..446ab71b8 100644 --- a/Tests/OpenUsageTests/ShareCardRendererTests.swift +++ b/Tests/OpenUsageTests/ShareCardRendererTests.swift @@ -48,6 +48,33 @@ final class ShareCardRendererTests: XCTestCase { XCTAssertGreaterThan(rep.pixelsHigh, 0) } + func testMultiAccountShareCardsRenderDistinctAccountLabels() throws { + let provider = MockData.claude + let personalProvider = Provider( + id: "claude@ab12cd34", + displayName: "Claude — Personal", + icon: provider.icon + ) + let cases = [ + (provider, "Claude — SUNSTORY", "Team 5x"), + (personalProvider, "Claude — Personal", "Max 20x") + ] + + for (account, name, plan) in cases { + let row = WidgetData(title: "Session", icon: account.icon, kind: .percent, used: 22, limit: 100) + let card = ShareCardView( + provider: account, + plan: plan, + rows: [row], + appearance: .dark, + displayNameOverride: name + ) + XCTAssertEqual(card.displayNameOverride, name) + XCTAssertEqual(card.plan, plan) + XCTAssertNotNil(ShareCardRenderer.image(for: card)) + } + } + func testCondensedTextRowIndicesFollowsNeighborRule() { let rows = MockData.descriptors(for: MockData.claude.id).map { $0.sample } XCTAssertGreaterThan(rows.count, 1, "sample fixture should have multiple rows") diff --git a/Tests/OpenUsageTests/TelemetryRecorderTests.swift b/Tests/OpenUsageTests/TelemetryRecorderTests.swift index 2517541ef..eb721308f 100644 --- a/Tests/OpenUsageTests/TelemetryRecorderTests.swift +++ b/Tests/OpenUsageTests/TelemetryRecorderTests.swift @@ -99,6 +99,53 @@ final class TelemetryRecorderTests: XCTestCase { } } + func testDailyActiveNeverIncludesAccountDerivedIdentifiers() { + let sink = FakeSink() + let store = makeStore("account-safe-daily-active") + let accountSnapshot = TelemetryConfigSnapshot( + enabledProviders: ["claude", "claude@ab12cd34", "codex@deadbeef"], + enabledMetricIDs: ["claude.session", "claude@ab12cd34.session", "codex@deadbeef.weekly"], + pinnedMetricIDs: ["claude@ab12cd34.session"], + expandedMetricIDs: ["codex@deadbeef.weekly", "codex.weekly"], + menuBarStyle: "text" + ) + let recorder = TelemetryRecorder( + sink: sink, + store: store, + snapshot: { accountSnapshot }, + now: { self.day(25) } + ) + + recorder.tick() + + let event = try! XCTUnwrap(sink.events(named: "app_daily_active").first) + XCTAssertEqual(event["enabled_providers"] as? [String], ["claude", "codex"]) + XCTAssertEqual(event["enabled_metric_ids"] as? [String], ["claude.session", "codex.weekly"]) + XCTAssertEqual(event["pinned_metric_ids"] as? [String], ["claude.session"]) + XCTAssertEqual(event["expanded_metric_ids"] as? [String], ["codex.weekly"]) + } + + func testAccountRefreshesShareOneFamilyRollupWithoutPersistingAccountIDs() { + let sink = FakeSink() + let store = makeStore("account-safe-provider-rollup") + var clock = day(25) + let recorder = TelemetryRecorder(sink: sink, store: store, snapshot: { self.snapshot }, now: { clock }) + + recorder.record(providerID: "claude", outcome: .refreshed, category: nil, manual: false) + recorder.record(providerID: "claude@ab12cd34", outcome: .failed, category: .network, manual: true) + + XCTAssertEqual(Set(store.providerCounters().keys), ["claude"]) + + clock = day(26) + recorder.tick() + + let event = try! XCTUnwrap(sink.events(named: "provider_refresh_daily").first) + XCTAssertEqual(event["provider_id"] as? String, "claude") + XCTAssertEqual(event["success_count"] as? Int, 1) + XCTAssertEqual(event["failure_count"] as? Int, 1) + XCTAssertEqual(event["manual_refresh_count"] as? Int, 1) + } + func testTickFlushesStalePriorDayCounterEvenWithoutNewOutcomes() { let sink = FakeSink() let store = makeStore("sweep") diff --git a/Tests/OpenUsageTests/TotalSpendAggregatorTests.swift b/Tests/OpenUsageTests/TotalSpendAggregatorTests.swift index c4a4ae92c..a534254e8 100644 --- a/Tests/OpenUsageTests/TotalSpendAggregatorTests.swift +++ b/Tests/OpenUsageTests/TotalSpendAggregatorTests.swift @@ -1,3 +1,4 @@ +import SwiftUI import XCTest @testable import OpenUsage @@ -50,6 +51,25 @@ final class TotalSpendAggregatorTests: XCTestCase { XCTAssertEqual(spend.centerValue, 9.75, accuracy: 0.0001) } + func testSlicesCarryTheCallerResolvedTitleThroughProjection() { + // The caller (the live card, with registry access) resolves each slice's title once; the + // legend and the share export both read that resolved string, so a mid-session rename can + // never show on one and not the other. + let snapshots = [ + "claude": snapshot(claude, lines: [spendLine("Today", dollars: 2.50)]), + "cursor": snapshot(cursor, lines: [spendLine("Today", dollars: 7.25)]) + ] + + let total = TotalSpendAggregator.total( + for: .today, + providers: [claude, cursor], + snapshots: snapshots, + title: { $0.id == "claude" ? "Claude Team" : $0.displayName } + ) + + XCTAssertEqual(total.projection(for: .cost).slices.map(\.title), ["Cursor", "Claude Team"]) + } + func testProviderWithoutPeriodLineIsExcludedNotZero() { let snapshots = [ "claude": snapshot(claude, lines: [spendLine("Today", dollars: 1.00)]), @@ -158,3 +178,50 @@ final class TotalSpendAggregatorTests: XCTestCase { XCTAssertTrue(total.projection(for: .costPerMtok).isEmpty) } } + +final class TotalSpendPaletteTests: XCTestCase { + func testBareProviderBrandColorsRemainUnchanged() { + XCTAssertEqual(TotalSpendPalette.color(for: "claude"), + Color(red: 222.0 / 255, green: 115.0 / 255, blue: 86.0 / 255)) + XCTAssertEqual(TotalSpendPalette.color(for: "codex"), + Color(red: 16.0 / 255, green: 163.0 / 255, blue: 127.0 / 255)) + XCTAssertNil(TotalSpendPalette.accountComponents(for: "claude")) + XCTAssertNil(TotalSpendPalette.accountComponents(for: "codex")) + } + + func testAccountColorIsStableAndMatchesItsRemoteOnlyAlias() throws { + let local = try XCTUnwrap(TotalSpendPalette.accountComponents(for: "claude@ab12cd34")) + for alias in ["claude@ab12cd34", "claude@AB12CD34", "claude@peer-ab12cd34"] { + XCTAssertEqual(local, TotalSpendPalette.accountComponents(for: alias)) + } + XCTAssertEqual(TotalSpendPalette.color(for: "claude@ab12cd34"), + TotalSpendPalette.color(for: "claude@peer-ab12cd34")) + } + + func testSiblingAccountsThatCollidedInTheLegacyFallbackGetDistinctColors() throws { + let siblings = try ["11111111", "22222222", "33333333"].map { + try XCTUnwrap(TotalSpendPalette.accountComponents(for: "claude@\($0)")) + } + XCTAssertNotEqual(siblings[0], siblings[1]) + XCTAssertNotEqual(siblings[0], siblings[2]) + XCTAssertNotEqual(siblings[1], siblings[2]) + } + + func testAccountShadesStayNearTheirFamilyBrandAndRemainLegible() throws { + let claude = try XCTUnwrap(TotalSpendPalette.accountComponents(for: "claude@ab12cd34")) + let codex = try XCTUnwrap(TotalSpendPalette.accountComponents(for: "codex@ab12cd34")) + XCTAssertTrue(claude.hue <= 0.15 || claude.hue >= 0.90) + XCTAssertTrue((0.33...0.56).contains(codex.hue)) + for color in [claude, codex] { + XCTAssertGreaterThanOrEqual(color.saturation, 0.50) + XCTAssertTrue((0.62...0.94).contains(color.brightness)) + } + } + + func testUnknownProvidersRetainTheExistingFallbackPalette() { + XCTAssertEqual(TotalSpendPalette.color(for: "mystery-provider"), + Color(red: 162.0 / 255, green: 132.0 / 255, blue: 94.0 / 255)) + XCTAssertNil(TotalSpendPalette.accountComponents(for: "mystery-provider")) + XCTAssertNil(TotalSpendPalette.accountComponents(for: "cursor@ab12cd34")) + } +} diff --git a/Tests/OpenUsageTests/UsageHistoryDocumentTests.swift b/Tests/OpenUsageTests/UsageHistoryDocumentTests.swift index 9eeba2d09..b7292576b 100644 --- a/Tests/OpenUsageTests/UsageHistoryDocumentTests.swift +++ b/Tests/OpenUsageTests/UsageHistoryDocumentTests.swift @@ -17,7 +17,7 @@ final class UsageHistoryDocumentTests: XCTestCase { func testRejectsUnsupportedSchemaInvalidValuesAndImpossibleDates() { var document = makeDocument(deviceID: "mac-a", updatedAt: .now) - document.schema = "openusage.history.v2" + document.schema = "openusage.history.v3" XCTAssertThrowsError(try document.validate()) { error in XCTAssertEqual(error as? UsageHistoryDocumentError, .unsupportedSchema) } @@ -43,6 +43,61 @@ final class UsageHistoryDocumentTests: XCTestCase { XCTAssertThrowsError(try document.validate()) } + func testRejectsInvalidOrUnboundAccountIdentityMetadata() { + var document = makeDocument(deviceID: "mac-a", updatedAt: .now) + document.identities = ["claude": ""] + XCTAssertThrowsError(try document.validate()) { error in + XCTAssertEqual(error as? UsageHistoryDocumentError, .invalidIdentity("claude")) + } + + document.identities = ["claude": "/Users/alice/.claude"] + XCTAssertThrowsError(try document.validate()) + + document.identities = ["codex": "account-id"] + XCTAssertThrowsError(try document.validate()) + + document.providers["cursor"] = document.providers["claude"] + document.identities = ["cursor": "account-id"] + XCTAssertThrowsError(try document.validate()) + } + + func testRejectsDuplicateAccountIdentityWithinOneProviderFamily() { + var document = makeDocument(deviceID: "mac-a", updatedAt: .now) + document.providers["claude@ab12cd34"] = document.providers["claude"] + document.identities = ["claude": "account-id", "claude@ab12cd34": "account-id"] + + XCTAssertThrowsError(try document.validate()) { error in + guard let historyError = error as? UsageHistoryDocumentError, + case .duplicateIdentity = historyError + else { + return XCTFail("expected duplicate account identity, got \(error)") + } + } + } + + func testRejectsAccountCardWithoutIdentityAndMalformedCardIdentifiers() { + var document = makeDocument(deviceID: "mac-a", updatedAt: .now) + document.providers["claude@ab12cd34"] = document.providers["claude"] + + XCTAssertThrowsError(try document.validate()) { error in + XCTAssertEqual(error as? UsageHistoryDocumentError, .invalidIdentity("claude@ab12cd34")) + } + + document.providers["claude@ab12cd34"] = nil + document.providers["claude@@ab12cd34"] = document.providers["claude"] + XCTAssertThrowsError(try document.validate()) { error in + XCTAssertEqual(error as? UsageHistoryDocumentError, .invalidProvider("claude@@ab12cd34")) + } + } + + func testLegacyDocumentsCannotCarryModernIdentityMetadata() { + var document = makeDocument(deviceID: "mac-a", updatedAt: .now) + document.schema = UsageHistoryDocument.legacySchemaV1 + document.identities = ["claude": "account-id"] + + XCTAssertThrowsError(try document.validate()) + } + func testNewestDocumentWinsForDuplicateMachine() { let old = makeDocument(deviceID: "same-mac", updatedAt: Date(timeIntervalSince1970: 100)) let newest = makeDocument(deviceID: "same-mac", updatedAt: Date(timeIntervalSince1970: 200)) diff --git a/Tests/OpenUsageTests/WidgetRegistryTests.swift b/Tests/OpenUsageTests/WidgetRegistryTests.swift index 7bc8374b3..e4e0c5547 100644 --- a/Tests/OpenUsageTests/WidgetRegistryTests.swift +++ b/Tests/OpenUsageTests/WidgetRegistryTests.swift @@ -15,6 +15,25 @@ final class WidgetRegistryTests: XCTestCase { ) } + func testNewAccountCardsSlotInAfterTheirFamilyGroup() { + let registry = WidgetRegistry( + providers: [provider("claude"), provider("claude@ab12cd34"), provider("codex"), provider("cursor")], + descriptors: [] + ) + + // The saved order predates the account card: it appears right after the claude group, not + // at the end of the dashboard; a genuinely new provider still appends. + XCTAssertEqual( + registry.orderedProviderIDs(savedOrder: ["codex", "claude"]), + ["codex", "claude", "claude@ab12cd34", "cursor"] + ) + // With no family sibling in the saved order, the card appends like any new provider. + XCTAssertEqual( + registry.orderedProviderIDs(savedOrder: ["codex"]), + ["codex", "claude", "claude@ab12cd34", "cursor"] + ) + } + func testLookupsReturnExpectedEntries() { let claude = provider("claude") let codex = provider("codex") diff --git a/docs/assets/claude-fable-weekly.png b/docs/assets/claude-fable-weekly.png deleted file mode 100644 index 53a713970..000000000 Binary files a/docs/assets/claude-fable-weekly.png and /dev/null differ diff --git a/docs/assets/cursor-enterprise-live.png b/docs/assets/cursor-enterprise-live.png deleted file mode 100644 index a5d3531b0..000000000 Binary files a/docs/assets/cursor-enterprise-live.png and /dev/null differ diff --git a/docs/assets/cursor-grok-bot-models.png b/docs/assets/cursor-grok-bot-models.png deleted file mode 100644 index 868a75fa2..000000000 Binary files a/docs/assets/cursor-grok-bot-models.png and /dev/null differ diff --git a/docs/assets/menu-bar-privacy-idle.png b/docs/assets/menu-bar-privacy-idle.png deleted file mode 100644 index 1e80763e0..000000000 Binary files a/docs/assets/menu-bar-privacy-idle.png and /dev/null differ diff --git a/docs/assets/menu-bar-privacy-sharing.png b/docs/assets/menu-bar-privacy-sharing.png deleted file mode 100644 index 30f086d7f..000000000 Binary files a/docs/assets/menu-bar-privacy-sharing.png and /dev/null differ diff --git a/docs/dashboard.md b/docs/dashboard.md index 6888f41d8..70a586467 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -8,6 +8,15 @@ A fresh install doesn't turn on every provider OpenUsage knows about. It starts This full detection only happens on a brand-new install. Updates never change the providers you already have on or off — but when an update ships a provider you've never seen, the same local check runs once for just that provider and turns it on only if you actually have the tool. See [Which Providers Are On](provider-enablement.md) for the full lifecycle. +When OpenUsage finds multiple verified Claude logins, each account gets its own provider card. Changing +the active Claude Code login while the app is running updates those cards automatically; an account's +name, metric choices, menu-bar stars, and position stay with that account instead of following the +login location. If an account temporarily disappears, its card stays hidden until a verified login +for that same account returns. Account cards use their organization names when more than one account +is present, so a work account and its personal counterpart can appear as **Claude — SUNSTORY** and +**Claude — Personal** even when both use the same email address. Codex still uses a single card; +if its verified account changes, that card's name and custom label follow the current account. + Each provider card leads with its **Always Visible** metrics. Any metrics you've moved below the **On Demand** line are tucked away behind the in-card caret — click it to reveal them below the caret, click again to collapse. Open cards stay open across popover closes and app restarts. A provider with neither On Demand metrics nor quick links shows no caret. When you expand a card, the tucked-away metrics open below the caret as a single-column list, so each detail row keeps the full card width. @@ -16,13 +25,18 @@ A provider card can also show **quick-link buttons** pinned at the bottom of its ## Total Spend -When any enabled provider tracks daily spend (Claude, Codex, Cursor, Grok, or OpenCode), a card sits above the provider sections. The title is a pull-down menu for **Cost**, **Cost/MTok**, or **Tokens** (Cost is the default; the choice sticks across restarts). A capsule switcher flips the period between **Today**, **Yesterday**, and **30 Days**. The ring, center total, and ranked legend follow the selected metric: +When any enabled provider tracks daily spend (Antigravity, Claude, Codex, Cursor, Grok, or OpenCode), a card sits above the provider sections. The title is a pull-down menu for **Cost**, **Cost/MTok**, or **Tokens** (Cost is the default; the choice sticks across restarts). A capsule switcher flips the period between **Today**, **Yesterday**, and **30 Days**. The ring, center total, and ranked legend follow the selected metric: - **Cost** — each segment is that provider's share of combined dollars (biggest spender first). - **Cost/MTok** — each segment is sized by that provider's dollars-per-million-tokens rate; the center is the blended rate across providers that have both spend and tokens; the legend lists each provider's own rate. - **Tokens** — each segment is that provider's share of combined tokens. -The ring center is always two short lines — a compact number on top and a quiet unit underneath (`$533` / `dollars`, `12.4` / `million`, or `$1.37` / `MTok`) — so Cost/MTok and big totals stay readable in the hole. Cost modes keep the `$` on the number. Hover the center for the exact one-line figure (and a note when any contributor's dollars are a local estimate — Cost and Cost/MTok only). Each provider keeps a fixed color drawn from its brand (Claude's terracotta, OpenAI's green, and so on), and even a tiny share keeps a visible sliver of the ring. Providers with nothing for the selected metric simply don't appear — they're never counted as zero. (An enabled provider counts even if you've hidden its own spend rows in Customize; other dollar rows, like OpenRouter's API spend, never mix in.) The header's share icon (or right-clicking the card) copies a branded PNG of the ring to your clipboard, just like sharing a provider card. The header also carries a small ⓘ naming the providers that feed the total. A period with nothing to show for the active metric shows a quiet empty state instead of hiding the card. Don't want the card at all? Turn it off with **Show Total Spend** at the top of [Settings](settings.md). +Each Claude account gets its own ring segment and legend entry instead of being folded into a single +Claude total. The ring and legend are ranked by the selected amount, highest first, even when you've +dragged the dashboard cards into a different order. A verified account that only exists on another +synced Mac can also contribute its own separate account-code entry without creating a local card. + +The ring center is always two short lines — a compact number on top and a quiet unit underneath (`$533` / `dollars`, `12.4` / `million`, or `$1.37` / `MTok`) — so Cost/MTok and big totals stay readable in the hole. Cost modes keep the `$` on the number. Hover the center for the exact one-line figure (and a note when any contributor's dollars are a local estimate — Cost and Cost/MTok only). Each provider keeps a fixed brand color, such as Claude's terracotta or OpenAI's green; additional accounts use consistent related shades so separate accounts remain distinguishable as the chart re-sorts. Even a tiny share keeps a visible sliver of the ring. Providers with nothing for the selected metric simply don't appear — they're never counted as zero. (An enabled provider counts even if you've hidden its own spend rows in Customize; other dollar rows, like OpenRouter's API spend, never mix in.) The header's share icon (or right-clicking the card) copies a branded PNG of the ring to your clipboard, just like sharing a provider card. The header also carries a small ⓘ naming the providers that feed the total. A period with nothing to show for the active metric shows a quiet empty state instead of hiding the card. Don't want the card at all? Turn it off with **Show Total Spend** at the top of [Settings](settings.md). ## Rows @@ -52,7 +66,7 @@ Rows with a reset date tick every 30 seconds, so countdowns and pace stay live b ## Right-click menus Every row: **Hide · Star for menu bar / Unstar · Refresh \ · Customize…** (Customize opens straight to that provider's metrics.) -Provider headers: **Hide \ · Refresh \ · Customize…** (Hide turns the whole provider off; turn it back on in Customize. Customize opens straight to that provider's metrics.) plus **Share Screenshot** (see below). +Provider headers: **Hide \ · Refresh \ · Customize…** (Hide turns the whole provider off; turn it back on in Customize. Customize opens straight to that provider's metrics.) plus **Share Screenshot** (see below). Claude and Codex cards also offer **Rename…** — give the card any name you like (handy with multiple accounts); leave the field empty to go back to the default name. The name follows the card everywhere it's shown: the dashboard, the Total Spend legend, share screenshots, notifications, and the CLI/API output. ## Share @@ -73,7 +87,7 @@ Open Customize from the footer's **Options** menu (or press **Return**). It's a The **provider list** shows every provider with a switch to turn it on or off, a count of its metrics, and a chevron into its detail. Turn a provider off and it stays in the list, greyed — its metrics hide from the dashboard and menu bar but keep their setup for when you turn it back on. Drag enabled providers by their grip to reorder; tap a row to open its detail. On a fresh install only the providers detected on your Mac start on (see "First launch" above); this list is where you add the rest. -A provider's **detail** has a back button and provider-specific Reset control in its top bar, followed by two metric sections: **Always Visible** (shown on the dashboard card) and **On Demand** (tucked behind the card's caret). Each metric row has a drag grip, its name, an always-visible star for the menu bar, and an on/off switch. Drag a metric into the other card—or onto one of that card's rows—to move it between Always Visible and On Demand. An empty card shows a dashed **Drag metrics here** target. You can star up to two metrics per provider. OpenRouter and Z.ai also show an **API Key** section here, where you can add, replace, reveal, or clear that provider's key. +A provider's **detail** has a back button and provider-specific Reset control in its top bar. Claude and Codex cards start with a **Name** field — the same rename the card's right-click menu offers; clear it to go back to the default name. Then come two metric sections: **Always Visible** (shown on the dashboard card) and **On Demand** (tucked behind the card's caret). Each metric row has a drag grip, its name, an always-visible star for the menu bar, and an on/off switch. Drag a metric into the other card—or onto one of that card's rows—to move it between Always Visible and On Demand. An empty card shows a dashed **Drag metrics here** target. You can star up to two metrics per provider. OpenRouter and Z.ai also show an **API Key** section here, where you can add, replace, reveal, or clear that provider's key. Drag-reorder also works directly on the dashboard — drag a row within its provider, drag it across the caret boundary while the card is open, or drag a provider header to reorder sections. On a Force Touch trackpad you'll feel a light tap each time the dragged item snaps into a new slot. diff --git a/docs/icloud-sync.md b/docs/icloud-sync.md index e8f365385..dd40320dd 100644 --- a/docs/icloud-sync.md +++ b/docs/icloud-sync.md @@ -7,10 +7,11 @@ existing file after app preferences are reset or the app is reinstalled. There i pairing code, or separate account. The file contains normalized daily tokens and spend, model totals, and unknown-model names for sources -that are local to one Mac: Claude, Codex, Grok, and OpenCode. It does not contain credentials, account -limits, raw logs, or provider responses. Cursor's history is already account-wide, so it stays local and -is never added across Macs. Disabling a provider immediately removes its peer contributions from the -combined view and omits it from this Mac's next iCloud write, while its local cached snapshot remains. +that are local to one Mac: Antigravity, Claude, Codex, Grok, and OpenCode. It does not contain +credentials, account limits, raw logs, or provider responses. Cursor's history is already account-wide, +so it stays local and is never added across Macs. Disabling a provider immediately removes its peer +contributions from the combined view and omits it from this Mac's next iCloud write, while its local +cached snapshot remains. OpenUsage combines the valid files in memory and rebuilds Today, Yesterday, Last 30 Days, Usage Trend, unknown-model warnings, and model breakdowns. The same combined spend rows feed the dashboard, Total @@ -24,9 +25,44 @@ This Mac updates its file after a five-minute refresh batch, a manual refresh, o change. iCloud delivery is eventually consistent, so another Mac can take longer than five minutes to receive it, especially while offline. Downloaded changes reload immediately when macOS reports them. +## Multiple accounts across Macs + +Histories match by **account**, not by card name. Each Mac's private iCloud file records the stable +account and organization identifiers supplied by the provider, so the same account merges into the same +card everywhere, even when one Mac shows it as the main card and another as an extra account card. +Claude account histories still match when one Mac omits an organization ID and the other includes it, +but only when all known identities establish exactly one possible organization. If multiple +organizations could match, that history is left out until its owner can be verified. +These identifiers are stored as supplied, not hashed, but never include an email address, account name, +login credential, plan, or quota. + +OpenUsage only combines account history when both Macs identify its owner. If either Mac cannot +identify an account, two cards claim the same account, or an older Mac sends account history without +identity information, that history is temporarily left out instead of being assigned to the wrong +card. Once the account can be identified, its history joins the matching card again. A Codex login kept +in the system keychain proves its identity during its normal successful refresh, so its history starts +syncing afterward without an extra Keychain read or a new permission prompt. If that same verified +login refreshes its access token or temporarily stops reporting its account identifier, syncing +continues; a different login without an identifiable owner stays excluded until its account can be +verified. + +An account you use on another Mac but have no login for here doesn't become a card. If this Mac already +has another enabled, verified account from the same provider, the remote account appears as its own +slice in **Total Spend**, named by its account code ("claude@ab12cd34"). The code is derived from the +account identity; an account that was a Mac's original Claude login can still keep the plain `claude` +card ID there. When you log that account in locally, its verified identity attaches the full +cross-machine history to the correct card regardless of which ID that card has. + +Update every syncing Mac together. Older OpenUsage builds reject the entire newer account-aware history +file, including other providers, and report that OpenUsage needs updating. Newer builds can still read +older files, but leave their Claude and Codex histories out because those accounts cannot be identified; +eligible non-account providers from older files still merge on the newer Mac. + Settings lists each valid device file with the time that Mac generated it. To remove a Mac from the combined summary, turn sync off on that Mac; this deletes its file from iCloud. Turning sync off also -stops that Mac from reading peers and immediately returns every surface there to local-only spend. +stops that Mac from reading peers and immediately returns every surface there to local-only spend, +even when a signed-in account changes at the same time. Pending updates from a previous account cannot +replace the current account's synced history. Malformed files are ignored and reported in Settings and the app log. ## Development and release setup diff --git a/docs/local-http-api.md b/docs/local-http-api.md index 9055dcbf0..d6098dae1 100644 --- a/docs/local-http-api.md +++ b/docs/local-http-api.md @@ -170,6 +170,8 @@ Line types are `progress`, `text`, `badge`, and `barChart`. A `barChart` line ca The in-app model breakdown shown when hovering spend rows is not included in this API yet. Spend rows continue to serialize as the same `text` lines so existing local integrations keep their current shape. +In both response shapes, `displayName` is the card's current name — if you renamed a card in the app, the rename shows here too. Match on `providerId` (or the envelope key), never on the name. + ## Errors ```json diff --git a/docs/menu-bar.md b/docs/menu-bar.md index e2905c83a..f0f6076c7 100644 --- a/docs/menu-bar.md +++ b/docs/menu-bar.md @@ -27,14 +27,6 @@ Settings → Privacy → **Hide From Screen Share** (off by default). While your Detection rides the system's own "an app is capturing the screen" signal — the same one that lights the capture indicator in the menu bar — checked the instant it changes and re-checked every few seconds while the setting is on. -Normally: - -![The menu bar strip showing usage values](assets/menu-bar-privacy-idle.png) - -While the screen is shared or recorded: - -![The menu bar strip concealed behind the OpenUsage wordmark](assets/menu-bar-privacy-sharing.png) - ## What the strip shows The strip only renders real data. A starred metric with nothing fetched yet is skipped; a provider whose stars all lack data disappears entirely (icon included). When nothing has data, the strip falls back to the app icon. Stars follow your Customize order — Always Visible metrics first, then On Demand ones. A metric can be starred whether it's Always Visible or On Demand. diff --git a/docs/privacy.md b/docs/privacy.md index 2d1610294..d9f78fc6e 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -15,6 +15,9 @@ app and macOS version, which providers and metrics you have enabled, and which m to the menu bar or tucked behind the "show more" caret. A random ID (not tied to you or any account) lets us count daily active users without identifying anyone. +When a provider has multiple accounts, analytics report only the provider and metric names. Account +identifiers, account-specific card identifiers, and the number of accounts are never included. + - **Crash reports** — if OpenUsage crashes, it saves a report and sends it the next time you open the app: the technical stack trace (which parts of *OpenUsage's own code* were running when it crashed) plus the app and macOS version. This contains no account details, credentials, or usage values — @@ -62,10 +65,12 @@ identity caches that have not been used for 35 days are removed. OpenUsage's pri the cache is read, so its computed aggregates and totals are not persisted in this cache. If you explicitly turn on [iCloud Sync](icloud-sync.md), OpenUsage writes normalized daily tokens, -spend, and model totals to its private iCloud container so your own Macs can show one combined summary. -Credentials, account limits, provider responses, and raw logs are never written there. This is separate -from anonymous usage analytics: iCloud Sync defaults off and uses your iCloud account, while the -analytics toggle controls extra PostHog events, not daily activity or crash reports. +spend, model totals, and provider-issued account and organization identifiers to its private iCloud +container so your own Macs can match the right accounts. Those identifiers are stored as supplied by +the provider; email addresses, account names, credentials, account limits, provider responses, and raw +logs are never written there. This is separate from anonymous usage analytics: iCloud Sync defaults off +and uses your iCloud account, while the analytics toggle controls extra PostHog events, not daily +activity or crash reports. ## How it works diff --git a/docs/provider-enablement.md b/docs/provider-enablement.md index ff1bb79db..afa3770e4 100644 --- a/docs/provider-enablement.md +++ b/docs/provider-enablement.md @@ -15,6 +15,8 @@ The same detection runs for providers that arrive later. On the first launch aft This check happens **once per provider**. After that, the provider is yours to manage: if you turn it off, no update will ever turn it back on, and installing the tool later won't flip it on behind your back either — head to Customize when you want it. +If your account changes while a check is still running, OpenUsage resumes that unfinished check after rebuilding the account list. It never repeats a completed check or overrides a provider switch you changed yourself. + ## Your choices always stick Everything you set in Customize — providers on or off, metric layout, menu-bar stars — carries across updates untouched. The only thing an update may ever change is turning **on** a provider you have never seen before, and only when you actually have that tool installed. @@ -27,6 +29,7 @@ The app persists three small lists in its settings: - **Enabled providers** — the providers currently on. This is the source of truth the dashboard and menu bar read. - **Known providers** — every provider this install has ever seen. This is what makes "new in this update" distinguishable from "you turned it off": a provider missing from the enabled list but present in the known list is a deliberate choice, and is left alone. Only providers missing from *both* get the credential check, and each is marked known immediately so the check never repeats. +- **Pending checks** — providers whose first credential check has started but hasn't finished yet. This short-lived list lets an interrupted check resume without turning a completed check into a recurring one. - Each provider implements a cheap, local-only credential probe (`hasLocalCredentials()`) — the same files, keychain entries, saved keys, and environment variables its normal refresh reads, never the network. Older installs (from before first-run detection existed) started with every provider on and stored only the ones turned *off*. A one-time settings migration converts them to the lists above with the exact same providers on and off as before — nothing visibly changes on the launch that migrates; those installs simply join the same new-provider detection from then on. diff --git a/docs/providers/claude.md b/docs/providers/claude.md index 443f2c898..84465cbf3 100644 --- a/docs/providers/claude.md +++ b/docs/providers/claude.md @@ -45,6 +45,41 @@ Today / Yesterday / Last 30 Days are computed **locally**: OpenUsage reads the C Local spend does not require a Claude OAuth login. If Claude Code uses an API-key gateway instead, the spend tiles and usage trend still load from its session logs; the Claude header shows **Not logged in** because the live Session and Weekly meters still require a Claude subscription login. +## Multiple accounts + +OpenUsage discovers separate Claude Code logins in hidden folders directly under your home directory +and folders directly under `~/.config`. Each account gets its own card, limits, plan, and local spend; +another folder signed into the same account contributes to the existing card instead of creating a +duplicate. The account using `$CLAUDE_CONFIG_DIR` is treated as the default login, even when that +folder lives elsewhere. Cards stay attached to their original account when the default login changes. + +Cowork session folders are assigned to the account named by their own Claude state. A separate +Claude Desktop account needs at least one Cowork session identifying its organization and a matching +cached Desktop login; signing into Desktop alone does not create a card. Desktop credentials are +pinned to their verified or uniquely remembered organization, so switching Desktop's active +organization cannot make a card borrow another account's usage. If users share an organization, or +another known user's organization is missing, its Desktop login is not used until the owner can be +verified. Previously seen accounts remain safety checks even while their cards are hidden. Sessions +without a provable owner are left unassigned when multiple accounts are present. An incomplete session +scan temporarily withholds Desktop spend history while a verified single-account login can still show +its live limits. Old Cowork sessions can keep a signed-out organization visible with a login warning. + +Changes to the default Claude Code login are detected within about five seconds; custom folders, +Desktop logins, and new Cowork sessions are checked about once a minute. Existing sessions update on +normal refreshes. The first Desktop refresh may ask for Keychain permission; choose **Always Allow**. +Subscription upgrades or downgrades appear after Claude Code or Desktop updates its saved login +details and OpenUsage refreshes; OpenUsage does not make a separate billing request. + +Cards keep their account identity, layout, and menu-bar pins when a login moves between sources or +temporarily disappears. When several accounts share the same email address, their cards use the +organization name instead; Claude's generic email-based organization becomes **Personal**. For +example, **Claude — SUNSTORY** and **Claude — Personal** stay easy to tell apart. Right-click a card +and choose **Rename…**, or change its name in Customize. Extra cards have identifiers such as +`claude@ab12cd34`; the original account keeps the existing +`claude` identifier even when it no longer occupies the default login. In the local API and CLI, +requesting `claude` returns every Claude account. There are no manual Add Account or Remove Account +controls; sign in or out through Claude Code or Claude Desktop instead. + ## Troubleshooting - **"Not logged in"** — run `claude` and sign in to enable live subscription limits, then refresh. If you use an API-key gateway, local spend still appears whenever Claude Code has written session logs.