From bc027be92746157db7fc77520098d73682de3896 Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 10 May 2026 19:38:48 +0200 Subject: [PATCH 1/3] feat(multi-account): track multiple Claude.ai accounts in parallel Replaces the single-account model with a list of ClaudeAccount entries keyed in settings, the keychain, and the disk cache by per-account UUID. Each configured account gets its own NSStatusItem with a dedicated popover so the menu bar shows freelance and client usage side by side. Setup wizard now collects a label, and Settings exposes add/edit/rename/ remove for accounts. Existing installs migrate the legacy 'cached_organization_id' setting and 'default' keychain slot to the new schema on first bootstrap. Co-Authored-By: Claude Opus 4.7 (1M context) --- ClaudeMeter/App/AppModel.swift | 222 ++++++--- ClaudeMeter/Models/AppSettings.swift | 65 ++- ClaudeMeter/Models/ClaudeAccount.swift | 41 ++ .../Repositories/CacheRepository.swift | 103 ++--- .../Protocols/CacheRepositoryProtocol.swift | 23 +- .../Protocols/UsageServiceProtocol.swift | 14 +- ClaudeMeter/Services/UsageService.swift | 57 +-- ClaudeMeter/Views/MenuBar/IconCache.swift | 18 +- .../Views/MenuBar/MenuBarIconRenderer.swift | 6 +- .../Views/MenuBar/MenuBarIconView.swift | 16 +- .../Views/MenuBar/MenuBarManager.swift | 228 ++++++--- .../Views/MenuBar/MenuBarPopoverView.swift | 18 +- .../Views/MenuBar/UsagePopoverView.swift | 238 +++++----- ClaudeMeter/Views/Settings/SettingsView.swift | 437 ++++++++++-------- ClaudeMeter/Views/Setup/SetupWizardView.swift | 50 +- ClaudeMeterTests/AppModelTests.swift | 312 +++++-------- .../SettingsRepositoryTests.swift | 35 +- .../TestDoubles/CacheRepositoryFake.swift | 38 +- .../TestDoubles/KeychainRepositoryFake.swift | 18 +- .../TestDoubles/UsageServiceStub.swift | 11 +- ClaudeMeterTests/UsageServiceTests.swift | 136 ++---- ...BarIcon_showsBatteryStyleWhenWarning.1.png | Bin 3097 -> 3158 bytes ...arIcon_showsCircularStyleWhenWarning.1.png | Bin 2326 -> 2372 bytes ...BarIcon_showsDualBarStyleWhenWarning.1.png | Bin 2121 -> 2213 bytes ...nuBarIcon_showsGaugeStyleWhenWarning.1.png | Bin 1670 -> 1787 bytes ..._showsLoadingIndicatorInBatteryStyle.1.png | Bin 1044 -> 1043 bytes ...BarIcon_showsMinimalStyleWhenWarning.1.png | Bin 1762 -> 1870 bytes ...arIcon_showsSegmentsStyleWhenWarning.1.png | Bin 861 -> 847 bytes ...on_showsStaleIndicatorInBatteryStyle.1.png | Bin 2383 -> 2366 bytes 29 files changed, 1188 insertions(+), 898 deletions(-) create mode 100644 ClaudeMeter/Models/ClaudeAccount.swift diff --git a/ClaudeMeter/App/AppModel.swift b/ClaudeMeter/App/AppModel.swift index 7fcece2..8fc9395 100644 --- a/ClaudeMeter/App/AppModel.swift +++ b/ClaudeMeter/App/AppModel.swift @@ -9,6 +9,14 @@ import AppKit import Foundation import Observation +/// Per-account fetch state. Stored in dictionaries keyed by account id on AppModel. +struct AccountUsageState: Equatable, Sendable { + var usageData: UsageData? + var isLoading: Bool = false + var isRefreshing: Bool = false + var errorMessage: String? +} + /// Main application model for SwiftUI-first architecture. @MainActor @Observable @@ -22,11 +30,8 @@ final class AppModel { } } - var usageData: UsageData? - var isLoading: Bool = false - var isRefreshing: Bool = false - var errorMessage: String? - var isSetupComplete: Bool = false + /// Per-account usage state. Read via `state(for:)`. + var accountStates: [UUID: AccountUsageState] = [:] var isReady: Bool = false // MARK: - Dependencies @@ -44,6 +49,9 @@ final class AppModel { @ObservationIgnored private var hasLoadedSettings: Bool = false @ObservationIgnored private let refreshClock = ContinuousClock() + /// Account name used by the legacy single-account schema (pre multi-account migration). + @ObservationIgnored private let legacyKeychainAccount = "default" + // MARK: - Initialization init( @@ -60,8 +68,7 @@ final class AppModel { let usageService = usageService ?? UsageService( networkService: networkService, cacheRepository: cacheRepository, - keychainRepository: keychainRepository, - settingsRepository: settingsRepository + keychainRepository: keychainRepository ) self.usageService = usageService self.notificationService = notificationService ?? NotificationService( @@ -78,68 +85,104 @@ final class AppModel { settings = await settingsRepository.load() hasLoadedSettings = true - isSetupComplete = await keychainRepository.exists(account: "default") + await migrateLegacyKeychainIfNeeded() + isReady = true - if isSetupComplete { - await refreshUsage(forceRefresh: true) + if !settings.accounts.isEmpty { + await refreshAllUsage(forceRefresh: true) startRefreshLoop() } startWakeObserver() } + /// Convenience: returns whether any account is configured. + var isSetupComplete: Bool { !settings.accounts.isEmpty } + + func state(for accountId: UUID) -> AccountUsageState { + accountStates[accountId] ?? AccountUsageState() + } + // MARK: - Usage - func refreshUsage(forceRefresh: Bool = false) async { - guard isSetupComplete else { - usageData = nil + /// Refresh a single account. + func refreshUsage(accountId: UUID, forceRefresh: Bool = false) async { + guard let account = settings.account(withId: accountId) else { + accountStates[accountId] = nil return } - guard !isRefreshing else { return } - if usageData == nil { - isLoading = true + var state = state(for: accountId) + guard !state.isRefreshing else { return } + + if state.usageData == nil { + state.isLoading = true } - isRefreshing = true - errorMessage = nil + state.isRefreshing = true + state.errorMessage = nil + accountStates[accountId] = state defer { - isLoading = false - isRefreshing = false + var s = self.state(for: accountId) + s.isLoading = false + s.isRefreshing = false + self.accountStates[accountId] = s } do { - let data = try await usageService.fetchUsage(forceRefresh: forceRefresh) - usageData = data + let isPrimary = settings.accounts.first?.id == accountId + let data = try await usageService.fetchUsage( + for: account, + isPrimary: isPrimary, + forceRefresh: forceRefresh + ) + var done = self.state(for: accountId) + done.usageData = data + self.accountStates[accountId] = done + await notificationService.evaluateThresholds( usageData: data, settings: settings ) } catch { - errorMessage = error.localizedDescription + var failed = self.state(for: accountId) + failed.errorMessage = error.localizedDescription + self.accountStates[accountId] = failed + } + } + + /// Refresh every configured account in parallel. + func refreshAllUsage(forceRefresh: Bool = false) async { + let accountIds = settings.accounts.map(\.id) + await withTaskGroup(of: Void.self) { group in + for id in accountIds { + group.addTask { @MainActor in + await self.refreshUsage(accountId: id, forceRefresh: forceRefresh) + } + } } } - // MARK: - Session Key + // MARK: - Account Management - func loadSessionKey() async -> String? { + /// Look up the session key for an account from the keychain. + func loadSessionKey(accountId: UUID) async -> String? { + guard let account = settings.account(withId: accountId) else { return nil } do { - return try await keychainRepository.retrieve(account: "default") - } catch KeychainError.notFound { - return nil + return try await keychainRepository.retrieve(account: account.keychainAccount) } catch { return nil } } - func validateAndSaveSessionKey(_ rawValue: String) async throws -> Bool { + /// Validate a session key, look up its organization, and create a new account. + /// - Returns: the freshly added account on success, or `nil` if validation failed. + @discardableResult + func addAccount(label: String, sessionKey rawValue: String) async throws -> ClaudeAccount? { let sessionKey = try SessionKey(rawValue) let isValid = try await usageService.validateSessionKey(sessionKey) - - guard isValid else { - return false - } + guard isValid else { return nil } let organizations = try await usageService.fetchOrganizations(sessionKey: sessionKey) guard let firstOrg = organizations.first, @@ -147,26 +190,64 @@ final class AppModel { throw AppError.organizationNotFound } - try await keychainRepository.save(sessionKey: sessionKey.value, account: "default") + let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedLabel = trimmed.isEmpty ? defaultLabelForNewAccount() : trimmed + + let account = ClaudeAccount(label: resolvedLabel, organizationId: orgUUID) + try await keychainRepository.save(sessionKey: sessionKey.value, account: account.keychainAccount) - settings.cachedOrganizationId = orgUUID + settings.accounts.append(account) settings.isFirstLaunch = false - isSetupComplete = true - await refreshUsage(forceRefresh: true) + await refreshUsage(accountId: account.id, forceRefresh: true) startRefreshLoop() + return account + } + + /// Replace the session key for an existing account (without changing its id/label). + @discardableResult + func updateSessionKey(accountId: UUID, _ rawValue: String) async throws -> Bool { + guard let index = settings.accounts.firstIndex(where: { $0.id == accountId }) else { + return false + } + let sessionKey = try SessionKey(rawValue) + let isValid = try await usageService.validateSessionKey(sessionKey) + guard isValid else { return false } + + let organizations = try await usageService.fetchOrganizations(sessionKey: sessionKey) + guard let firstOrg = organizations.first, + let orgUUID = firstOrg.organizationUUID else { + throw AppError.organizationNotFound + } + + try await keychainRepository.save( + sessionKey: sessionKey.value, + account: settings.accounts[index].keychainAccount + ) + settings.accounts[index].organizationId = orgUUID + await refreshUsage(accountId: accountId, forceRefresh: true) return true } - func clearSessionKey() async throws { - try await keychainRepository.delete(account: "default") - settings.cachedOrganizationId = nil - settings.isFirstLaunch = true - isSetupComplete = false - usageData = nil - errorMessage = nil - refreshTask?.cancel() + /// Rename an account. + func renameAccount(_ accountId: UUID, label: String) { + guard let index = settings.accounts.firstIndex(where: { $0.id == accountId }) else { return } + let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + settings.accounts[index].label = trimmed + } + + /// Remove an account: deletes its session key and any cached state. + func removeAccount(_ accountId: UUID) async throws { + guard let account = settings.account(withId: accountId) else { return } + try? await keychainRepository.delete(account: account.keychainAccount) + settings.accounts.removeAll(where: { $0.id == accountId }) + accountStates[accountId] = nil + if settings.accounts.isEmpty { + settings.isFirstLaunch = true + refreshTask?.cancel() + } } // MARK: - Notifications @@ -192,10 +273,34 @@ final class AppModel { // MARK: - Private + /// One-time migration from the pre-multi-account schema: when settings still hold a single + /// "Default" account synthesised from the legacy `cached_organization_id`, copy the keychain + /// session key from the legacy "default" slot to the new per-account UUID slot. + private func migrateLegacyKeychainIfNeeded() async { + guard await keychainRepository.exists(account: legacyKeychainAccount) else { return } + guard let firstAccount = settings.accounts.first else { return } + // Already migrated if the new slot has a key. + if await keychainRepository.exists(account: firstAccount.keychainAccount) { return } + + do { + let key = try await keychainRepository.retrieve(account: legacyKeychainAccount) + try await keychainRepository.save(sessionKey: key, account: firstAccount.keychainAccount) + try await keychainRepository.delete(account: legacyKeychainAccount) + } catch { + // If the legacy slot can't be read, leave it alone — the user will be prompted to re-enter. + } + } + + private func defaultLabelForNewAccount() -> String { + let n = settings.accounts.count + 1 + return "Account \(n)" + } + private func scheduleSettingsSave(previous: AppSettings) { settingsSaveTask?.cancel() - settingsSaveTask = Task { - try? await settingsRepository.save(settings) + let snapshot = settings + settingsSaveTask = Task { [settingsRepository] in + try? await settingsRepository.save(snapshot) } if previous.refreshInterval != settings.refreshInterval { @@ -205,14 +310,14 @@ final class AppModel { private func startRefreshLoop() { refreshTask?.cancel() - guard isSetupComplete else { return } + guard !settings.accounts.isEmpty else { return } let interval = Duration.seconds(Int(settings.refreshInterval)) refreshTask = Task { [weak self] in guard let self else { return } while !Task.isCancelled { try? await self.refreshClock.sleep(for: interval) - await self.refreshUsage() + await self.refreshAllUsage() } } } @@ -222,7 +327,7 @@ final class AppModel { wakeTask = Task { [weak self] in guard let self else { return } for await _ in NSWorkspace.shared.notificationCenter.notifications(named: NSWorkspace.didWakeNotification) { - await self.refreshUsage(forceRefresh: true) + await self.refreshAllUsage(forceRefresh: true) } } } @@ -238,14 +343,21 @@ final class AppModel { errorMessage: String?, isLoading: Bool ) { - self.usageData = usageData - self.isSetupComplete = isSetupComplete - self.errorMessage = errorMessage - self.isLoading = isLoading + if isSetupComplete { + let demoAccount = ClaudeAccount(label: "Demo") + settings.accounts = [demoAccount] + var state = AccountUsageState() + state.usageData = usageData + state.errorMessage = errorMessage + state.isLoading = isLoading + accountStates[demoAccount.id] = state + } else { + settings.accounts = [] + accountStates = [:] + } self.isReady = true self.hasLoadedSettings = true // Don't start refresh loop or wake observer in demo mode } #endif - } diff --git a/ClaudeMeter/Models/AppSettings.swift b/ClaudeMeter/Models/AppSettings.swift index 2719c9c..e4e2ae2 100644 --- a/ClaudeMeter/Models/AppSettings.swift +++ b/ClaudeMeter/Models/AppSettings.swift @@ -21,8 +21,8 @@ struct AppSettings: Codable, Equatable, Sendable { /// Whether this is first launch var isFirstLaunch: Bool - /// Last known organization ID (cached) - var cachedOrganizationId: UUID? + /// Configured Claude.ai accounts. Multi-account: each has its own keychain entry and menu bar item. + var accounts: [ClaudeAccount] /// Whether to show Sonnet usage in the popover var isSonnetUsageShown: Bool @@ -35,7 +35,7 @@ struct AppSettings: Codable, Equatable, Sendable { hasNotificationsEnabled: true, notificationThresholds: .default, isFirstLaunch: true, - cachedOrganizationId: nil, + accounts: [], isSonnetUsageShown: false, iconStyle: .battery ) @@ -45,9 +45,61 @@ struct AppSettings: Codable, Equatable, Sendable { case hasNotificationsEnabled = "notifications_enabled" case notificationThresholds = "notification_thresholds" case isFirstLaunch = "is_first_launch" - case cachedOrganizationId = "cached_organization_id" + case accounts case isSonnetUsageShown = "show_sonnet_usage" case iconStyle = "icon_style" + // Legacy keys, decoded only for migration + case legacyCachedOrganizationId = "cached_organization_id" + } + + init( + refreshInterval: TimeInterval, + hasNotificationsEnabled: Bool, + notificationThresholds: NotificationThresholds, + isFirstLaunch: Bool, + accounts: [ClaudeAccount], + isSonnetUsageShown: Bool, + iconStyle: IconStyle + ) { + self.refreshInterval = refreshInterval + self.hasNotificationsEnabled = hasNotificationsEnabled + self.notificationThresholds = notificationThresholds + self.isFirstLaunch = isFirstLaunch + self.accounts = accounts + self.isSonnetUsageShown = isSonnetUsageShown + self.iconStyle = iconStyle + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let defaults = AppSettings.default + refreshInterval = try container.decodeIfPresent(TimeInterval.self, forKey: .refreshInterval) ?? defaults.refreshInterval + hasNotificationsEnabled = try container.decodeIfPresent(Bool.self, forKey: .hasNotificationsEnabled) ?? defaults.hasNotificationsEnabled + notificationThresholds = try container.decodeIfPresent(NotificationThresholds.self, forKey: .notificationThresholds) ?? defaults.notificationThresholds + isFirstLaunch = try container.decodeIfPresent(Bool.self, forKey: .isFirstLaunch) ?? defaults.isFirstLaunch + isSonnetUsageShown = try container.decodeIfPresent(Bool.self, forKey: .isSonnetUsageShown) ?? defaults.isSonnetUsageShown + iconStyle = try container.decodeIfPresent(IconStyle.self, forKey: .iconStyle) ?? defaults.iconStyle + + if let decoded = try container.decodeIfPresent([ClaudeAccount].self, forKey: .accounts) { + accounts = decoded + } else if let legacyOrgId = try container.decodeIfPresent(UUID.self, forKey: .legacyCachedOrganizationId) { + // Migrate from single-account schema: synthesize a default account using the cached org id. + // The session key will be moved out of keychain account "default" at app bootstrap. + accounts = [ClaudeAccount(label: "Default", organizationId: legacyOrgId)] + } else { + accounts = [] + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(refreshInterval, forKey: .refreshInterval) + try container.encode(hasNotificationsEnabled, forKey: .hasNotificationsEnabled) + try container.encode(notificationThresholds, forKey: .notificationThresholds) + try container.encode(isFirstLaunch, forKey: .isFirstLaunch) + try container.encode(accounts, forKey: .accounts) + try container.encode(isSonnetUsageShown, forKey: .isSonnetUsageShown) + try container.encode(iconStyle, forKey: .iconStyle) } } @@ -56,4 +108,9 @@ extension AppSettings { mutating func setRefreshInterval(_ interval: TimeInterval) { refreshInterval = max(60, min(600, interval)) } + + /// Find an account by id. + func account(withId id: UUID) -> ClaudeAccount? { + accounts.first(where: { $0.id == id }) + } } diff --git a/ClaudeMeter/Models/ClaudeAccount.swift b/ClaudeMeter/Models/ClaudeAccount.swift new file mode 100644 index 0000000..5c55e70 --- /dev/null +++ b/ClaudeMeter/Models/ClaudeAccount.swift @@ -0,0 +1,41 @@ +// +// ClaudeAccount.swift +// ClaudeMeter +// + +import Foundation + +/// A single Claude.ai account being monitored. +/// Each account has its own session key (in Keychain) and its own menu bar status item. +struct ClaudeAccount: Codable, Equatable, Identifiable, Sendable, Hashable { + /// Stable identifier used as the Keychain account name and cache key. + let id: UUID + + /// User-facing label shown in settings and the menu bar (e.g. "Perso", "Client X"). + var label: String + + /// Cached organization UUID for this account (avoids round-trip on each refresh). + var organizationId: UUID? + + init(id: UUID = UUID(), label: String, organizationId: UUID? = nil) { + self.id = id + self.label = label + self.organizationId = organizationId + } + + /// Keychain `account` parameter: scoped per-account so multiple session keys can coexist. + var keychainAccount: String { id.uuidString } + + /// Single-character glyph used as a prefix in the menu bar to disambiguate accounts. + var menuBarInitial: String { + let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines) + guard let first = trimmed.first else { return "?" } + return String(first).uppercased() + } + + enum CodingKeys: String, CodingKey { + case id + case label + case organizationId = "organization_id" + } +} diff --git a/ClaudeMeter/Repositories/CacheRepository.swift b/ClaudeMeter/Repositories/CacheRepository.swift index 084df05..f7eb41a 100644 --- a/ClaudeMeter/Repositories/CacheRepository.swift +++ b/ClaudeMeter/Repositories/CacheRepository.swift @@ -7,15 +7,22 @@ import Foundation -/// Actor-isolated two-tier cache repository +/// Actor-isolated two-tier cache repository, scoped per account. actor CacheRepository: CacheRepositoryProtocol { - private var memoryCache: UsageData? - private var memoryCacheTimestamp: Date? + private struct MemoryEntry { + let data: UsageData + let timestamp: Date + } + + private var memoryCache: [UUID: MemoryEntry] = [:] private let cacheTTL: TimeInterval = Constants.Cache.ttl - private let diskCacheURL: URL + private let cacheDir: URL private let publicJSONURL: URL + private let fileManager: FileManager init(fileManager: FileManager = .default) { + self.fileManager = fileManager + let appSupport = fileManager.urls( for: .applicationSupportDirectory, in: .userDomainMask @@ -23,8 +30,7 @@ actor CacheRepository: CacheRepositoryProtocol { let cacheDir = appSupport.appendingPathComponent("com.claudemeter", isDirectory: true) try? fileManager.createDirectory(at: cacheDir, withIntermediateDirectories: true) - - self.diskCacheURL = cacheDir.appendingPathComponent("usage_cache.json") + self.cacheDir = cacheDir // Public JSON export at ~/.claudemeter/usage.json for external tools let homeDir = fileManager.homeDirectoryForCurrentUser @@ -33,57 +39,51 @@ actor CacheRepository: CacheRepositoryProtocol { self.publicJSONURL = publicDir.appendingPathComponent("usage.json") } - /// Get cached usage data (respects TTL) - func get() async -> UsageData? { - // Check in-memory cache first - if let cached = memoryCache, - let timestamp = memoryCacheTimestamp, - Date().timeIntervalSince(timestamp) < cacheTTL { - return cached - } + // MARK: - Public API - // Memory cache is stale or missing + func get(accountId: UUID) async -> UsageData? { + if let entry = memoryCache[accountId], + Date().timeIntervalSince(entry.timestamp) < cacheTTL { + return entry.data + } return nil } - /// Cache usage data in both memory and disk - func set(_ data: UsageData) async { - memoryCache = data - memoryCacheTimestamp = Date() - await saveToDisk(data) + func set(_ data: UsageData, accountId: UUID, isPrimary: Bool) async { + memoryCache[accountId] = MemoryEntry(data: data, timestamp: Date()) + await saveToDisk(data, accountId: accountId, isPrimary: isPrimary) + } + + func invalidate(accountId: UUID) async { + memoryCache[accountId] = nil } - /// Invalidate memory cache - func invalidate() async { - memoryCache = nil - memoryCacheTimestamp = nil + func getLastKnown(accountId: UUID) async -> UsageData? { + loadFromDisk(accountId: accountId) } - /// Get last known data from disk (ignores TTL) for offline display - func getLastKnown() async -> UsageData? { - await loadFromDisk() + func purge(accountId: UUID) async { + memoryCache[accountId] = nil + try? fileManager.removeItem(at: diskURL(for: accountId)) } - // MARK: - Private Methods + // MARK: - Private + + private func diskURL(for accountId: UUID) -> URL { + cacheDir.appendingPathComponent("usage_\(accountId.uuidString).json") + } - private func saveToDisk(_ data: UsageData) async { + private func saveToDisk(_ data: UsageData, accountId: UUID, isPrimary: Bool) async { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 - guard let jsonData = try? encoder.encode(data) else { - return - } + guard let jsonData = try? encoder.encode(data) else { return } - do { - try jsonData.write(to: diskCacheURL, options: .atomic) - } catch { - // Silently fail - } + try? jsonData.write(to: diskURL(for: accountId), options: .atomic) - // Also write to public location for external tools (statusline scripts, etc.) - // Note: Ideally this would be a separate service, but since we always export - // when caching fresh data, co-locating here avoids additional coordination. - saveToPublicJSON(data) + if isPrimary { + saveToPublicJSON(data) + } } private func saveToPublicJSON(_ data: UsageData) { @@ -91,29 +91,18 @@ actor CacheRepository: CacheRepositoryProtocol { encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - guard let jsonData = try? encoder.encode(data) else { - return - } - - do { - try jsonData.write(to: publicJSONURL, options: .atomic) - } catch { - // Silently fail - external tools location is optional - } + guard let jsonData = try? encoder.encode(data) else { return } + try? jsonData.write(to: publicJSONURL, options: .atomic) } - private func loadFromDisk() async -> UsageData? { - guard let jsonData = try? Data(contentsOf: diskCacheURL) else { + private func loadFromDisk(accountId: UUID) -> UsageData? { + guard let jsonData = try? Data(contentsOf: diskURL(for: accountId)) else { return nil } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - do { - return try decoder.decode(UsageData.self, from: jsonData) - } catch { - return nil - } + return try? decoder.decode(UsageData.self, from: jsonData) } } diff --git a/ClaudeMeter/Repositories/Protocols/CacheRepositoryProtocol.swift b/ClaudeMeter/Repositories/Protocols/CacheRepositoryProtocol.swift index 96b80a9..2071c31 100644 --- a/ClaudeMeter/Repositories/Protocols/CacheRepositoryProtocol.swift +++ b/ClaudeMeter/Repositories/Protocols/CacheRepositoryProtocol.swift @@ -7,17 +7,22 @@ import Foundation -/// Protocol for two-tier usage data caching +/// Protocol for two-tier usage data caching, scoped per account. protocol CacheRepositoryProtocol: Actor { - /// Get cached usage data (respects 10-second TTL) - func get() async -> UsageData? + /// Get cached usage data for an account (respects TTL). + func get(accountId: UUID) async -> UsageData? - /// Cache usage data - func set(_ data: UsageData) async + /// Cache usage data for an account. + /// - Parameter isPrimary: when true, also writes the public `~/.claudemeter/usage.json` export + /// (preserves backwards compatibility with statusline scripts that expected a single account). + func set(_ data: UsageData, accountId: UUID, isPrimary: Bool) async - /// Invalidate cache - func invalidate() async + /// Invalidate the in-memory cache for an account. + func invalidate(accountId: UUID) async - /// Get last known data (ignores TTL) for offline display - func getLastKnown() async -> UsageData? + /// Get last known data from disk for an account (ignores TTL) for offline display. + func getLastKnown(accountId: UUID) async -> UsageData? + + /// Drop all on-disk cache files (e.g. when an account is removed or all accounts are cleared). + func purge(accountId: UUID) async } diff --git a/ClaudeMeter/Services/Protocols/UsageServiceProtocol.swift b/ClaudeMeter/Services/Protocols/UsageServiceProtocol.swift index 7ac0723..250e9c3 100644 --- a/ClaudeMeter/Services/Protocols/UsageServiceProtocol.swift +++ b/ClaudeMeter/Services/Protocols/UsageServiceProtocol.swift @@ -9,14 +9,14 @@ import Foundation /// Protocol for Claude.ai usage operations protocol UsageServiceProtocol: Actor { - /// Fetch usage data for the user's organization - /// - Parameter forceRefresh: If true, clears cache before fetching new data - func fetchUsage(forceRefresh: Bool) async throws -> UsageData + /// Fetch usage data for a specific account. + /// - Parameters: + /// - account: the account to fetch usage for + /// - isPrimary: when true, the public `~/.claudemeter/usage.json` export is updated + /// - forceRefresh: if true, bypasses the cache before fetching + func fetchUsage(for account: ClaudeAccount, isPrimary: Bool, forceRefresh: Bool) async throws -> UsageData - /// Fetch list of organizations for the user (from keychain) - func fetchOrganizations() async throws -> [Organization] - - /// Fetch list of organizations with explicit session key (for setup) + /// Fetch list of organizations with explicit session key (for setup before keychain save) func fetchOrganizations(sessionKey: SessionKey) async throws -> [Organization] /// Validate session key with Claude API diff --git a/ClaudeMeter/Services/UsageService.swift b/ClaudeMeter/Services/UsageService.swift index 71b3e82..1d631ad 100644 --- a/ClaudeMeter/Services/UsageService.swift +++ b/ClaudeMeter/Services/UsageService.swift @@ -14,7 +14,6 @@ actor UsageService: UsageServiceProtocol { private let networkService: NetworkServiceProtocol private let cacheRepository: CacheRepositoryProtocol private let keychainRepository: KeychainRepositoryProtocol - private let settingsRepository: SettingsRepositoryProtocol private let maxRetries = Constants.Network.maxRetries private let baseURL = "https://claude.ai/api" @@ -22,20 +21,18 @@ actor UsageService: UsageServiceProtocol { init( networkService: NetworkServiceProtocol, cacheRepository: CacheRepositoryProtocol, - keychainRepository: KeychainRepositoryProtocol, - settingsRepository: SettingsRepositoryProtocol + keychainRepository: KeychainRepositoryProtocol ) { self.networkService = networkService self.cacheRepository = cacheRepository self.keychainRepository = keychainRepository - self.settingsRepository = settingsRepository } - /// Fetch usage data with cache integration and exponential backoff retry - func fetchUsage(forceRefresh: Bool = false) async throws -> UsageData { + /// Fetch usage data for an account with cache integration and exponential backoff retry. + func fetchUsage(for account: ClaudeAccount, isPrimary: Bool, forceRefresh: Bool) async throws -> UsageData { let sessionKeyString: String do { - sessionKeyString = try await keychainRepository.retrieve(account: "default") + sessionKeyString = try await keychainRepository.retrieve(account: account.keychainAccount) } catch KeychainError.notFound { throw AppError.noSessionKey } catch let error as KeychainError { @@ -44,27 +41,21 @@ actor UsageService: UsageServiceProtocol { let sessionKey = try SessionKey(sessionKeyString) - // Clear cache if force refresh is requested if forceRefresh { - await cacheRepository.invalidate() + await cacheRepository.invalidate(accountId: account.id) } - // Check cache first (will be empty if force refresh) - if let cachedData = await cacheRepository.get() { + if let cachedData = await cacheRepository.get(accountId: account.id) { return cachedData } - // Get organization ID - let settings = await settingsRepository.load() let organizationId: UUID - - if let cachedOrgId = settings.cachedOrganizationId { - organizationId = cachedOrgId - } else if let orgId = sessionKey.organizationId { - organizationId = orgId + if let cached = account.organizationId { + organizationId = cached + } else if let embedded = sessionKey.organizationId { + organizationId = embedded } else { - // Fetch organizations to get ID - let orgs = try await fetchOrganizations() + let orgs = try await fetchOrganizations(sessionKey: sessionKey) guard let firstOrg = orgs.first, let uuid = firstOrg.organizationUUID else { throw AppError.organizationNotFound @@ -72,7 +63,6 @@ actor UsageService: UsageServiceProtocol { organizationId = uuid } - // Fetch usage data with retry logic var lastError: Error? for attempt in 0.. [Organization] { - let sessionKeyString: String - do { - sessionKeyString = try await keychainRepository.retrieve(account: "default") - } catch KeychainError.notFound { - throw AppError.noSessionKey - } catch let error as KeychainError { - throw AppError.keychainError(error) - } - - let sessionKey = try SessionKey(sessionKeyString) - return try await fetchOrganizations(sessionKey: sessionKey) - } - /// Fetch list of organizations with explicit session key (for setup before keychain save) func fetchOrganizations(sessionKey: SessionKey) async throws -> [Organization] { let organizations: OrganizationListResponse = try await networkService.request( diff --git a/ClaudeMeter/Views/MenuBar/IconCache.swift b/ClaudeMeter/Views/MenuBar/IconCache.swift index 48f67be..e6f905c 100644 --- a/ClaudeMeter/Views/MenuBar/IconCache.swift +++ b/ClaudeMeter/Views/MenuBar/IconCache.swift @@ -21,7 +21,8 @@ final class IconCache { isLoading: Bool, isStale: Bool, iconStyle: IconStyle, - weeklyPercentage: Double + weeklyPercentage: Double, + accountLabel: String? ) -> NSImage? { cache.object(forKey: cacheKey( percentage: percentage, @@ -29,7 +30,8 @@ final class IconCache { isLoading: isLoading, isStale: isStale, iconStyle: iconStyle, - weeklyPercentage: weeklyPercentage + weeklyPercentage: weeklyPercentage, + accountLabel: accountLabel )) } @@ -40,7 +42,8 @@ final class IconCache { isLoading: Bool, isStale: Bool, iconStyle: IconStyle, - weeklyPercentage: Double + weeklyPercentage: Double, + accountLabel: String? ) { cache.setObject( image, @@ -50,7 +53,8 @@ final class IconCache { isLoading: isLoading, isStale: isStale, iconStyle: iconStyle, - weeklyPercentage: weeklyPercentage + weeklyPercentage: weeklyPercentage, + accountLabel: accountLabel ) ) } @@ -61,10 +65,12 @@ final class IconCache { isLoading: Bool, isStale: Bool, iconStyle: IconStyle, - weeklyPercentage: Double + weeklyPercentage: Double, + accountLabel: String? ) -> NSString { let percent = String(format: "%.2f", percentage) let weekly = String(format: "%.2f", weeklyPercentage) - return "\(percent)|\(weekly)|\(status.rawValue)|\(isLoading)|\(isStale)|\(iconStyle.rawValue)" as NSString + let label = accountLabel ?? "" + return "\(percent)|\(weekly)|\(status.rawValue)|\(isLoading)|\(isStale)|\(iconStyle.rawValue)|\(label)" as NSString } } diff --git a/ClaudeMeter/Views/MenuBar/MenuBarIconRenderer.swift b/ClaudeMeter/Views/MenuBar/MenuBarIconRenderer.swift index cde2580..6a064e3 100644 --- a/ClaudeMeter/Views/MenuBar/MenuBarIconRenderer.swift +++ b/ClaudeMeter/Views/MenuBar/MenuBarIconRenderer.swift @@ -17,7 +17,8 @@ struct MenuBarIconRenderer { isLoading: Bool, isStale: Bool, iconStyle: IconStyle, - weeklyPercentage: Double = 0 + weeklyPercentage: Double = 0, + accountLabel: String? = nil ) -> NSImage { let iconView = MenuBarIconView( percentage: percentage, @@ -25,7 +26,8 @@ struct MenuBarIconRenderer { isLoading: isLoading, isStale: isStale, iconStyle: iconStyle, - weeklyPercentage: weeklyPercentage + weeklyPercentage: weeklyPercentage, + accountLabel: accountLabel ) let renderer = ImageRenderer(content: iconView) diff --git a/ClaudeMeter/Views/MenuBar/MenuBarIconView.swift b/ClaudeMeter/Views/MenuBar/MenuBarIconView.swift index a6993c6..7a2f236 100644 --- a/ClaudeMeter/Views/MenuBar/MenuBarIconView.swift +++ b/ClaudeMeter/Views/MenuBar/MenuBarIconView.swift @@ -7,7 +7,7 @@ import SwiftUI -/// SwiftUI view for menu bar icon with configurable style +/// SwiftUI view for menu bar icon with configurable style. struct MenuBarIconView: View { let percentage: Double let status: UsageStatus @@ -15,8 +15,22 @@ struct MenuBarIconView: View { let isStale: Bool let iconStyle: IconStyle var weeklyPercentage: Double = 0 // Optional, used by dualBar style + /// When set, a single-character label is rendered before the icon to disambiguate accounts. + var accountLabel: String? = nil var body: some View { + HStack(spacing: 3) { + if let accountLabel, !accountLabel.isEmpty { + Text(accountLabel) + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .foregroundColor(isStale ? .gray : status.color) + } + iconBody + } + } + + @ViewBuilder + private var iconBody: some View { switch iconStyle { case .battery: BatteryIcon(percentage: percentage, status: status, isLoading: isLoading, isStale: isStale) diff --git a/ClaudeMeter/Views/MenuBar/MenuBarManager.swift b/ClaudeMeter/Views/MenuBar/MenuBarManager.swift index 7276352..561bc8f 100644 --- a/ClaudeMeter/Views/MenuBar/MenuBarManager.swift +++ b/ClaudeMeter/Views/MenuBar/MenuBarManager.swift @@ -9,39 +9,44 @@ import AppKit import Observation import SwiftUI -/// Manages NSStatusItem and NSPopover presentation. +/// Sentinel id for the setup status item shown when no accounts are configured. +private let setupSentinel = UUID(uuidString: "00000000-0000-0000-0000-000000000000")! + +/// Manages NSStatusItem and NSPopover presentation. One status item per Claude account, plus a +/// single setup status item when no accounts are configured. @MainActor final class MenuBarManager { private let appModel: AppModel - private var statusItem: NSStatusItem? - private var popover: NSPopover? private let iconCache = IconCache() private let iconRenderer = MenuBarIconRenderer() private var openUsageObserver: NSObjectProtocol? + private struct ManagedItem { + let statusItem: NSStatusItem + let popover: NSPopover + } + + private var managedItems: [UUID: ManagedItem] = [:] + init(appModel: AppModel) { self.appModel = appModel } func start() { - setupStatusItem() - createPopover() - observeIconUpdates() + observeUpdates() observeOpenPopoverRequests() Task { await appModel.bootstrap() + await MainActor.run { self.reconcileItems() } } } #if DEBUG - /// Starts the menu bar without calling bootstrap. - /// Used in demo mode when state is pre-configured. func startWithoutBootstrap() { - setupStatusItem() - createPopover() - observeIconUpdates() + observeUpdates() observeOpenPopoverRequests() + reconcileItems() } #endif @@ -51,33 +56,21 @@ final class MenuBarManager { } } - // MARK: - Setup - - private func setupStatusItem() { - statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - guard let button = statusItem?.button else { return } - - button.target = self - button.action = #selector(togglePopover) - button.sendAction(on: [.leftMouseUp, .rightMouseUp]) - button.imagePosition = .imageOnly - button.setAccessibilityLabel("ClaudeMeter") - - updateIcon() - } + // MARK: - Observation - private func createPopover() { - let popoverView = MenuBarPopoverView(appModel: appModel) { [weak self] in - self?.closePopover() + private func observeUpdates() { + withObservationTracking { + // Track everything that affects the menu bar layout or icon contents. + _ = appModel.settings.accounts + _ = appModel.settings.iconStyle + _ = appModel.accountStates + } onChange: { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.reconcileItems() + self.observeUpdates() + } } - let hostingController = NSHostingController(rootView: popoverView) - - let popover = NSPopover() - popover.contentViewController = hostingController - popover.behavior = .transient - popover.animates = true - - self.popover = popover } private func observeOpenPopoverRequests() { @@ -87,46 +80,107 @@ final class MenuBarManager { queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in - self?.showPopover() + self?.showFirstPopover() } } } - // MARK: - Observation + // MARK: - Reconciliation - private func observeIconUpdates() { - withObservationTracking { - _ = appModel.usageData - _ = appModel.isLoading - _ = appModel.settings.iconStyle - } onChange: { [weak self] in - Task { @MainActor [weak self] in - guard let self else { return } - self.updateIcon() - self.observeIconUpdates() + /// Bring the set of status items into sync with the current account list. + private func reconcileItems() { + let accounts = appModel.settings.accounts + + if accounts.isEmpty { + // Remove any per-account items, ensure setup item exists. + for (id, item) in managedItems where id != setupSentinel { + NSStatusBar.system.removeStatusItem(item.statusItem) + managedItems[id] = nil + } + if managedItems[setupSentinel] == nil { + managedItems[setupSentinel] = makeStatusItem(for: nil) + } + updateSetupItemIcon() + return + } + + // Drop the setup item once at least one account is configured. + if let setup = managedItems[setupSentinel] { + NSStatusBar.system.removeStatusItem(setup.statusItem) + managedItems[setupSentinel] = nil + } + + // Remove items for accounts that no longer exist. + let configuredIds = Set(accounts.map(\.id)) + for id in managedItems.keys where !configuredIds.contains(id) { + if let item = managedItems[id] { + NSStatusBar.system.removeStatusItem(item.statusItem) + } + managedItems[id] = nil + } + + // Ensure each account has an item, then refresh icons. + for account in accounts { + if managedItems[account.id] == nil { + managedItems[account.id] = makeStatusItem(for: account) } + updateIcon(for: account) + } + } + + private func makeStatusItem(for account: ClaudeAccount?) -> ManagedItem { + let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + if let button = statusItem.button { + button.target = self + button.imagePosition = .imageOnly + button.setAccessibilityLabel(account?.label ?? "ClaudeMeter") + // Encode the account id (or sentinel) into the action via the button's identifier. + button.identifier = NSUserInterfaceItemIdentifier((account?.id ?? setupSentinel).uuidString) + button.action = #selector(handleClick(_:)) + button.sendAction(on: [.leftMouseUp, .rightMouseUp]) } + + let popoverAccountId = account?.id + let popoverView = MenuBarPopoverView( + appModel: appModel, + accountId: popoverAccountId + ) { [weak self] in + self?.closePopover(for: popoverAccountId ?? setupSentinel) + } + let hostingController = NSHostingController(rootView: popoverView) + let popover = NSPopover() + popover.contentViewController = hostingController + popover.behavior = .transient + popover.animates = true + + return ManagedItem(statusItem: statusItem, popover: popover) } - private func updateIcon() { - guard let button = statusItem?.button else { return } + // MARK: - Icon updates + + private func updateIcon(for account: ClaudeAccount) { + guard let item = managedItems[account.id], let button = item.statusItem.button else { return } - let percentage = clamped(appModel.usageData?.sessionUsage.percentage ?? 0) - let weeklyPercentage = clamped(appModel.usageData?.weeklyUsage.percentage ?? 0) - let status = appModel.usageData?.primaryStatus ?? .safe - let isStale = appModel.usageData?.isStale ?? false - let isLoading = appModel.isLoading + let state = appModel.state(for: account.id) + let percentage = clamped(state.usageData?.sessionUsage.percentage ?? 0) + let weeklyPercentage = clamped(state.usageData?.weeklyUsage.percentage ?? 0) + let status = state.usageData?.primaryStatus ?? .safe + let isStale = state.usageData?.isStale ?? false + let isLoading = state.isLoading let style = appModel.settings.iconStyle + // Only show a label when more than one account is configured — single-account looks cleaner without it. + let label: String? = appModel.settings.accounts.count > 1 ? account.menuBarInitial : nil - if let cachedImage = iconCache.get( + if let cached = iconCache.get( percentage: percentage, status: status, isLoading: isLoading, isStale: isStale, iconStyle: style, - weeklyPercentage: weeklyPercentage + weeklyPercentage: weeklyPercentage, + accountLabel: label ) { - button.image = cachedImage + button.image = cached return } @@ -136,7 +190,8 @@ final class MenuBarManager { isLoading: isLoading, isStale: isStale, iconStyle: style, - weeklyPercentage: weeklyPercentage + weeklyPercentage: weeklyPercentage, + accountLabel: label ) iconCache.set( @@ -146,33 +201,64 @@ final class MenuBarManager { isLoading: isLoading, isStale: isStale, iconStyle: style, - weeklyPercentage: weeklyPercentage + weeklyPercentage: weeklyPercentage, + accountLabel: label ) button.image = image + button.setAccessibilityLabel("\(account.label): \(Int(percentage))%") } + private func updateSetupItemIcon() { + guard let item = managedItems[setupSentinel], let button = item.statusItem.button else { return } + let symbol = NSImage(systemSymbolName: "gauge.with.dots.needle.0percent", accessibilityDescription: "ClaudeMeter setup") + symbol?.isTemplate = true + button.image = symbol + button.setAccessibilityLabel("ClaudeMeter — setup required") + } private func clamped(_ value: Double) -> Double { max(0, min(value, 100)) } - // MARK: - Popover Control + // MARK: - Popover control - @objc private func togglePopover() { - guard let popover else { return } - popover.isShown ? closePopover() : showPopover() + @objc private func handleClick(_ sender: NSStatusBarButton) { + guard let identifier = sender.identifier?.rawValue, + let id = UUID(uuidString: identifier) else { return } + togglePopover(for: id) } - private func showPopover() { - guard let button = statusItem?.button, let popover else { return } - guard !popover.isShown else { return } + private func togglePopover(for id: UUID) { + guard let item = managedItems[id] else { return } + if item.popover.isShown { + item.popover.performClose(nil) + } else { + // Close any other open popover first. + for (otherId, other) in managedItems where otherId != id && other.popover.isShown { + other.popover.performClose(nil) + } + guard let button = item.statusItem.button else { return } + item.popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + NSApp.activate(ignoringOtherApps: true) + } + } - popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) - NSApp.activate(ignoringOtherApps: true) + private func closePopover(for id: UUID) { + managedItems[id]?.popover.performClose(nil) } - private func closePopover() { - popover?.performClose(nil) + private func showFirstPopover() { + // Used when a notification asks us to surface the UI. + if let firstAccount = appModel.settings.accounts.first, + let item = managedItems[firstAccount.id] { + guard let button = item.statusItem.button, !item.popover.isShown else { return } + item.popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + NSApp.activate(ignoringOtherApps: true) + } else if let setup = managedItems[setupSentinel] { + guard let button = setup.statusItem.button, !setup.popover.isShown else { return } + setup.popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + NSApp.activate(ignoringOtherApps: true) + } } } diff --git a/ClaudeMeter/Views/MenuBar/MenuBarPopoverView.swift b/ClaudeMeter/Views/MenuBar/MenuBarPopoverView.swift index 0a86a2c..c3f5233 100644 --- a/ClaudeMeter/Views/MenuBar/MenuBarPopoverView.swift +++ b/ClaudeMeter/Views/MenuBar/MenuBarPopoverView.swift @@ -7,16 +7,26 @@ import SwiftUI -/// Root view for the menu bar popover, switching between setup and usage. +/// Root view for a menu bar popover. When `account` is set the popover shows that account's usage; +/// otherwise the setup wizard is shown so the user can configure their first account. struct MenuBarPopoverView: View { @Bindable var appModel: AppModel + /// When nil, this popover hosts the setup wizard (no account configured yet). + let accountId: UUID? let onRequestClose: () -> Void var body: some View { - if appModel.isSetupComplete { - UsagePopoverView(appModel: appModel, onRequestClose: onRequestClose) + if let accountId, let account = appModel.settings.account(withId: accountId) { + UsagePopoverView( + appModel: appModel, + account: account, + onRequestClose: onRequestClose + ) } else { - SetupWizardView(appModel: appModel) + SetupWizardView( + appModel: appModel, + onComplete: onRequestClose + ) } } } diff --git a/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift b/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift index 7147cd6..032356d 100644 --- a/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift +++ b/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift @@ -8,152 +8,162 @@ import SwiftUI import AppKit -/// Usage popover view with detailed metrics +/// Usage popover view for a single Claude account. struct UsagePopoverView: View { @Bindable var appModel: AppModel + let account: ClaudeAccount let onRequestClose: (() -> Void)? @Environment(\.openSettings) private var openSettings + private var state: AccountUsageState { + appModel.state(for: account.id) + } + var body: some View { VStack(spacing: 0) { - // Header - HStack { - Text("Claude Usage") + header + + Divider() + + if let errorMessage = state.errorMessage { + errorBanner(errorMessage) + Divider() + } + + content + + Divider() + + footer + } + .frame(width: 320, height: 460) + .background(Color(nsColor: .windowBackgroundColor)) + .accessibilityElement(children: .contain) + .accessibilityLabel("Usage Dashboard for \(account.label)") + } + + // MARK: - Sections + + private var header: some View { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text(account.label) .font(.title2) .fontWeight(.bold) + Text("Claude Usage") + .font(.caption) + .foregroundColor(.secondary) + } - Spacer() + Spacer() - // Refresh button - Button(action: { - Task { - await appModel.refreshUsage(forceRefresh: true) - } - }) { - if appModel.isRefreshing { - ProgressView() - .scaleEffect(0.7) - .frame(width: 20, height: 20) - } else { - Image(systemName: "arrow.clockwise") - } + Button(action: { + Task { await appModel.refreshUsage(accountId: account.id, forceRefresh: true) } + }) { + if state.isRefreshing { + ProgressView() + .scaleEffect(0.7) + .frame(width: 20, height: 20) + } else { + Image(systemName: "arrow.clockwise") } - .buttonStyle(.plain) - .disabled(appModel.isRefreshing) - .help("Refresh usage data") - .keyboardShortcut("r", modifiers: .command) } - .padding() + .buttonStyle(.plain) + .disabled(state.isRefreshing) + .help("Refresh usage data") + .keyboardShortcut("r", modifiers: .command) + } + .padding() + } - Divider() + private func errorBanner(_ message: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text(message) + .font(.callout) + .foregroundColor(.primary) + Spacer() + } - // Error banner - if let errorMessage = appModel.errorMessage { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.orange) - Text(errorMessage) - .font(.callout) - .foregroundColor(.primary) - - Spacer() - } + HStack(spacing: 8) { + Button("Retry") { + Task { await appModel.refreshUsage(accountId: account.id, forceRefresh: true) } + } + .buttonStyle(.bordered) - HStack(spacing: 8) { - // Retry button for recoverable errors - Button("Retry") { - Task { - await appModel.refreshUsage(forceRefresh: true) - } - } - .buttonStyle(.bordered) - - // Update Key button for authentication errors - if errorMessage.contains("invalid") || errorMessage.contains("expired") || errorMessage.contains("authentication") { - Button("Update Session Key") { - openSettingsFront() - } - .buttonStyle(.borderedProminent) - } + if message.contains("invalid") || message.contains("expired") || message.contains("authentication") { + Button("Update Session Key") { + openSettingsFront() } + .buttonStyle(.borderedProminent) } - .padding() - .background(Color.orange.opacity(0.1)) - - Divider() } + } + .padding() + .background(Color.orange.opacity(0.1)) + } - // Content - if let usageData = appModel.usageData { - ScrollView { - VStack(spacing: 16) { - // Session usage card - UsageCardView( - title: "5-Hour Session", - usageLimit: usageData.sessionUsage, - icon: "gauge.with.dots.needle.67percent", - windowDuration: Constants.Pacing.sessionWindow - ) - - // Weekly usage card + @ViewBuilder + private var content: some View { + if let usageData = state.usageData { + ScrollView { + VStack(spacing: 16) { + UsageCardView( + title: "5-Hour Session", + usageLimit: usageData.sessionUsage, + icon: "gauge.with.dots.needle.67percent", + windowDuration: Constants.Pacing.sessionWindow + ) + UsageCardView( + title: "Weekly Usage", + usageLimit: usageData.weeklyUsage, + icon: "calendar", + windowDuration: Constants.Pacing.weeklyWindow + ) + if appModel.settings.isSonnetUsageShown, let sonnetUsage = usageData.sonnetUsage { UsageCardView( - title: "Weekly Usage", - usageLimit: usageData.weeklyUsage, - icon: "calendar", + title: "Weekly Sonnet", + usageLimit: sonnetUsage, + icon: "sparkles", windowDuration: Constants.Pacing.weeklyWindow ) - - // Sonnet usage card (conditional rendering) - if appModel.settings.isSonnetUsageShown, let sonnetUsage = usageData.sonnetUsage { - UsageCardView( - title: "Weekly Sonnet", - usageLimit: sonnetUsage, - icon: "sparkles", - windowDuration: Constants.Pacing.weeklyWindow - ) - } } - .padding() - } - } else { - // Loading state - VStack(spacing: 16) { - ProgressView() - Text("Loading usage data...") - .font(.callout) - .foregroundColor(.secondary) } - .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() } + } else { + VStack(spacing: 16) { + ProgressView() + Text("Loading usage data...") + .font(.callout) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + } - Divider() - - // Footer with settings button - HStack { - Button("Settings") { - openSettingsFront() - } - .buttonStyle(.plain) - .keyboardShortcut(",", modifiers: .command) - .accessibilityLabel("Open settings window") + private var footer: some View { + HStack { + Button("Settings") { + openSettingsFront() + } + .buttonStyle(.plain) + .keyboardShortcut(",", modifiers: .command) + .accessibilityLabel("Open settings window") - Spacer() + Spacer() - Button("Quit") { - NSApplication.shared.terminate(nil) - } - .buttonStyle(.plain) - .keyboardShortcut("q", modifiers: .command) - .accessibilityLabel("Quit application") + Button("Quit") { + NSApplication.shared.terminate(nil) } - .padding() + .buttonStyle(.plain) + .keyboardShortcut("q", modifiers: .command) + .accessibilityLabel("Quit application") } - .frame(width: 320, height: 460) - .background(Color(nsColor: .windowBackgroundColor)) - .accessibilityElement(children: .contain) - .accessibilityLabel("Usage Dashboard") + .padding() } private func openSettingsFront() { diff --git a/ClaudeMeter/Views/Settings/SettingsView.swift b/ClaudeMeter/Views/Settings/SettingsView.swift index 01b06b8..d59238f 100644 --- a/ClaudeMeter/Views/Settings/SettingsView.swift +++ b/ClaudeMeter/Views/Settings/SettingsView.swift @@ -13,17 +13,15 @@ import AppKit struct SettingsView: View { @Bindable var appModel: AppModel - @State private var sessionKey: String = "" - @State private var isSessionKeyShown: Bool = false - @State private var isValidatingSessionKey: Bool = false - @State private var sessionKeyValidationMessage: String? - @State private var hasSessionKeyValidationSucceeded: Bool = false - @State private var isSendingTestNotification: Bool = false @State private var testNotificationMessage: String? @State private var hasTestNotificationSucceeded: Bool = false @State private var notificationError: String? + @State private var addAccountSheetPresented: Bool = false + @State private var editingAccount: ClaudeAccount? + @State private var pendingRemoval: ClaudeAccount? + @State private var launchAtLogin: Bool = SMAppService.mainApp.status == .enabled var body: some View { @@ -35,9 +33,9 @@ struct SettingsView: View { aboutTab .tabItem { Label("About", systemImage: "info.circle") } } - .frame(width: 500) + .frame(width: 520) .onAppear { - loadSettings() + Task { await updateNotificationStatus() } } .onChange(of: appModel.settings.hasNotificationsEnabled) { _, newValue in Task { @@ -50,90 +48,104 @@ struct SettingsView: View { .onChange(of: launchAtLogin) { _, newValue in updateLaunchAtLogin(newValue) } + .sheet(isPresented: $addAccountSheetPresented) { + SetupWizardView(appModel: appModel) { + addAccountSheetPresented = false + } + } + .sheet(item: $editingAccount) { account in + EditAccountSheet( + appModel: appModel, + account: account, + onClose: { editingAccount = nil } + ) + } + .alert( + "Remove account?", + isPresented: Binding( + get: { pendingRemoval != nil }, + set: { if !$0 { pendingRemoval = nil } } + ), + presenting: pendingRemoval + ) { account in + Button("Remove", role: .destructive) { + Task { + try? await appModel.removeAccount(account.id) + pendingRemoval = nil + } + } + Button("Cancel", role: .cancel) { + pendingRemoval = nil + } + } message: { account in + Text("\(account.label) will be removed and its session key deleted from the Keychain.") + } } // MARK: - General Tab private var generalTab: some View { - VStack(alignment: .leading, spacing: 16) { - if !appModel.isReady { - VStack { - Spacer() - ProgressView("Loading settings...") - .controlSize(.large) - Spacer() + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if !appModel.isReady { + VStack { + Spacer() + ProgressView("Loading settings...") + .controlSize(.large) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + accountsSection + refreshIntervalSection + sonnetUsageSection + iconStyleSection + launchAtLoginSection } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - sessionKeySection - refreshIntervalSection - sonnetUsageSection - iconStyleSection - launchAtLoginSection } + .padding(24) } - .padding(24) } - // MARK: - Session Key Section + // MARK: - Accounts Section - private var sessionKeySection: some View { + private var accountsSection: some View { VStack(alignment: .leading, spacing: 12) { - Text("Session Key") - .font(.subheadline) - - Text("Your Claude.ai session key authenticates API requests. Find this in your browser's cookies.") - .font(.caption) - .foregroundStyle(.secondary) - HStack { - if isSessionKeyShown { - TextField("sk-ant-...", text: $sessionKey) - .textFieldStyle(.roundedBorder) - .font(.system(.body, design: .monospaced)) - } else { - SecureField("sk-ant-...", text: $sessionKey) - .textFieldStyle(.roundedBorder) - .font(.system(.body, design: .monospaced)) - } - - Button(action: { isSessionKeyShown.toggle() }) { - Image(systemName: isSessionKeyShown ? "eye.slash" : "eye") - } - .buttonStyle(.borderless) - .help(isSessionKeyShown ? "Hide session key" : "Show session key") - - if !sessionKey.isEmpty { - Button(action: clearSessionKey) { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - .buttonStyle(.borderless) - .help("Clear session key") + Text("Claude Accounts") + .font(.subheadline) + Spacer() + Button { + addAccountSheetPresented = true + } label: { + Label("Add Account", systemImage: "plus") } + .controlSize(.small) } - HStack { - Button("Validate & Save") { - Task { - await validateAndSaveSessionKey() - } - } - .controlSize(.small) - .disabled(sessionKey.isEmpty || isValidatingSessionKey) + Text("Each account gets its own menu bar item.") + .font(.caption) + .foregroundStyle(.secondary) - if isValidatingSessionKey { - ProgressView() - .controlSize(.small) + if appModel.settings.accounts.isEmpty { + HStack { + Image(systemName: "person.crop.circle.badge.plus") + .foregroundStyle(.secondary) + Text("No accounts configured. Click \"Add Account\" to begin.") + .font(.callout) + .foregroundStyle(.secondary) } - - if let message = sessionKeyValidationMessage { - Label(message, systemImage: hasSessionKeyValidationSucceeded ? "checkmark.circle.fill" : "xmark.circle.fill") - .font(.caption) - .foregroundStyle(hasSessionKeyValidationSucceeded ? .green : .red) + .padding(.vertical, 8) + } else { + VStack(spacing: 8) { + ForEach(appModel.settings.accounts) { account in + AccountRow( + account: account, + onEdit: { editingAccount = account }, + onRemove: { pendingRemoval = account } + ) + } } - - Spacer() } } .padding() @@ -152,9 +164,7 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) } - Spacer() - Picker("", selection: $appModel.settings.refreshInterval) { Text("1 minute").tag(60.0) Text("5 minutes").tag(300.0) @@ -180,9 +190,7 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) } - Spacer() - Toggle("", isOn: $appModel.settings.isSonnetUsageShown) .labelsHidden() } @@ -197,11 +205,9 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 12) { Text("Menu Bar Icon Style") .font(.subheadline) - Text("Choose how the usage indicator appears in your menu bar") .font(.caption) .foregroundStyle(.secondary) - IconStylePicker(selection: $appModel.settings.iconStyle) } .padding() @@ -220,9 +226,7 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) } - Spacer() - Toggle("", isOn: $launchAtLogin) .labelsHidden() } @@ -259,13 +263,10 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) } - Spacer() - Toggle("", isOn: $appModel.settings.hasNotificationsEnabled) .labelsHidden() } - if let error = notificationError { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") @@ -273,7 +274,6 @@ struct SettingsView: View { Text(error) .font(.caption) .foregroundStyle(.secondary) - Button("Open Settings") { openSystemNotificationSettings() } @@ -298,22 +298,17 @@ struct SettingsView: View { .foregroundStyle(.orange) .font(.subheadline.monospacedDigit()) } - Slider( value: warningThresholdBinding, in: Constants.Thresholds.Notification.warningMin...Constants.Thresholds.Notification.warningMax, step: Constants.Thresholds.Notification.step ) .tint(.orange) - Text("Get notified when session usage reaches this percentage") .font(.caption) .foregroundStyle(.secondary) } - - Divider() - .padding(.vertical, 4) - + Divider().padding(.vertical, 4) VStack(alignment: .leading, spacing: 8) { HStack { Text("Critical Threshold") @@ -323,18 +318,15 @@ struct SettingsView: View { .foregroundStyle(.red) .font(.subheadline.monospacedDigit()) } - Slider( value: criticalThresholdBinding, in: Constants.Thresholds.Notification.criticalMin...Constants.Thresholds.Notification.criticalMax, step: Constants.Thresholds.Notification.step ) .tint(.red) - Text("Get urgent notification when session usage reaches this percentage") .font(.caption) .foregroundStyle(.secondary) - if criticalThresholdValue <= warningThresholdValue { Label("Critical threshold must be higher than warning", systemImage: "exclamationmark.triangle") .font(.caption) @@ -356,9 +348,7 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) } - Spacer() - Toggle("", isOn: isNotifiedOnResetBinding) .labelsHidden() } @@ -370,24 +360,18 @@ struct SettingsView: View { private var testNotificationSection: some View { HStack { Button("Send Test Notification") { - Task { - await sendTestNotification() - } + Task { await sendTestNotification() } } .controlSize(.small) .disabled(isSendingTestNotification) - if isSendingTestNotification { - ProgressView() - .controlSize(.small) + ProgressView().controlSize(.small) } - if let message = testNotificationMessage { Label(message, systemImage: hasTestNotificationSucceeded ? "checkmark.circle.fill" : "xmark.circle.fill") .font(.caption) .foregroundStyle(hasTestNotificationSucceeded ? .green : .red) } - Spacer() } .padding() @@ -418,19 +402,13 @@ struct SettingsView: View { ) } - private var warningThresholdValue: Double { - appModel.settings.notificationThresholds.warningThreshold - } - - private var criticalThresholdValue: Double { - appModel.settings.notificationThresholds.criticalThreshold - } + private var warningThresholdValue: Double { appModel.settings.notificationThresholds.warningThreshold } + private var criticalThresholdValue: Double { appModel.settings.notificationThresholds.criticalThreshold } // MARK: - About Tab private var aboutTab: some View { VStack(spacing: 24) { - // App Icon if let appIconImage = NSImage(named: "AppIcon") { Image(nsImage: appIconImage) .resizable() @@ -442,12 +420,9 @@ struct SettingsView: View { .font(.system(size: 80)) .foregroundStyle(.blue) } - - // App Name & Version VStack(spacing: 8) { Text("ClaudeMeter") .font(.system(size: 28, weight: .semibold)) - if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String { Text("Version \(version) (\(build))") @@ -455,19 +430,14 @@ struct SettingsView: View { .foregroundStyle(.secondary) } } - - // Copyright VStack(spacing: 4) { Text("© 2025 Edd Mann") .font(.caption) .foregroundStyle(.secondary) - Text("Monitor your Claude.ai usage limits.") .font(.caption) .foregroundStyle(.secondary) } - - // Project Link Link(destination: URL(string: "https://github.com/eddmann/ClaudeMeter")!) { HStack { Image(systemName: "link.circle.fill") @@ -485,13 +455,6 @@ struct SettingsView: View { // MARK: - Actions - private func loadSettings() { - Task { @MainActor in - sessionKey = await appModel.loadSessionKey() ?? "" - await updateNotificationStatus() - } - } - @MainActor private func updateNotificationStatus() async { let hasPermission = await appModel.checkNotificationPermissions() @@ -505,59 +468,6 @@ struct SettingsView: View { } } - @MainActor - private func validateAndSaveSessionKey() async { - guard !sessionKey.isEmpty else { - sessionKeyValidationMessage = "Session key cannot be empty" - hasSessionKeyValidationSucceeded = false - return - } - - isValidatingSessionKey = true - sessionKeyValidationMessage = nil - hasSessionKeyValidationSucceeded = false - - do { - let isValid = try await appModel.validateAndSaveSessionKey(sessionKey) - - if isValid { - sessionKeyValidationMessage = "Session key saved" - hasSessionKeyValidationSucceeded = true - - Task { @MainActor in - try? await Task.sleep(for: .seconds(2)) - sessionKeyValidationMessage = nil - hasSessionKeyValidationSucceeded = false - } - } else { - sessionKeyValidationMessage = "Session key validation failed" - hasSessionKeyValidationSucceeded = false - } - } catch let error as SessionKeyError { - sessionKeyValidationMessage = error.localizedDescription - hasSessionKeyValidationSucceeded = false - } catch { - sessionKeyValidationMessage = "Validation failed: \(error.localizedDescription)" - hasSessionKeyValidationSucceeded = false - } - - isValidatingSessionKey = false - } - - private func clearSessionKey() { - Task { @MainActor in - do { - try await appModel.clearSessionKey() - sessionKey = "" - sessionKeyValidationMessage = nil - hasSessionKeyValidationSucceeded = false - } catch { - sessionKeyValidationMessage = "Failed to clear: \(error.localizedDescription)" - hasSessionKeyValidationSucceeded = false - } - } - } - private func updateLaunchAtLogin(_ enabled: Bool) { do { if enabled { @@ -566,7 +476,6 @@ struct SettingsView: View { try SMAppService.mainApp.unregister() } } catch { - // Revert the toggle if it failed launchAtLogin = SMAppService.mainApp.status == .enabled } } @@ -589,14 +498,9 @@ struct SettingsView: View { return } } - - // Send test notification try await appModel.sendTestNotification() - testNotificationMessage = "Test notification sent!" hasTestNotificationSucceeded = true - - // Clear message after 2 seconds Task { @MainActor in try? await Task.sleep(for: .seconds(2)) testNotificationMessage = nil @@ -606,7 +510,6 @@ struct SettingsView: View { testNotificationMessage = "Failed: \(error.localizedDescription)" hasTestNotificationSucceeded = false } - isSendingTestNotification = false } @@ -616,3 +519,163 @@ struct SettingsView: View { } } } + +// MARK: - Account Row + +private struct AccountRow: View { + let account: ClaudeAccount + let onEdit: () -> Void + let onRemove: () -> Void + + var body: some View { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(.tertiary) + .frame(width: 28, height: 28) + Text(account.menuBarInitial) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.primary) + } + + VStack(alignment: .leading, spacing: 2) { + Text(account.label) + .font(.callout) + .fontWeight(.medium) + if let orgId = account.organizationId { + Text("org: \(orgId.uuidString.prefix(8))…") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + Button("Edit", action: onEdit) + .controlSize(.small) + Button(role: .destructive, action: onRemove) { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help("Remove account") + } + .padding(8) + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } +} + +// MARK: - Edit Account Sheet + +private struct EditAccountSheet: View { + @Bindable var appModel: AppModel + let account: ClaudeAccount + let onClose: () -> Void + + @State private var labelInput: String + @State private var sessionKeyInput: String = "" + @State private var isSessionKeyShown: Bool = false + @State private var isValidating: Bool = false + @State private var statusMessage: String? + @State private var hasSucceeded: Bool = false + + init(appModel: AppModel, account: ClaudeAccount, onClose: @escaping () -> Void) { + self.appModel = appModel + self.account = account + self.onClose = onClose + _labelInput = State(initialValue: account.label) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Edit Account") + .font(.title2) + .fontWeight(.bold) + + VStack(alignment: .leading, spacing: 6) { + Text("Label").font(.subheadline) + TextField("Label", text: $labelInput) + .textFieldStyle(.roundedBorder) + } + + VStack(alignment: .leading, spacing: 6) { + Text("Replace Session Key (optional)").font(.subheadline) + Text("Leave blank to keep the existing key.") + .font(.caption) + .foregroundStyle(.secondary) + HStack { + if isSessionKeyShown { + TextField("sk-ant-...", text: $sessionKeyInput) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + } else { + SecureField("sk-ant-...", text: $sessionKeyInput) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + } + Button(action: { isSessionKeyShown.toggle() }) { + Image(systemName: isSessionKeyShown ? "eye.slash" : "eye") + } + .buttonStyle(.borderless) + } + } + + if let message = statusMessage { + Label(message, systemImage: hasSucceeded ? "checkmark.circle.fill" : "exclamationmark.circle.fill") + .foregroundStyle(hasSucceeded ? .green : .red) + .font(.caption) + } + + Spacer() + + HStack { + Button("Cancel") { onClose() } + Spacer() + Button("Save") { + Task { await save() } + } + .keyboardShortcut(.defaultAction) + .disabled(isValidating) + } + } + .padding(20) + .frame(width: 420, height: 320) + } + + @MainActor + private func save() async { + isValidating = true + statusMessage = nil + hasSucceeded = false + + let trimmedLabel = labelInput.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedLabel.isEmpty && trimmedLabel != account.label { + appModel.renameAccount(account.id, label: trimmedLabel) + } + + let trimmedKey = sessionKeyInput.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedKey.isEmpty { + do { + let isValid = try await appModel.updateSessionKey(accountId: account.id, trimmedKey) + if isValid { + statusMessage = "Session key updated" + hasSucceeded = true + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(500)) + onClose() + } + isValidating = false + return + } else { + statusMessage = "Session key validation failed" + } + } catch { + statusMessage = error.localizedDescription + } + } else { + // Label-only change. + onClose() + } + isValidating = false + } +} diff --git a/ClaudeMeter/Views/Setup/SetupWizardView.swift b/ClaudeMeter/Views/Setup/SetupWizardView.swift index 5bb2ed8..2252897 100644 --- a/ClaudeMeter/Views/Setup/SetupWizardView.swift +++ b/ClaudeMeter/Views/Setup/SetupWizardView.swift @@ -8,10 +8,13 @@ import SwiftUI import AppKit -/// Setup wizard view for initial configuration +/// Setup wizard view used for the very first account, and for adding additional accounts. struct SetupWizardView: View { @Bindable var appModel: AppModel + /// Called when the wizard finishes successfully (used to dismiss the popover/sheet). + var onComplete: (() -> Void)? = nil + @State private var labelInput: String = "" @State private var sessionKeyInput: String = "" @State private var isValidating: Bool = false @State private var errorMessage: String? @@ -19,7 +22,6 @@ struct SetupWizardView: View { var body: some View { VStack(spacing: 24) { - // Header VStack(spacing: 8) { if let appIcon = NSImage(named: NSImage.applicationIconName) { Image(nsImage: appIcon) @@ -31,7 +33,7 @@ struct SetupWizardView: View { .foregroundColor(.blue) } - Text("Welcome to ClaudeMeter") + Text(appModel.settings.accounts.isEmpty ? "Welcome to ClaudeMeter" : "Add Another Account") .font(.title) .fontWeight(.bold) @@ -41,7 +43,21 @@ struct SetupWizardView: View { } .padding(.top, 32) - // Session Key Input + VStack(alignment: .leading, spacing: 8) { + Text("Account Label") + .font(.headline) + + TextField("e.g. Personal, Client X", text: $labelInput) + .textFieldStyle(.roundedBorder) + .disabled(isValidating) + .accessibilityLabel("Account label") + + Text("Used to distinguish this account in the menu bar") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.horizontal, 32) + VStack(alignment: .leading, spacing: 8) { Text("Claude Session Key") .font(.headline) @@ -50,13 +66,11 @@ struct SetupWizardView: View { .textFieldStyle(.roundedBorder) .disabled(isValidating) .accessibilityLabel("Session key input field") - .accessibilityHint("Enter your Claude session key starting with sk-ant-") Text("Find your session key in Claude.ai browser cookies") .font(.caption) .foregroundColor(.secondary) - // Format validation indicator if !sessionKeyInput.isEmpty { HStack(spacing: 4) { Image(systemName: isFormatValid ? "checkmark.circle.fill" : "xmark.circle.fill") @@ -69,8 +83,7 @@ struct SetupWizardView: View { } .padding(.horizontal, 32) - // Error Message - if let errorMessage = errorMessage { + if let errorMessage { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) @@ -85,12 +98,11 @@ struct SetupWizardView: View { .accessibilityLabel("Error: \(errorMessage)") } - // Success Message if hasValidationSucceeded { HStack(spacing: 8) { Image(systemName: "checkmark.circle.fill") .foregroundColor(.green) - Text("Setup complete! Launching ClaudeMeter...") + Text("Account added!") .font(.callout) .foregroundColor(.green) } @@ -102,11 +114,8 @@ struct SetupWizardView: View { Spacer() - // Continue Button Button(action: { - Task { - await validateAndSave() - } + Task { await validateAndSave() } }) { HStack { Text(isValidating ? "Validating..." : "Continue") @@ -119,10 +128,10 @@ struct SetupWizardView: View { .padding(.horizontal, 32) .padding(.bottom, 32) .accessibilityLabel(isValidating ? "Validating session key" : "Continue with setup") - .accessibilityHint("Validates your session key and completes setup") } - .frame(width: 360, height: 420) + .frame(width: 360, height: 460) } + // MARK: - Validation private var isFormatValid: Bool { @@ -143,9 +152,14 @@ struct SetupWizardView: View { hasValidationSucceeded = false do { - let isValid = try await appModel.validateAndSaveSessionKey(sessionKeyInput) - if isValid { + let resolvedLabel = labelInput.trimmingCharacters(in: .whitespacesAndNewlines) + let account = try await appModel.addAccount(label: resolvedLabel, sessionKey: sessionKeyInput) + if account != nil { hasValidationSucceeded = true + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(800)) + onComplete?() + } } else { errorMessage = "Session key is invalid or expired" } diff --git a/ClaudeMeterTests/AppModelTests.swift b/ClaudeMeterTests/AppModelTests.swift index d8c05ca..8fd6fa0 100644 --- a/ClaudeMeterTests/AppModelTests.swift +++ b/ClaudeMeterTests/AppModelTests.swift @@ -10,36 +10,31 @@ import XCTest @MainActor final class AppModelTests: XCTestCase { - func test_bootstrap_withoutSessionKey_showsSetupState() async { - let usageService = UsageServiceStub(fetchUsageResult: .failure(TestError(message: TestConstants.unexpectedErrorMessage))) - let notificationService = NotificationServiceSpy() - let settingsRepository = SettingsRepositoryFake() - let keychainRepository = KeychainRepositoryFake() - - let appModel = AppModel( - settingsRepository: settingsRepository, - keychainRepository: keychainRepository, - usageService: usageService, - notificationService: notificationService + func test_bootstrap_withoutAnyAccount_showsSetupState() async { + let appModel = makeAppModel( + usageService: UsageServiceStub(fetchUsageResult: .failure(TestError(message: TestConstants.unexpectedErrorMessage))) ) - try? await keychainRepository.delete(account: "default") - await appModel.bootstrap() XCTAssertTrue(appModel.isReady) XCTAssertFalse(appModel.isSetupComplete) - XCTAssertNil(appModel.usageData) - XCTAssertNil(appModel.errorMessage) + XCTAssertTrue(appModel.settings.accounts.isEmpty) } - func test_userWithSessionKey_seesUsageAfterLaunch() async { + func test_bootstrap_withConfiguredAccount_loadsUsage() async throws { let expectedUsage = makeUsageData(percentage: TestConstants.sessionPercentage) let usageService = UsageServiceStub(fetchUsageResult: .success(expectedUsage)) let notificationService = NotificationServiceSpy() let settingsRepository = SettingsRepositoryFake() let keychainRepository = KeychainRepositoryFake() + let account = ClaudeAccount(label: "Personal", organizationId: UUID(uuidString: TestConstants.organizationUUIDString)) + var seeded = AppSettings.default + seeded.accounts = [account] + try await settingsRepository.save(seeded) + try await keychainRepository.save(sessionKey: TestConstants.sessionKeyValue, account: account.keychainAccount) + let appModel = AppModel( settingsRepository: settingsRepository, keychainRepository: keychainRepository, @@ -47,27 +42,28 @@ final class AppModelTests: XCTestCase { notificationService: notificationService ) - try? await keychainRepository.save( - sessionKey: TestConstants.sessionKeyValue, - account: "default" - ) - await appModel.bootstrap() XCTAssertTrue(appModel.isReady) XCTAssertTrue(appModel.isSetupComplete) - XCTAssertEqual(appModel.usageData, expectedUsage) - XCTAssertNil(appModel.errorMessage) + XCTAssertEqual(appModel.state(for: account.id).usageData, expectedUsage) + XCTAssertNil(appModel.state(for: account.id).errorMessage) XCTAssertEqual(notificationService.lastEvaluatedUsageData, expectedUsage) } - func test_userWithSessionKey_seesErrorWhenUsageFailsAfterLaunch() async { + func test_bootstrap_withConfiguredAccount_surfacesFetchFailure() async throws { let failure = TestError(message: TestConstants.fetchFailureMessage) let usageService = UsageServiceStub(fetchUsageResult: .failure(failure)) let notificationService = NotificationServiceSpy() let settingsRepository = SettingsRepositoryFake() let keychainRepository = KeychainRepositoryFake() + let account = ClaudeAccount(label: "Personal", organizationId: UUID(uuidString: TestConstants.organizationUUIDString)) + var seeded = AppSettings.default + seeded.accounts = [account] + try await settingsRepository.save(seeded) + try await keychainRepository.save(sessionKey: TestConstants.sessionKeyValue, account: account.keychainAccount) + let appModel = AppModel( settingsRepository: settingsRepository, keychainRepository: keychainRepository, @@ -75,119 +71,31 @@ final class AppModelTests: XCTestCase { notificationService: notificationService ) - try? await keychainRepository.save( - sessionKey: TestConstants.sessionKeyValue, - account: "default" - ) - await appModel.bootstrap() XCTAssertTrue(appModel.isReady) XCTAssertTrue(appModel.isSetupComplete) - XCTAssertNil(appModel.usageData) - XCTAssertEqual(appModel.errorMessage, failure.localizedDescription) + XCTAssertNil(appModel.state(for: account.id).usageData) + XCTAssertEqual(appModel.state(for: account.id).errorMessage, failure.localizedDescription) XCTAssertNil(notificationService.lastEvaluatedUsageData) } - func test_refreshingUsage_showsLatestUsageAndClearsError() async { - let expectedUsage = makeUsageData(percentage: TestConstants.sessionPercentage) - let usageService = UsageServiceStub(fetchUsageResult: .success(expectedUsage)) - let notificationService = NotificationServiceSpy() - let settingsRepository = SettingsRepositoryFake() - let keychainRepository = KeychainRepositoryFake() - - let appModel = AppModel( - settingsRepository: settingsRepository, - keychainRepository: keychainRepository, - usageService: usageService, - notificationService: notificationService + func test_addAccount_withInvalidSessionKey_returnsNil() async throws { + let appModel = makeAppModel( + usageService: UsageServiceStub( + fetchUsageResult: .failure(TestError(message: TestConstants.unexpectedErrorMessage)), + isSessionKeyValid: false + ) ) + appModel.isReady = true - appModel.isSetupComplete = true - appModel.errorMessage = TestConstants.previousErrorMessage - - await appModel.refreshUsage(forceRefresh: true) + let result = try await appModel.addAccount(label: "Personal", sessionKey: TestConstants.sessionKeyValue) - XCTAssertEqual(appModel.usageData, expectedUsage) - XCTAssertNil(appModel.errorMessage) - XCTAssertFalse(appModel.isRefreshing) - XCTAssertFalse(appModel.isLoading) - XCTAssertEqual(notificationService.lastEvaluatedUsageData, expectedUsage) + XCTAssertNil(result) + XCTAssertTrue(appModel.settings.accounts.isEmpty) } - func test_refreshingUsage_showsErrorWhenFetchFails() async { - let failure = TestError(message: TestConstants.fetchFailureMessage) - let usageService = UsageServiceStub(fetchUsageResult: .failure(failure)) - let notificationService = NotificationServiceSpy() - let settingsRepository = SettingsRepositoryFake() - let keychainRepository = KeychainRepositoryFake() - - let appModel = AppModel( - settingsRepository: settingsRepository, - keychainRepository: keychainRepository, - usageService: usageService, - notificationService: notificationService - ) - - appModel.isSetupComplete = true - - await appModel.refreshUsage(forceRefresh: false) - - XCTAssertNil(appModel.usageData) - XCTAssertEqual(appModel.errorMessage, failure.localizedDescription) - XCTAssertFalse(appModel.isRefreshing) - XCTAssertFalse(appModel.isLoading) - XCTAssertNil(notificationService.lastEvaluatedUsageData) - } - - func test_refreshingUsage_hidesUsageWhenSetupIncomplete() async { - let usageService = UsageServiceStub(fetchUsageResult: .failure(TestError(message: TestConstants.unexpectedErrorMessage))) - let notificationService = NotificationServiceSpy() - let settingsRepository = SettingsRepositoryFake() - let keychainRepository = KeychainRepositoryFake() - - let appModel = AppModel( - settingsRepository: settingsRepository, - keychainRepository: keychainRepository, - usageService: usageService, - notificationService: notificationService - ) - - appModel.isSetupComplete = false - appModel.usageData = makeUsageData(percentage: TestConstants.cachedPercentage) - - await appModel.refreshUsage(forceRefresh: false) - - XCTAssertNil(appModel.usageData) - XCTAssertNil(notificationService.lastEvaluatedUsageData) - } - - func test_userWithInvalidSessionKey_staysInSetup() async throws { - let usageService = UsageServiceStub( - fetchUsageResult: .failure(TestError(message: TestConstants.unexpectedErrorMessage)), - isSessionKeyValid: false - ) - let notificationService = NotificationServiceSpy() - let settingsRepository = SettingsRepositoryFake() - let keychainRepository = KeychainRepositoryFake() - - let appModel = AppModel( - settingsRepository: settingsRepository, - keychainRepository: keychainRepository, - usageService: usageService, - notificationService: notificationService - ) - - let result = try await appModel.validateAndSaveSessionKey(TestConstants.sessionKeyValue) - - XCTAssertFalse(result) - XCTAssertFalse(appModel.isSetupComplete) - XCTAssertTrue(appModel.settings.isFirstLaunch) - XCTAssertNil(appModel.settings.cachedOrganizationId) - XCTAssertNil(appModel.usageData) - } - - func test_userWithValidSessionKey_entersUsageAndLoadsData() async throws { + func test_addAccount_withValidSessionKey_addsAndLoadsUsage() async throws { let expectedUsage = makeUsageData(percentage: TestConstants.sessionPercentage) let organization = Organization( id: 1, @@ -199,99 +107,112 @@ final class AppModelTests: XCTestCase { organizations: [organization], isSessionKeyValid: true ) - let notificationService = NotificationServiceSpy() - let settingsRepository = SettingsRepositoryFake() - let keychainRepository = KeychainRepositoryFake() + let appModel = makeAppModel(usageService: usageService) + appModel.isReady = true - let appModel = AppModel( - settingsRepository: settingsRepository, - keychainRepository: keychainRepository, - usageService: usageService, - notificationService: notificationService - ) + let account = try await appModel.addAccount(label: "Personal", sessionKey: TestConstants.sessionKeyValue) - let result = try await appModel.validateAndSaveSessionKey(TestConstants.sessionKeyValue) - - XCTAssertTrue(result) - XCTAssertTrue(appModel.isSetupComplete) + XCTAssertNotNil(account) + XCTAssertEqual(appModel.settings.accounts.count, 1) + XCTAssertEqual(appModel.settings.accounts.first?.label, "Personal") + XCTAssertEqual(appModel.settings.accounts.first?.organizationId, UUID(uuidString: TestConstants.organizationUUIDString)) + XCTAssertEqual(appModel.state(for: account!.id).usageData, expectedUsage) XCTAssertFalse(appModel.settings.isFirstLaunch) - XCTAssertEqual( - appModel.settings.cachedOrganizationId, - UUID(uuidString: TestConstants.organizationUUIDString) - ) - XCTAssertEqual(appModel.usageData, expectedUsage) + XCTAssertTrue(appModel.isSetupComplete) } - func test_userWithValidSessionKeyWithoutOrganization_staysInSetup() async { + func test_addAccount_withoutOrganization_throws() async { let usageService = UsageServiceStub( fetchUsageResult: .failure(TestError(message: TestConstants.unexpectedErrorMessage)), organizations: [], isSessionKeyValid: true ) - let notificationService = NotificationServiceSpy() - let settingsRepository = SettingsRepositoryFake() - let keychainRepository = KeychainRepositoryFake() - - let appModel = AppModel( - settingsRepository: settingsRepository, - keychainRepository: keychainRepository, - usageService: usageService, - notificationService: notificationService - ) + let appModel = makeAppModel(usageService: usageService) + appModel.isReady = true do { - _ = try await appModel.validateAndSaveSessionKey(TestConstants.sessionKeyValue) + _ = try await appModel.addAccount(label: "Personal", sessionKey: TestConstants.sessionKeyValue) XCTFail("Expected organizationNotFound to be thrown") } catch AppError.organizationNotFound { - XCTAssertFalse(appModel.isSetupComplete) - XCTAssertNil(appModel.usageData) + XCTAssertTrue(appModel.settings.accounts.isEmpty) } catch { XCTFail("Unexpected error: \(error)") } } - func test_userClearsSession_returnsToSetupState() async throws { - let usageService = UsageServiceStub(fetchUsageResult: .failure(TestError(message: TestConstants.unexpectedErrorMessage))) - let notificationService = NotificationServiceSpy() + func test_removeAccount_clearsKeychainAndState() async throws { + let expectedUsage = makeUsageData(percentage: TestConstants.cachedPercentage) + let usageService = UsageServiceStub(fetchUsageResult: .success(expectedUsage)) let settingsRepository = SettingsRepositoryFake() let keychainRepository = KeychainRepositoryFake() + let account = ClaudeAccount(label: "Personal", organizationId: UUID(uuidString: TestConstants.organizationUUIDString)) + var seeded = AppSettings.default + seeded.accounts = [account] + try await settingsRepository.save(seeded) + try await keychainRepository.save(sessionKey: TestConstants.sessionKeyValue, account: account.keychainAccount) + let appModel = AppModel( settingsRepository: settingsRepository, keychainRepository: keychainRepository, usageService: usageService, - notificationService: notificationService + notificationService: NotificationServiceSpy() ) + await appModel.bootstrap() - appModel.isSetupComplete = true - appModel.usageData = makeUsageData(percentage: TestConstants.cachedPercentage) - appModel.errorMessage = TestConstants.fetchFailureMessage + try await appModel.removeAccount(account.id) - var updatedSettings = appModel.settings - updatedSettings.cachedOrganizationId = UUID(uuidString: TestConstants.organizationUUIDString) - updatedSettings.isFirstLaunch = false - appModel.settings = updatedSettings + XCTAssertTrue(appModel.settings.accounts.isEmpty) + XCTAssertFalse(appModel.isSetupComplete) + XCTAssertNil(appModel.accountStates[account.id]) + let stillExists = await keychainRepository.exists(account: account.keychainAccount) + XCTAssertFalse(stillExists) + } - try await appModel.clearSessionKey() + func test_renameAccount_updatesLabel() { + let appModel = makeAppModel( + usageService: UsageServiceStub(fetchUsageResult: .failure(TestError(message: TestConstants.unexpectedErrorMessage))) + ) + appModel.isReady = true + let account = ClaudeAccount(label: "Original") + appModel.settings.accounts = [account] - XCTAssertFalse(appModel.isSetupComplete) - XCTAssertNil(appModel.usageData) - XCTAssertNil(appModel.errorMessage) - XCTAssertNil(appModel.settings.cachedOrganizationId) - XCTAssertTrue(appModel.settings.isFirstLaunch) + appModel.renameAccount(account.id, label: "Renamed") + + XCTAssertEqual(appModel.settings.accounts.first?.label, "Renamed") } - func test_userWithNotificationPermission_doesNotSeePermissionPrompt() async { + func test_legacyKeychainMigration_movesDefaultKeyToFirstAccount() async throws { let usageService = UsageServiceStub(fetchUsageResult: .failure(TestError(message: TestConstants.unexpectedErrorMessage))) - let notificationService = NotificationServiceSpy() - notificationService.hasPermission = true let settingsRepository = SettingsRepositoryFake() let keychainRepository = KeychainRepositoryFake() + // Simulate legacy state: account synthesised by AppSettings migration, key in legacy "default" slot. + let migratedAccount = ClaudeAccount(label: "Default", organizationId: UUID(uuidString: TestConstants.organizationUUIDString)) + var seeded = AppSettings.default + seeded.accounts = [migratedAccount] + try await settingsRepository.save(seeded) + try await keychainRepository.save(sessionKey: TestConstants.sessionKeyValue, account: "default") + let appModel = AppModel( settingsRepository: settingsRepository, keychainRepository: keychainRepository, usageService: usageService, + notificationService: NotificationServiceSpy() + ) + await appModel.bootstrap() + + let legacyExists = await keychainRepository.exists(account: "default") + let newExists = await keychainRepository.exists(account: migratedAccount.keychainAccount) + XCTAssertFalse(legacyExists) + XCTAssertTrue(newExists) + } + + func test_userWithNotificationPermission_doesNotSeePermissionPrompt() async { + let notificationService = NotificationServiceSpy() + notificationService.hasPermission = true + let appModel = makeAppModel( + usageService: UsageServiceStub(fetchUsageResult: .failure(TestError(message: TestConstants.unexpectedErrorMessage))), notificationService: notificationService ) @@ -301,16 +222,10 @@ final class AppModelTests: XCTestCase { } func test_userWithoutNotificationPermission_isPromptedForPermission() async { - let usageService = UsageServiceStub(fetchUsageResult: .failure(TestError(message: TestConstants.unexpectedErrorMessage))) let notificationService = NotificationServiceSpy() notificationService.hasPermission = false - let settingsRepository = SettingsRepositoryFake() - let keychainRepository = KeychainRepositoryFake() - - let appModel = AppModel( - settingsRepository: settingsRepository, - keychainRepository: keychainRepository, - usageService: usageService, + let appModel = makeAppModel( + usageService: UsageServiceStub(fetchUsageResult: .failure(TestError(message: TestConstants.unexpectedErrorMessage))), notificationService: notificationService ) @@ -320,15 +235,9 @@ final class AppModelTests: XCTestCase { } func test_userSendsTestNotification_triggersNotificationService() async throws { - let usageService = UsageServiceStub(fetchUsageResult: .failure(TestError(message: TestConstants.unexpectedErrorMessage))) let notificationService = NotificationServiceSpy() - let settingsRepository = SettingsRepositoryFake() - let keychainRepository = KeychainRepositoryFake() - - let appModel = AppModel( - settingsRepository: settingsRepository, - keychainRepository: keychainRepository, - usageService: usageService, + let appModel = makeAppModel( + usageService: UsageServiceStub(fetchUsageResult: .failure(TestError(message: TestConstants.unexpectedErrorMessage))), notificationService: notificationService ) @@ -341,6 +250,23 @@ final class AppModelTests: XCTestCase { // MARK: - Helpers +@MainActor +private func makeAppModel( + usageService: UsageServiceProtocol, + notificationService: NotificationServiceSpy? = nil, + settingsRepository: SettingsRepositoryFake? = nil, + keychainRepository: KeychainRepositoryFake? = nil +) -> AppModel { + // Defaults are constructed inside the body because Swift 6 disallows main-actor-isolated + // initialisers in nonisolated default-value expressions. + AppModel( + settingsRepository: settingsRepository ?? SettingsRepositoryFake(), + keychainRepository: keychainRepository ?? KeychainRepositoryFake(), + usageService: usageService, + notificationService: notificationService ?? NotificationServiceSpy() + ) +} + private func makeUsageData(percentage: Double) -> UsageData { let resetDate = Date().addingTimeInterval(TestConstants.oneHourInterval) let sessionUsage = UsageLimit(utilization: percentage, resetAt: resetDate) diff --git a/ClaudeMeterTests/SettingsRepositoryTests.swift b/ClaudeMeterTests/SettingsRepositoryTests.swift index 7b7964d..7905d11 100644 --- a/ClaudeMeterTests/SettingsRepositoryTests.swift +++ b/ClaudeMeterTests/SettingsRepositoryTests.swift @@ -15,11 +15,16 @@ final class SettingsRepositoryTests: XCTestCase { defer { userDefaults?.removePersistentDomain(forName: suiteName) } let repository = SettingsRepository(userDefaults: userDefaults ?? .standard) + + let account = ClaudeAccount( + label: "Personal", + organizationId: UUID(uuidString: TestConstants.organizationUUIDString) + ) var settings = AppSettings.default settings.refreshInterval = 300 settings.hasNotificationsEnabled = false settings.isFirstLaunch = false - settings.cachedOrganizationId = UUID(uuidString: TestConstants.organizationUUIDString) + settings.accounts = [account] settings.iconStyle = .segments try await repository.save(settings) @@ -28,6 +33,34 @@ final class SettingsRepositoryTests: XCTestCase { XCTAssertEqual(loaded, settings) } + func test_legacyCachedOrganizationId_migratesToFirstAccount() async throws { + let suiteName = "SettingsRepositoryTests.\(UUID().uuidString)" + let userDefaults = UserDefaults(suiteName: suiteName) + defer { userDefaults?.removePersistentDomain(forName: suiteName) } + + // Hand-rolled legacy payload (pre multi-account schema). + let legacyJSON: [String: Any] = [ + "refresh_interval": 60, + "notifications_enabled": true, + "is_first_launch": false, + "show_sonnet_usage": false, + "icon_style": "battery", + "cached_organization_id": TestConstants.organizationUUIDString + ] + let data = try JSONSerialization.data(withJSONObject: legacyJSON) + userDefaults?.set(data, forKey: "app_settings") + + let repository = SettingsRepository(userDefaults: userDefaults ?? .standard) + let loaded = await repository.load() + + XCTAssertEqual(loaded.accounts.count, 1) + XCTAssertEqual(loaded.accounts.first?.label, "Default") + XCTAssertEqual( + loaded.accounts.first?.organizationId, + UUID(uuidString: TestConstants.organizationUUIDString) + ) + } + func test_notificationStatePersistsAcrossLaunches() async throws { let suiteName = "SettingsRepositoryTests.\(UUID().uuidString)" let userDefaults = UserDefaults(suiteName: suiteName) diff --git a/ClaudeMeterTests/TestDoubles/CacheRepositoryFake.swift b/ClaudeMeterTests/TestDoubles/CacheRepositoryFake.swift index 8e01a9c..dd60888 100644 --- a/ClaudeMeterTests/TestDoubles/CacheRepositoryFake.swift +++ b/ClaudeMeterTests/TestDoubles/CacheRepositoryFake.swift @@ -9,23 +9,39 @@ import Foundation @testable import ClaudeMeter actor CacheRepositoryFake: CacheRepositoryProtocol { - var cachedData: UsageData? - var lastKnownData: UsageData? + private(set) var cachedByAccount: [UUID: UsageData] = [:] + private(set) var lastKnownByAccount: [UUID: UsageData] = [:] + private(set) var primaryWrites: [UUID: Bool] = [:] - func get() async -> UsageData? { - cachedData + /// Convenience accessor used by older tests that operate on a single implicit account. + var cachedData: UsageData? { cachedByAccount.values.first } + + func get(accountId: UUID) async -> UsageData? { + cachedByAccount[accountId] + } + + func set(_ data: UsageData, accountId: UUID, isPrimary: Bool) async { + cachedByAccount[accountId] = data + lastKnownByAccount[accountId] = data + primaryWrites[accountId] = isPrimary + } + + func invalidate(accountId: UUID) async { + cachedByAccount[accountId] = nil } - func set(_ data: UsageData) async { - cachedData = data - lastKnownData = data + func getLastKnown(accountId: UUID) async -> UsageData? { + lastKnownByAccount[accountId] } - func invalidate() async { - cachedData = nil + func purge(accountId: UUID) async { + cachedByAccount[accountId] = nil + lastKnownByAccount[accountId] = nil } - func getLastKnown() async -> UsageData? { - lastKnownData + /// Test convenience: seed the cache for a specific account without going through `set`. + func seed(_ data: UsageData, accountId: UUID) async { + cachedByAccount[accountId] = data + lastKnownByAccount[accountId] = data } } diff --git a/ClaudeMeterTests/TestDoubles/KeychainRepositoryFake.swift b/ClaudeMeterTests/TestDoubles/KeychainRepositoryFake.swift index dd28fef..45228cd 100644 --- a/ClaudeMeterTests/TestDoubles/KeychainRepositoryFake.swift +++ b/ClaudeMeterTests/TestDoubles/KeychainRepositoryFake.swift @@ -9,32 +9,28 @@ import Foundation @testable import ClaudeMeter actor KeychainRepositoryFake: KeychainRepositoryProtocol { - var sessionKey: String? - var hasSessionKey: Bool = false + private(set) var keysByAccount: [String: String] = [:] func save(sessionKey: String, account: String) async throws { - self.sessionKey = sessionKey - hasSessionKey = true + keysByAccount[account] = sessionKey } func retrieve(account: String) async throws -> String { - guard let sessionKey else { + guard let key = keysByAccount[account] else { throw KeychainError.notFound } - return sessionKey + return key } func update(sessionKey: String, account: String) async throws { - self.sessionKey = sessionKey - hasSessionKey = true + keysByAccount[account] = sessionKey } func delete(account: String) async throws { - sessionKey = nil - hasSessionKey = false + keysByAccount[account] = nil } func exists(account: String) async -> Bool { - hasSessionKey + keysByAccount[account] != nil } } diff --git a/ClaudeMeterTests/TestDoubles/UsageServiceStub.swift b/ClaudeMeterTests/TestDoubles/UsageServiceStub.swift index b96de87..0f54c2e 100644 --- a/ClaudeMeterTests/TestDoubles/UsageServiceStub.swift +++ b/ClaudeMeterTests/TestDoubles/UsageServiceStub.swift @@ -13,6 +13,9 @@ actor UsageServiceStub: UsageServiceProtocol { let isSessionKeyValid: Bool let organizations: [Organization] + private(set) var lastFetchedAccountId: UUID? + private(set) var lastFetchWasPrimary: Bool? + init( fetchUsageResult: Result, organizations: [Organization] = [], @@ -23,7 +26,9 @@ actor UsageServiceStub: UsageServiceProtocol { self.isSessionKeyValid = isSessionKeyValid } - func fetchUsage(forceRefresh: Bool) async throws -> UsageData { + func fetchUsage(for account: ClaudeAccount, isPrimary: Bool, forceRefresh: Bool) async throws -> UsageData { + lastFetchedAccountId = account.id + lastFetchWasPrimary = isPrimary switch fetchUsageResult { case .success(let data): return data @@ -32,10 +37,6 @@ actor UsageServiceStub: UsageServiceProtocol { } } - func fetchOrganizations() async throws -> [Organization] { - organizations - } - func fetchOrganizations(sessionKey: SessionKey) async throws -> [Organization] { organizations } diff --git a/ClaudeMeterTests/UsageServiceTests.swift b/ClaudeMeterTests/UsageServiceTests.swift index f5e1ca0..d04e82b 100644 --- a/ClaudeMeterTests/UsageServiceTests.swift +++ b/ClaudeMeterTests/UsageServiceTests.swift @@ -13,17 +13,17 @@ final class UsageServiceTests: XCTestCase { let networkService = NetworkServiceStub(responseData: Data()) let cacheRepository = CacheRepositoryFake() let keychainRepository = KeychainRepositoryFake() - let settingsRepository = SettingsRepositoryFake() let service = UsageService( networkService: networkService, cacheRepository: cacheRepository, - keychainRepository: keychainRepository, - settingsRepository: settingsRepository + keychainRepository: keychainRepository ) + let account = ClaudeAccount(label: "Test") + do { - _ = try await service.fetchUsage(forceRefresh: false) + _ = try await service.fetchUsage(for: account, isPrimary: true, forceRefresh: false) XCTFail("Expected noSessionKey error") } catch AppError.noSessionKey { // Expected @@ -37,22 +37,18 @@ final class UsageServiceTests: XCTestCase { let networkService = NetworkServiceStub(responseData: Data()) let cacheRepository = CacheRepositoryFake() let keychainRepository = KeychainRepositoryFake() - let settingsRepository = SettingsRepositoryFake() let service = UsageService( networkService: networkService, cacheRepository: cacheRepository, - keychainRepository: keychainRepository, - settingsRepository: settingsRepository + keychainRepository: keychainRepository ) - try await keychainRepository.save( - sessionKey: TestConstants.sessionKeyValue, - account: "default" - ) - await cacheRepository.set(expectedUsage) + let account = ClaudeAccount(label: "Test", organizationId: UUID(uuidString: TestConstants.organizationUUIDString)) + try await keychainRepository.save(sessionKey: TestConstants.sessionKeyValue, account: account.keychainAccount) + await cacheRepository.seed(expectedUsage, accountId: account.id) - let usageData = try await service.fetchUsage(forceRefresh: false) + let usageData = try await service.fetchUsage(for: account, isPrimary: true, forceRefresh: false) let requestCount = await networkService.requestCount let lastEndpoint = await networkService.lastEndpoint @@ -71,37 +67,27 @@ final class UsageServiceTests: XCTestCase { sonnetUtilization: nil, sonnetResetAt: nil ) - let expectedSessionPercentage = TestConstants.sessionPercentage - let expectedWeeklyPercentage = TestConstants.weeklyPercentage let networkService = NetworkServiceStub(responseData: responseData) let cacheRepository = CacheRepositoryFake() let keychainRepository = KeychainRepositoryFake() - let settingsRepository = SettingsRepositoryFake() let service = UsageService( networkService: networkService, cacheRepository: cacheRepository, - keychainRepository: keychainRepository, - settingsRepository: settingsRepository + keychainRepository: keychainRepository ) - try await keychainRepository.save( - sessionKey: TestConstants.sessionKeyValue, - account: "default" - ) - var settings = AppSettings.default - settings.cachedOrganizationId = UUID(uuidString: TestConstants.organizationUUIDString) - try await settingsRepository.save(settings) - await cacheRepository.set(cachedUsage) + let account = ClaudeAccount(label: "Test", organizationId: UUID(uuidString: TestConstants.organizationUUIDString)) + try await keychainRepository.save(sessionKey: TestConstants.sessionKeyValue, account: account.keychainAccount) + await cacheRepository.seed(cachedUsage, accountId: account.id) - let usageData = try await service.fetchUsage(forceRefresh: true) - let cachedData = await cacheRepository.cachedData + let usageData = try await service.fetchUsage(for: account, isPrimary: true, forceRefresh: true) + let cached = await cacheRepository.get(accountId: account.id) let requestCount = await networkService.requestCount - XCTAssertEqual(usageData.sessionUsage.utilization, expectedSessionPercentage) - XCTAssertEqual(usageData.weeklyUsage.utilization, expectedWeeklyPercentage) - XCTAssertEqual(cachedData?.sessionUsage.utilization, expectedSessionPercentage) - XCTAssertEqual(cachedData?.weeklyUsage.utilization, expectedWeeklyPercentage) + XCTAssertEqual(usageData.sessionUsage.utilization, TestConstants.sessionPercentage) + XCTAssertEqual(usageData.weeklyUsage.utilization, TestConstants.weeklyPercentage) + XCTAssertEqual(cached?.sessionUsage.utilization, TestConstants.sessionPercentage) XCTAssertEqual(requestCount, 1) } @@ -114,28 +100,20 @@ final class UsageServiceTests: XCTestCase { sonnetUtilization: nil, sonnetResetAt: nil ) - let networkService = NetworkServiceStub(responseData: responseData) let cacheRepository = CacheRepositoryFake() let keychainRepository = KeychainRepositoryFake() - let settingsRepository = SettingsRepositoryFake() let service = UsageService( networkService: networkService, cacheRepository: cacheRepository, - keychainRepository: keychainRepository, - settingsRepository: settingsRepository + keychainRepository: keychainRepository ) - try await keychainRepository.save( - sessionKey: TestConstants.sessionKeyValue, - account: "default" - ) - var settings = AppSettings.default - settings.cachedOrganizationId = UUID(uuidString: TestConstants.organizationUUIDString) - try await settingsRepository.save(settings) + let account = ClaudeAccount(label: "Test", organizationId: UUID(uuidString: TestConstants.organizationUUIDString)) + try await keychainRepository.save(sessionKey: TestConstants.sessionKeyValue, account: account.keychainAccount) - _ = try await service.fetchUsage(forceRefresh: true) + _ = try await service.fetchUsage(for: account, isPrimary: true, forceRefresh: true) let lastEndpoint = await networkService.lastEndpoint let expectedPath = "/organizations/\(TestConstants.organizationUUIDString)/usage" @@ -151,29 +129,20 @@ final class UsageServiceTests: XCTestCase { sonnetUtilization: nil, sonnetResetAt: nil ) - let networkService = NetworkServiceStub(responseData: responseData) let cacheRepository = CacheRepositoryFake() let keychainRepository = KeychainRepositoryFake() - let settingsRepository = SettingsRepositoryFake() let service = UsageService( networkService: networkService, cacheRepository: cacheRepository, - keychainRepository: keychainRepository, - settingsRepository: settingsRepository + keychainRepository: keychainRepository ) - try await keychainRepository.save( - sessionKey: TestConstants.sessionKeyValue, - account: "default" - ) + let account = ClaudeAccount(label: "Test", organizationId: UUID(uuidString: TestConstants.organizationUUIDString)) + try await keychainRepository.save(sessionKey: TestConstants.sessionKeyValue, account: account.keychainAccount) - var settings = AppSettings.default - settings.cachedOrganizationId = UUID(uuidString: TestConstants.organizationUUIDString) - try await settingsRepository.save(settings) - - let usageData = try await service.fetchUsage(forceRefresh: true) + let usageData = try await service.fetchUsage(for: account, isPrimary: true, forceRefresh: true) XCTAssertEqual(usageData.sessionUsage.utilization, TestConstants.sessionPercentage) XCTAssertEqual(usageData.weeklyUsage.utilization, TestConstants.weeklyPercentage) @@ -190,35 +159,24 @@ final class UsageServiceTests: XCTestCase { sonnetUtilization: nil, sonnetResetAt: nil ) - let networkService = NetworkServiceStub(responseData: responseData) let cacheRepository = CacheRepositoryFake() let keychainRepository = KeychainRepositoryFake() - let settingsRepository = SettingsRepositoryFake() let service = UsageService( networkService: networkService, cacheRepository: cacheRepository, - keychainRepository: keychainRepository, - settingsRepository: settingsRepository - ) - - try await keychainRepository.save( - sessionKey: TestConstants.sessionKeyValue, - account: "default" + keychainRepository: keychainRepository ) - var settings = AppSettings.default - settings.cachedOrganizationId = UUID(uuidString: TestConstants.organizationUUIDString) - try await settingsRepository.save(settings) + let account = ClaudeAccount(label: "Test", organizationId: UUID(uuidString: TestConstants.organizationUUIDString)) + try await keychainRepository.save(sessionKey: TestConstants.sessionKeyValue, account: account.keychainAccount) do { - _ = try await service.fetchUsage(forceRefresh: true) + _ = try await service.fetchUsage(for: account, isPrimary: true, forceRefresh: true) XCTFail("Expected invalidResponse error") } catch AppError.networkError(let networkError) { - if case .invalidResponse = networkError { - return - } + if case .invalidResponse = networkError { return } XCTFail("Expected invalidResponse error") } catch { XCTFail("Unexpected error: \(error)") @@ -234,29 +192,20 @@ final class UsageServiceTests: XCTestCase { sonnetUtilization: TestConstants.sonnetPercentage, sonnetResetAt: TestConstants.sonnetResetDateString ) - let networkService = NetworkServiceStub(responseData: responseData) let cacheRepository = CacheRepositoryFake() let keychainRepository = KeychainRepositoryFake() - let settingsRepository = SettingsRepositoryFake() let service = UsageService( networkService: networkService, cacheRepository: cacheRepository, - keychainRepository: keychainRepository, - settingsRepository: settingsRepository - ) - - try await keychainRepository.save( - sessionKey: TestConstants.sessionKeyValue, - account: "default" + keychainRepository: keychainRepository ) - var settings = AppSettings.default - settings.cachedOrganizationId = UUID(uuidString: TestConstants.organizationUUIDString) - try await settingsRepository.save(settings) + let account = ClaudeAccount(label: "Test", organizationId: UUID(uuidString: TestConstants.organizationUUIDString)) + try await keychainRepository.save(sessionKey: TestConstants.sessionKeyValue, account: account.keychainAccount) - let usageData = try await service.fetchUsage(forceRefresh: true) + let usageData = try await service.fetchUsage(for: account, isPrimary: true, forceRefresh: true) XCTAssertEqual(usageData.sonnetUsage?.utilization, TestConstants.sonnetPercentage) if let resetAt = usageData.sonnetUsage?.resetAt { @@ -278,21 +227,12 @@ private func makeUsageResponseData( sonnetResetAt: String? ) throws -> Data { let sonnetUsage = sonnetUtilization.map { - UsageLimitResponse( - utilization: $0, - resetsAt: sonnetResetAt - ) + UsageLimitResponse(utilization: $0, resetsAt: sonnetResetAt) } let response = UsageAPIResponse( - fiveHour: UsageLimitResponse( - utilization: sessionUtilization, - resetsAt: sessionResetAt - ), - sevenDay: UsageLimitResponse( - utilization: weeklyUtilization, - resetsAt: weeklyResetAt - ), + fiveHour: UsageLimitResponse(utilization: sessionUtilization, resetsAt: sessionResetAt), + sevenDay: UsageLimitResponse(utilization: weeklyUtilization, resetsAt: weeklyResetAt), sevenDaySonnet: sonnetUsage ) diff --git a/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsBatteryStyleWhenWarning.1.png b/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsBatteryStyleWhenWarning.1.png index 5de139925c85e9acf52bc772d8a7732c7288090a..ab709678e929435735e9c2358d51aaf6834f467f 100644 GIT binary patch delta 2986 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 Gk^ctpxW#+` delta 2924 zcmaLZ=OfgCqZuh9mtF`v=lMOk31R6Czi ziy`T*07T#K+Yxl<;{@4%9S2UQ(A3XlC%xsPzPsZNw@;DurK#pzAFlUyF_}0q%)%XY z!Rj?leAL5XH9`C1x6ibs&Bp9%o?GAUE@#<43Th-eL$;CQGn!=yvYrJQUB!t&1g7LA zcc}>{o%d-~@{k#drun%*;m>Ncc|jx<;2`Ceco4Uvez|?nA7qG}lCjg% z_#1Ppq;u@l+7yc9Q(|;Q%A|nleQ(MIBq`5_~hJuDQZr=cx! ztgyKJ=IN;mgZZ`T-A`1Xg0_l5sS)brbrgy@75XlrAW-qWTm1ZHtQs{2<}MEJ`6&4F zzWZ${=EF3`lM@ZLv>MHMgU76o;5C7lOTQKold~s!_x;xG|1z4tS8oFRO(I;WXR#eh zB5swZf8@CHGq|C^?M(~pUHX#!k4kEGi?*zj-ds=0^DpY@=dxDqW#GVf?=6Lp?)KMLS4&1OZJ=Z-$Vq3y?kk7RfAC;y;@%u`j|$ zO;4lw2UY5ssFCEvtO6|U(eb&VFZ0cQG<$S~$L@AsolKbWVpDQNoP{jwEP1^2J%f&^ zWv=-8bpa~}?0sgbiJ$Y?b+tL%?rt?+E0AEi$;85*8JqGDvo!n&X^W`AyuB!vbC5Ni z-saY!#R6i){VNW$%?V%CRZ|v!)Hc|+Thp$e2GxfUd%QH0fn2NS^;8;a4U*tPkvs}1 zUA}yx?JJjAk1m~fBc{Z>^$bEIK6_SA_cq$9ZKcsB^WSMfmRfY~nRy(6OHRZ&S{>Ii zH%A`8KUDT$w{-%~H3%ZI*0 z9Cp~I?z!(UAe3%!oFbOTY}80~pcbuh+OmF6DWw=_p`7>4o;t>^LN!p^{ zqs_ra^V}*=B$uOTQDLP9T3dfNh6c1?ogDsbAb=idNt$+(-&}VJI(4KOkdJ6#2uD5> zxKcqMOw>Ptc!igh8SfGq+2EU&3GFOn0VT9-cJ*0bwZmh>>6A#w*F<1b;>DG#2 z>(gPH-V!V?!uMDkvpKp`J*`KwYOOk6>Nrm7!S)OV+Ggp=n?jJ~Z2UKVN0Gi7fN~ei ztsep`{wXOvWxRqCo-xs&BYx;KBv3~FP9D#g2 zimEa&io67Y_fO!LC6_}4cdS|0#hIK~DPT>UN?7yxm5 z_VXJ?I0eI5iIyciiGF!?Va~&3tZonAG*(pyE_YagABAlCQU;xU66$r0+@Zbw2f7o1h&Ov^VNJ#=ZD;sJ{~#g~b;etSmcz(l$lLPy)L zg5jwswh~v$6BDDTnVY?vbzTc302I^f(5Z4{9@4I3`j!>HBQg$pup+W)+N%CYwr;h0 zX$qhV$Q&?tuI%yOgLp)^_r7w?cyMktuu1Utd5)Aejuc?td~+%E5V0z5&tH<$6%lA_26oEXIA00Yr%S)3K0K zD=SZWDK$u3Ezfnyz>+SAV=Kq9ge?;{_d->@)w#dtM)NuVSXON&%E0i@6-|R)35!TAUKTz;D zCS8}~uG{vp%>)n?M*&$pykokm_Y)f^ll>$oGN=tK4;qo?SVQ(8l}Kh+mSXg@Dr1+&@yuc$2KUS(#^X2@0K~zGE*! z9S$y-_NdQ(B}f`_S~KDf85_je5>FC7@00dk-s0QfAe&;CV+UkH zSb$U!A%-u~h~;;^ihtS0d^t)i+~s7aZJH+Onul3%X+qlDtZPnOyId|laASn~MEh-; zKnxD~Z>w4+TqJd5$ JrA)~x_&?bnxU>KO diff --git a/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsCircularStyleWhenWarning.1.png b/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsCircularStyleWhenWarning.1.png index 412483bf182d7c3ed85d66afbcdbe7df6173a495..d54ce83bdd861a7e7d670738730f193802fe65d2 100644 GIT binary patch delta 2194 zcmV;D2yOS462uaa%70}^L_t(&1>IT=Y!t^8es9 z2E~CwgI#QV=FYym-RXOGth?;auFr63+KzOddEa~UX5PNpnSVEL4e)GPhzg*Ljr9!9$%kXzk7DN9$9!LXqAPJuHL&DcNtoYy7b@jk62fuuP`sulvM@|fC zX=y0}@J<#Ybt?dRjL9t%FRm(r{`m$it94Lfy;!_$pT1!IcSGS`nao(Qn=)v_hONc< z`Ff?MK{=vs(|=Vjw(gY)l}4Gf9c7F+wOB$$X@$;$$)z|Lb#u4=upP4h1|ZKcG5w5Ce;% z7--Qj??c&y%$NdtaJskSSlfngea?)#wX=F^yv$N5_yQ?%G)C7kq3inDSgfy`)_%fm zP=nVKnSWhbh>2%>V(1|Qlhy30?c?euE?>U(ii~f@Ml~q20q{Emv^C31Dy5zC`_-LG@wH|}xbGwx~-ADT{EGka#miWL{!l-~$%e8a&VG01`)o5{i%-pzOea8j5nyd!_o zY=0+R^Oe7>bK*%2qMby^1e*44yfxqGZf4i!jLdlaPkqDa|FcY@O!O`1Im&o>GLWwT z=BU|_;X^YP>$UA@qV-N6Bz$)mawD||260vUha)sKNVXgjc{4%5V$hW~7;NvrFa-sY z5*6OnKCqurbl?Ua8@GRM7+}A(o776a&wp42nWiP!OHamrXr?^70JV18FQs*hrs|R9 zA)2aIL6RZ_gx5fb->2mZYu-p3DH*W5@#uR_5rv{?b0u9s>LI?IA=-~93TOTyQv$la zlgz#T6h~M{vi!i(hoxw)egFhKlkSi32Wo#n?d9aNxsdd1r1qm}p5;#ryss2Os(;=B zgHIolhC@<=_@+tTue^Ghs_I^ua6BHrD#LA4(-go{&4tJS1+BfUP1%kML$7veabqC zv@wO%zb3qne*FF{!-kHRt#a4VDc@%}Ut}$uL9${@OPAb2eb?f&F{U0Rl)YIJJUqn& zUDp)hyS-#a#t4L6*!bQ_vwuLwn40GaV`FHBVktJBe!(mwYY%EZzEs)@r^9R@0a6bY zVYE^vJYMwvfbd2c4}y9v88nG_0yJUs4iwgq_XK;8i%Q=XGCub|^6aUt(%eYXo z=xx%Y1AIj^a54O!uh|6GPMhAX>za%Bwb<9q|x z*239VP9a-yrllTYMt@(^?B5jOz*25o%SBN#c|zz=w-wJN&3T6D@iUiYIxk^qOGU%M z1{qK4Ar|b*D`}Q>V5}6Qd!2Z>!Z~T;q>J<5PCQ40rmTLgEe?S{KX+#&lq}Kw{1hnTz}7~f933k=p$7su$v>DjvA;aR2FzgQ94G>=H!P9aPMWjfQ_{eR*gFRXL=pQrx?e)1TU U_q delta 2147 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 N7jroB2HDs>=|5~2=*9p5 delta 1941 zcmaivdmz(^1HebbJjW`XqTfwr88!-eY=)7?7$Ig@#qv7ItI_byBjt{~>QD|TugnlG zBzASxN~d|QbwVVgJ!+P1mipaazx(s|`TX%lK=k~*cI?+b&t@o9aVSizT8R>FFbS$e%bHZTdFcT=&Vk9igFu)4m{*dx%w)QzjfPFO zK`u15nm{t%+SP2W%^_Q(@r}KAP-vZA!l}sdBVo#deDZyRSa$T-4rU#`f&RO#V~DXn z;;k#02%A1L#P=3+;|GZPDW-d3lirj3*ES?TQO*9*N~f?l22BQu(1o~XOVkwaGu5Wc z(Po5uXIEILf$*8?aebeOiR-z{7U;R&BfGl9>Qgy!VQZC(Vm%ez4CY>NS?7AV2eFDS zxg|cDn7UjRRBh%3iYCSGdt50Qa=R1m;7aTDDx{mpLu;`fVT|P>z#N@wXzJ^|i>9h#AuOZN^qh>&V(5_BjwtVu)#;LS% zF4w6jSYfm5%A*FF@si5P>+9-{go6v{_{ALR@gWS7k^Tjo5YJs1 zJ$|V%k`om%+@Oehx#oE#p{{)FAi%FI?0Ybmr1$~lEM4>19$?0Uxmz>w66~(PJgEBu0gPK!4CsXttVk-UvJTkf66KOW$J`}gVBGoz0( z8fO?Wa#u+ac*GHjp8jXrF4LhHA2VN~*sSQ)_F`Z6%SJwNC~fgP`^g&+?iiX-j*Ga9 z@HH}Ge=+)nrB>~|t-8Q5YnTaZGdBrr7usD#*Twb}+%DO!`TQ;11c2J|RQ>o}f_`u0 zxfH?WL8>*VW?rIf8iNi7Q7AW%Bknpc_Qa(7o&hi_cqmwSyyCY5e`WF)%jsVi?c%~4 zZYHX_ybq1vVqNCS?Eo?TZ{}$c7qqFu^+2~r-&+*dq`f-!pNfTJ9Z4z_UC>PX@T%~M zFB05i(@s6;M37}YE+;DWCw9s9v2Qf7M)29HnTO=?0p}qSyvMIZ8I#cQJ=KGB-~fMx zq=XLtlXRAGU?3!3Xc8T^uo8T(*WcA@xQ zao0JY*H)HolS=iyI3*u!P}zq}vy$R7^V3(HqVfRvSJ9(}aYZTq<{XayYyyk|A3eyvl3iA#%%gtX$t z-9$Y}|jlyn38lpc6E!6mg@hp?_u)+1L)&OG;O0+ zSh5PMGFuMi#gRX#F-}<UeJR{#H%+5$GMH=czhr?;-?Zct|<0 z#24JGLAA7g_ZBjVdEeAp*gHEB=ur11k`E!m3DQ<&h?I-GW#koG%GOMO5VM`rG>B`a zgr(Ex5FoKzzvNfH$+Q#Qm*#qe<@5SQsSHl=Tg|Qzmt40(l>M;7sL33K5VAL$>NvS= zE9}jNzGt1+J1>&9QeDzy9gTnhZXPbb-l^bTDsx4jPJz%DPGHxsiJ%-Pdo{8avvX-D zJ-l+_Xiu()VuEY&`24D3dh+fu8O+^7cSgi+4A?Tv@~yS)OYlHen(eVhiRMk^{n9CF z)6A>OV?kT0Z83`UF&eRZR!zNX6UorvU;KjAh`wcI2~=v^rFaNW5&$v>+i-0%uUwmr zG-+?)KHlGYmRVH@(O}*yceLJa8YiLnzTw6!b5rAITz#3LM0s`OHJoj7jQ^j7}zFiOQ@+Ty)^6Qq}LSf^zQqF*9F)Z8EH8+VP*zE43 zv{ZF@uWva#TV*{xZ4rNBRn>1(cm&<&PhPo^vtL!jyA_$ diff --git a/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsGaugeStyleWhenWarning.1.png b/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsGaugeStyleWhenWarning.1.png index d7b0a0f4617652ece8e80c25d81ecc91d8821375..efde8e1698cd8a1646efc6aa319ac920de32a65e 100644 GIT binary patch delta 1605 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 diff --git a/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsLoadingIndicatorInBatteryStyle.1.png b/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsLoadingIndicatorInBatteryStyle.1.png index 22b1a39ebf027968bd1b5e288b77e6de060de94e..8ccd7478acda589163e5276ca13eef6b90f4cccc 100644 GIT binary patch delta 854 zcmV-c1F8I!2$Kkq%6~LTL_t(o3GG&2NK;W5|GvAeiBuL@B#2o(N%Wv|sPiR3GAgN% z<+dKQ2=h${sRv(zpon^?ry!|FVVf=UDSGJ-SZ!mQB1tIJgAqlLVuA+K?S6gd+J&+1 z*u8TP1^2+&Ip_Pn^ZS1LopZl?0nBD9U@BlLU@BlL@J}d!#eZPo4113d!XN_n#hd(3 zi-9Q>10+qb$~YAS6s(W>P80*PD8we41Fu-EE+CMA=y6@R^Y})|6@{21(O#NHnkJ-+ zOh}SIdLoW@BjgIcV~&Kk98!202|561k06vyglteVQ6HGiw(D72^#o zXp7<__yGu0Jby3eUaQ7SNqOC`8Vrno{mXoN>m%L)BD&3Sn-S=4TE^;xE3hlk6gbTH z>=N6>g8+zdF5Wy67xO}zp`&z0JlzENkP|I12@jWs7i!YFu&+%*8e`?`AY)I$<-f1` z3K?}1JM_A{WVs~yk&{%iQcNm1q9$~{>vWPNjq}>hzJCks8&rLTj=GO>*}i(7lT>ma z%_JofBciaUdF(9)DS@e*7#~rK*Z{ITtc)2yF;{SG@0V0rywQIz!@QxXfv1vcL?#>B z6IvNVr$cEiyRbDx&ury7cXIWoW%Pa9@Jg<@OuK%?lv+i-XttkAi?YW` zYYIlTUu$iE7`_w$GU8b+pjwY0eBlAY^PM(GmtpMb(PnDtP4gn?2-eUq+f=cJM6yzJlR9W z^3()}St1uAa(_0e)zmMgs$v34G8UQ`m`w*UKnU}RU9Ou&w-)JlX_>&7rbN4 z<gVH6K#5lGz>k1N9|>B{BbhfzLvfTV&D;h z6g83kK(9hdTrGB{A1T%yPs5D^^D=~`Q%PV>s`KNu!_zXr={_nXIymQjb2uygmYN|p zMZt<)?|=Q9(t}F%5qAkc{4CEh3tp61se`Zhh0!br2{>`D{k9D@P{ z8Jy0EmSw^-;@?YVC)H2mzTq!fT(X*^i7iynZ4dC3YMUploydx|!dN$UzyX}!6=n{+ zn?7m2!X@WWn%Lx)V4QCIu2c|?w>+aQsp}&bvVZ$cOT}Vhsh^l^BXXm$&+G5!^37() zY{u1W^rzZI`|c(mvHMUG#fBLvQ62QItO9%OF002ovPDHLkV1njimLC8B diff --git a/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsMinimalStyleWhenWarning.1.png b/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsMinimalStyleWhenWarning.1.png index 1d89340b120b39d2c96decd474e7658478527e3f..09f093c4d6f1a1f56e409bd740719b24f77987f8 100644 GIT binary patch delta 1688 zcmV;J250%=4bBdb%71T3L_t(&1?^aAY*bYgKIhF;ik+eQ2QjiqDl?^sH6-8<7bFo( z1X*H`MNC8#G+`}^sE{F|Vd+#MkU(TpNJtPQ7!2qy#27;iQDkY0C`(90;}W4z+L^xN zci+6_&UCgG35j_pd2`Qp&%O7%=WavflaGLpfRBKWfRBKWfPasGkARPWkAROrVFb9a z%dPQ{Sk)cWEjfrJ&_WGOQ`PJcEp*i>utUZ z204_7hf-a%TGl0}@$9oLmMvVm47OJ@>QoRL2V`Bv1Ms_&VY5U4uold0GUQk`yRLJP z)pfUqZ@RV(finSU&C=LpN(AFdZ)FbAEZgTg%1!vbw}0jC@O$3o(+VpC6ab2ZvVx>n zC=P@`a3dM~RNjy1O+7I93yv&xJ-dQ`&XG}GM(3W!azO@|mFVd^(6xDXdDh}rSKNm( zWb609e60lJwH$cv$=^^>eK`r}FVu`eCco#( zX82KJ>3`zNBr|wH;}=TCx^6(R4E4)Vre=@0m(*4bP<9s^=A)K)_4IKHM{j^HMNC!4 zC8>+P3vd_NidvrEu*5ayVT#LyJOPwRYAYW{F~W5d{)89SpLAuWM_iuh28BWgH95ty zU}MzCNcJYPCABXowJ8;rH9SydhrOY)0>dJyjejl&0CqOY2gm?@QXIFe<$nSKvAIHv zZX#uuYcdl0&N%k<`>oGR^vQZ0n#Pc{I)UJ6DlfkXU22&yjg>W#;Y3Mirj`3S^&|1h zrPy$nDIT<*YZ~XJ*y4VRIwJFh!VHW=2M%dIyD$rx8=z)x&q{p2!|?hF7b-c4nK)K!kdIrgdDF%@nn z*VIq-PO(P1!bdrFD%%n4#&wuZgCH@i@No1;1gf%wwIMYtc~SjOPVjuCb!>{Y zK|bO7F&(9{vf)SVzFVTsJb^sTcTSax7T+ynk20CbmD={NuxgWKW-O_!DJ{C{!? zMJ6Gl0{~K(b`}kQdN)XDAL@Gm5S<~@990m2c;qz~0HR$KY^%Xww`TyL2Kx|hiyIVE zh&ly=#D)ctk})lKlFX#K@DwDgZrI!n$B~-zMGRyF?hJoux^^t?60cyeseuXard|zG zDG^W`^WC=dA@plH`16)5^o+b;t$$~S?Ur723IqyYehFbJyioixFFO0PCuirZB^0{6 z18FnH^$@y5hWTWnw8c$u8tQDqrzF?GAEO=E&-QZYhnU?x(om*%>i7dB=dVVc=yvsJlQ%Xg$Wg)xAV9AEaPn zocqMKBjK2yh~sisN|dir=LkSkOXpromy~Y2xbdp(p0(&$CK#>if#U#xws>nH0IXJ- z@e*{VD{DX6D@8E#W_N(6E>U07mFDprJMXHgL%o1%_u1%yXJ z-(qxgJVkDlW4OJm(PE?Z^$Zq+I`ZS(OogTNpZ+;Ja1bhU11bhU11bhU1 i1bhU11pWsI{0q2k@DWOK%o_Os0000NeE* zeYA!`8}8-rENEOxTbZ^0MfyHdRK=|NOVV1k3h!7sl;{>b zMZ+$Rqd(NaDt}9I89iGi`7=E^KdW%I@1#3$5?ay4$<(H%=sZfvO`~lZeX8_EEK!XR zU_bzxsK3?lsh>$?`{;Kr{eDfUQyf3s21&IPg5`2pv7ykVIEPjH-f zTK(f(3VoSAnQ}qPL9J#9gLyLhJDD%P+X-O8$MBewN8xhvlJd!>P_G*BstBZ?k~FdZ zc+%w81Aora?KofT9y9|+K7jpU04@B;d2l} zCKuwg&C|~^wG*gzehT}9XBIt7(q#25TxNGzz93T27MUxO-h`P<{XL#fH@`xyC+B53 zxv89-Y_g`N`H&Z=o2a)|8j_>Wx+59 zmVcfFp|4FTImt=_&`V7K01JgHY~mq-oXl1I27RY`{yQ0?#*@J_#&|A@*_`x5zV9eA?{)H5(`UW$)7*aJ zxQ{-vq$+9u3==S*=7(&-rbGJAh(XZJe1BCrK4iEHY{+2_WQtmjFVbHTE}3n!)D+08 zJdXj^f$6Lnawf>yxC=Be12!55g%?S8P=22DRM$yy7fE0)x5)iYmxUSmawx|_#tZ!4rYxfXJt*_!|354XF4a%3z5bQ0`P;=g)poiw|EolVJ0r(NF>f_)?EtpGCto} z`Fj0y1~}H>lM#WymoH|JNnW5Ac!vMVlS5gvgepw>&h;D)aA94OFKtb5EMK2Pff=+5=hwABkvM%(Dl&3|f*Q z=NIEVzw=n@Id<{ro4JK~X3Msrg;S6WN;Qqu{1>5&XAS>EnY71ofZ+U00DtPJ=;RY) z|2|gz`OJX9R1o~`_%c+He~W(pZj-V*$8<)JEx4IKHpZB*R$)5HEii$}c~iO7Yg@&`A2CvvM=DIyKUe%N&ET8(mCDe%5aKWir);M<)^qz7|RQe zMiKxCsIfI40QSAXO)$$aLVwjUCVT~fkVDjK7=s$MI2{1fYcyWrS>t>1GOjD^V@&+n zo(}+VEVd@x6htTb@?Su3Ty^R!OwN5jYo9oNp=}SRK92y%&GDFKCK)r^wE*F_x4CoN z?klqKMJ!^V@&Z?xsTU#sruE6JP>N zfC(^xS`&~&?w z&3;lEB@AD7F$CS zO4rQ&N;rrbeWxa6A4=Nc7HDpibj!j zkD#>GXdl=Sp?|=3?x3sJ`)DRb|N8AN^D&qSnLsT5xPAV=N0i}+P(XzoM2)^ngRjp% z+z>Kw(oU&1#;A6oojzo~*FhnU7d8R6&a|9%rV61La2O5K0%>{(baQ1!v^?UJt^CI;ZYWmC^bDQWmFCZ+O za*0c8n&Z5)c?1-)p{u}26B8i1%hXm2X0Vn_E}`N@C9E^U0{yO62}9~B|TF@)Ugt#8#>6t(CeV z9s>0x=mXpeGg8IGj?|&XPJ2}@XWvJ^uk}gQU2G@BKmQw=3xHilzz7%tBVYuKfDvdl z0Vm8}vK7K=1r>U=$S@qf-gG+nfCyLNw4H;$+01S-why4 zisK44U+q0BqJJr~t*=4%X}5>zWCZC`{%epf14biD!g2K!)MP}CRn!#^vwVfBu5t8L z?H>{j&wqC}fMOCQZGQ^!3wLyP;RhDB)!RoNVx>53JX+&?jOJg%esDlMXGtZhjZ~%m zSKWo;w!Q|fQKUv)SaGD1aSTvds~n%8YG-5YTOZ3K*f5ikNq;210k`~za`|33kp9$^3g002ovPDHLk FV1g=7Frfed diff --git a/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsStaleIndicatorInBatteryStyle.1.png b/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsStaleIndicatorInBatteryStyle.1.png index 310e57d12a8a6092999a2c0fe21c93702f264dcc..961370f96f9168ca0f3c4b6e008cb1aa77c4f57c 100644 GIT binary patch delta 2188 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 T#6+f_9r`O%V-)hOp-araJTp9* From 419d08b95f915de3497589cf97c585a146748d60 Mon Sep 17 00:00:00 2001 From: Jean Date: Wed, 13 May 2026 10:21:53 +0200 Subject: [PATCH 2/3] feat(notif): prefix notification titles with account label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-account users were getting "Usage Warning" / "Session Reset" notifications with no indication of which account fired them. Now the title is prefixed with the account label, e.g. "Client X — Session Reset". The body is left untouched. `accountLabel` is required on `evaluateThresholds` and optional on `sendThresholdNotification` / `sendResetNotification` (test notifications fired from Settings pass nil to keep the bare title). Known limitation (not addressed here): `NotificationState`'s warning / critical / reset trackers remain account-global, so account A hitting a threshold can briefly suppress account B's notification until A drops below. That's a separate refactor and the existing behaviour is unchanged vs. the rest of this PR. --- ClaudeMeter/App/AppModel.swift | 2 ++ .../Services/NotificationService.swift | 22 +++++++++++++++---- .../NotificationServiceProtocol.swift | 10 ++++++--- .../NotificationServiceTests.swift | 20 ++++++++--------- .../TestDoubles/NotificationServiceSpy.swift | 10 +++++++-- 5 files changed, 45 insertions(+), 19 deletions(-) diff --git a/ClaudeMeter/App/AppModel.swift b/ClaudeMeter/App/AppModel.swift index 8fc9395..d614907 100644 --- a/ClaudeMeter/App/AppModel.swift +++ b/ClaudeMeter/App/AppModel.swift @@ -142,6 +142,7 @@ final class AppModel { self.accountStates[accountId] = done await notificationService.evaluateThresholds( + accountLabel: account.label, usageData: data, settings: settings ) @@ -265,6 +266,7 @@ final class AppModel { func sendTestNotification() async throws { try await notificationService.sendThresholdNotification( + accountLabel: nil, // Test notification isn't tied to a specific account. percentage: 85.0, threshold: .warning, resetTime: Date().addingTimeInterval(3600) diff --git a/ClaudeMeter/Services/NotificationService.swift b/ClaudeMeter/Services/NotificationService.swift index ecd91fc..6d438fb 100644 --- a/ClaudeMeter/Services/NotificationService.swift +++ b/ClaudeMeter/Services/NotificationService.swift @@ -36,6 +36,7 @@ final class NotificationService: NSObject, NotificationServiceProtocol, UNUserNo /// Evaluate usage thresholds and send notifications func evaluateThresholds( + accountLabel: String, usageData: UsageData, settings: AppSettings ) async { @@ -63,6 +64,7 @@ final class NotificationService: NSObject, NotificationServiceProtocol, UNUserNo if isNotificationEnabled && shouldNotifyWarning { try? await sendThresholdNotification( + accountLabel: accountLabel, percentage: percentage, threshold: .warning, resetTime: resetTime @@ -72,6 +74,7 @@ final class NotificationService: NSObject, NotificationServiceProtocol, UNUserNo if isNotificationEnabled && shouldNotifyCritical { try? await sendThresholdNotification( + accountLabel: accountLabel, percentage: percentage, threshold: .critical, resetTime: resetTime @@ -80,7 +83,7 @@ final class NotificationService: NSObject, NotificationServiceProtocol, UNUserNo } if shouldNotifyReset { - try? await sendResetNotification() + try? await sendResetNotification(accountLabel: accountLabel) } if percentage < thresholds.warningThreshold { @@ -96,6 +99,7 @@ final class NotificationService: NSObject, NotificationServiceProtocol, UNUserNo /// Send threshold notification func sendThresholdNotification( + accountLabel: String?, percentage: Double, threshold: UsageThresholdType, resetTime: Date @@ -104,7 +108,7 @@ final class NotificationService: NSObject, NotificationServiceProtocol, UNUserNo guard await shouldSendNotifications() else { return } let content = UNMutableNotificationContent() - content.title = threshold.title + content.title = prefixTitle(threshold.title, accountLabel: accountLabel) content.body = threshold.body(percentage: percentage, resetTime: resetTime) content.sound = threshold == .critical ? .defaultCritical : .default content.categoryIdentifier = "usage.threshold" @@ -120,11 +124,11 @@ final class NotificationService: NSObject, NotificationServiceProtocol, UNUserNo } /// Send session reset notification - func sendResetNotification() async throws { + func sendResetNotification(accountLabel: String?) async throws { guard await shouldSendNotifications() else { return } let content = UNMutableNotificationContent() - content.title = UsageThresholdType.reset.title + content.title = prefixTitle(UsageThresholdType.reset.title, accountLabel: accountLabel) content.body = UsageThresholdType.reset.body(percentage: 0, resetTime: Date()) content.sound = .default content.categoryIdentifier = "usage.reset" @@ -145,6 +149,16 @@ final class NotificationService: NSObject, NotificationServiceProtocol, UNUserNo // MARK: - Private Methods + /// Prefix a notification title with the account label when one is provided, so multi-account + /// users see which account the alert refers to (e.g. "Client X — Session Reset"). When nil + /// the bare title is used — for test notifications and any global / not-account-scoped alert. + private func prefixTitle(_ title: String, accountLabel: String?) -> String { + guard let label = accountLabel?.trimmingCharacters(in: .whitespaces), !label.isEmpty else { + return title + } + return "\(label) — \(title)" + } + private func shouldSendNotifications() async -> Bool { let systemPermission = await checkNotificationPermissions() let settings = await settingsRepository.load() diff --git a/ClaudeMeter/Services/Protocols/NotificationServiceProtocol.swift b/ClaudeMeter/Services/Protocols/NotificationServiceProtocol.swift index 55ad812..a2875a0 100644 --- a/ClaudeMeter/Services/Protocols/NotificationServiceProtocol.swift +++ b/ClaudeMeter/Services/Protocols/NotificationServiceProtocol.swift @@ -49,19 +49,23 @@ protocol NotificationServiceProtocol { /// Evaluate thresholds and send notifications for new usage data func evaluateThresholds( + accountLabel: String, usageData: UsageData, settings: AppSettings ) async - /// Send threshold notification + /// Send threshold notification. When `accountLabel` is non-nil, the title is prefixed with + /// the label (e.g. "Client X — Usage Warning") so multi-account users see which account + /// the alert refers to. Pass nil for global/test notifications. func sendThresholdNotification( + accountLabel: String?, percentage: Double, threshold: UsageThresholdType, resetTime: Date ) async throws - /// Send session reset notification - func sendResetNotification() async throws + /// Send session reset notification. `accountLabel` is prefixed to the title when non-nil. + func sendResetNotification(accountLabel: String?) async throws /// Check system notification permissions func checkNotificationPermissions() async -> Bool diff --git a/ClaudeMeterTests/NotificationServiceTests.swift b/ClaudeMeterTests/NotificationServiceTests.swift index e6ef1d0..7d00b35 100644 --- a/ClaudeMeterTests/NotificationServiceTests.swift +++ b/ClaudeMeterTests/NotificationServiceTests.swift @@ -25,7 +25,7 @@ final class NotificationServiceTests: XCTestCase { let usageData = makeUsageData(percentage: 80) - await service.evaluateThresholds(usageData: usageData, settings: settings) + await service.evaluateThresholds(accountLabel: "TestAccount", usageData: usageData, settings: settings) XCTAssertEqual(notificationCenter.addedRequests.count, 1) XCTAssertEqual(notificationCenter.addedRequests.first?.content.userInfo["threshold"] as? String, "warning") @@ -45,7 +45,7 @@ final class NotificationServiceTests: XCTestCase { let usageData = makeUsageData(percentage: 80) - await service.evaluateThresholds(usageData: usageData, settings: settings) + await service.evaluateThresholds(accountLabel: "TestAccount", usageData: usageData, settings: settings) XCTAssertTrue(notificationCenter.addedRequests.isEmpty) } @@ -66,7 +66,7 @@ final class NotificationServiceTests: XCTestCase { let usageData = makeUsageData(percentage: 80) - await service.evaluateThresholds(usageData: usageData, settings: settings) + await service.evaluateThresholds(accountLabel: "TestAccount", usageData: usageData, settings: settings) XCTAssertTrue(notificationCenter.addedRequests.isEmpty) } @@ -86,8 +86,8 @@ final class NotificationServiceTests: XCTestCase { let usageData = makeUsageData(percentage: 80) - await service.evaluateThresholds(usageData: usageData, settings: settings) - await service.evaluateThresholds(usageData: usageData, settings: settings) + await service.evaluateThresholds(accountLabel: "TestAccount", usageData: usageData, settings: settings) + await service.evaluateThresholds(accountLabel: "TestAccount", usageData: usageData, settings: settings) XCTAssertEqual(notificationCenter.addedRequests.count, 1) } @@ -107,7 +107,7 @@ final class NotificationServiceTests: XCTestCase { let usageData = makeUsageData(percentage: 95) - await service.evaluateThresholds(usageData: usageData, settings: settings) + await service.evaluateThresholds(accountLabel: "TestAccount", usageData: usageData, settings: settings) let sentCritical = notificationCenter.addedRequests.contains { request in request.content.userInfo["threshold"] as? String == "critical" @@ -128,9 +128,9 @@ final class NotificationServiceTests: XCTestCase { settings.notificationThresholds.warningThreshold = 75 settings.notificationThresholds.criticalThreshold = 90 - await service.evaluateThresholds(usageData: makeUsageData(percentage: 80), settings: settings) - await service.evaluateThresholds(usageData: makeUsageData(percentage: 50), settings: settings) - await service.evaluateThresholds(usageData: makeUsageData(percentage: 80), settings: settings) + await service.evaluateThresholds(accountLabel: "TestAccount", usageData: makeUsageData(percentage: 80), settings: settings) + await service.evaluateThresholds(accountLabel: "TestAccount", usageData: makeUsageData(percentage: 50), settings: settings) + await service.evaluateThresholds(accountLabel: "TestAccount", usageData: makeUsageData(percentage: 80), settings: settings) XCTAssertEqual(notificationCenter.addedRequests.count, 2) } @@ -151,7 +151,7 @@ final class NotificationServiceTests: XCTestCase { state.lastPercentage = 50 try? await settingsRepository.saveNotificationState(state) - await service.evaluateThresholds(usageData: makeUsageData(percentage: 0), settings: settings) + await service.evaluateThresholds(accountLabel: "TestAccount", usageData: makeUsageData(percentage: 0), settings: settings) XCTAssertEqual(notificationCenter.addedRequests.count, 1) XCTAssertEqual(notificationCenter.addedRequests.first?.content.categoryIdentifier, "usage.reset") diff --git a/ClaudeMeterTests/TestDoubles/NotificationServiceSpy.swift b/ClaudeMeterTests/TestDoubles/NotificationServiceSpy.swift index 400b2a9..02f3a5c 100644 --- a/ClaudeMeterTests/TestDoubles/NotificationServiceSpy.swift +++ b/ClaudeMeterTests/TestDoubles/NotificationServiceSpy.swift @@ -23,20 +23,26 @@ final class NotificationServiceSpy: NotificationServiceProtocol { return true } - func evaluateThresholds(usageData: UsageData, settings: AppSettings) async { + private(set) var lastEvaluatedAccountLabel: String? + private(set) var sentThresholdAccountLabel: String? + + func evaluateThresholds(accountLabel: String, usageData: UsageData, settings: AppSettings) async { lastEvaluatedUsageData = usageData + lastEvaluatedAccountLabel = accountLabel } func sendThresholdNotification( + accountLabel: String?, percentage: Double, threshold: UsageThresholdType, resetTime: Date ) async throws { + sentThresholdAccountLabel = accountLabel sentThresholdPercentage = percentage sentThresholdType = threshold } - func sendResetNotification() async throws {} + func sendResetNotification(accountLabel: String?) async throws {} func checkNotificationPermissions() async -> Bool { hasPermission From 056e423ba5380791f215f10dd15edf1967b465ca Mon Sep 17 00:00:00 2001 From: Jean Date: Thu, 14 May 2026 08:40:34 +0200 Subject: [PATCH 3/3] fix(notif): per-account state to stop reset-notif spam in multi-account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `NotificationState` kept `hasWarningBeenNotified`, `hasCriticalBeenNotified` and `lastPercentage` as account-global flags. In multi-account mode every refresh would overwrite the global with whichever account was checked last — so when account A sat at 50% and account B at 0%, B repeatedly satisfied the "reset detected" check (`lastPercentage > 0 && current == 0`) because A had just bumped lastPercentage back to 50. Result: a "Session Reset" notification fired on every refresh interval for any account sitting at 0% while another account had non-zero usage. Fix: store all three trackers per-account UUID: - `hasWarningBeenNotified: [UUID: Bool]` - `hasCriticalBeenNotified: [UUID: Bool]` - `lastSessionPercentageByAccount: [UUID: Double]` `evaluateThresholds` now takes an `accountId` parameter and indexes into the dictionaries. Re-arming the warning/critical "already notified" flags when utilization drops below the threshold is also scoped per-account. Legacy single-account keys (`warning_notified`, `critical_notified`, `last_percentage`) cannot be safely migrated — they have no account attribution — so the per-account dicts start empty on upgrade. Worst case: one extra reset notification after the upgrade if a user was sitting at non-zero utilization at the moment of upgrade (the very first refresh will write the percentage; the second refresh will see the value drop or hold). Worth the cleanup. Also clarifies the reset notification's title and body to specify the 5-hour session window (vs the weekly window, which doesn't fire reset notifications today). New test cases cover: - reset fires exactly once per real reset (subsequent refreshes at 0% don't re-fire) - a second account sitting at 0% never receives a spurious reset notif even while another account is bouncing around non-zero values --- .../xcschemes/ClaudeMeter.xcscheme | 8 +- ClaudeMeter/App/AppModel.swift | 1 + ClaudeMeter/Models/NotificationState.swift | 81 +++++++++++-------- .../Services/NotificationService.swift | 20 +++-- .../NotificationServiceProtocol.swift | 7 +- .../NotificationServiceTests.swift | 78 +++++++++++++++--- .../SettingsRepositoryTests.swift | 7 +- .../TestDoubles/NotificationServiceSpy.swift | 9 ++- 8 files changed, 150 insertions(+), 61 deletions(-) diff --git a/ClaudeMeter.xcodeproj/xcshareddata/xcschemes/ClaudeMeter.xcscheme b/ClaudeMeter.xcodeproj/xcshareddata/xcschemes/ClaudeMeter.xcscheme index b90c50c..86cee12 100644 --- a/ClaudeMeter.xcodeproj/xcshareddata/xcschemes/ClaudeMeter.xcscheme +++ b/ClaudeMeter.xcodeproj/xcshareddata/xcschemes/ClaudeMeter.xcscheme @@ -15,7 +15,7 @@ buildForAnalyzing = "YES"> @@ -35,7 +35,7 @@ parallelizable = "YES"> @@ -57,7 +57,7 @@ runnableDebuggingMode = "0"> @@ -108,7 +108,7 @@ runnableDebuggingMode = "0"> diff --git a/ClaudeMeter/App/AppModel.swift b/ClaudeMeter/App/AppModel.swift index d614907..342181a 100644 --- a/ClaudeMeter/App/AppModel.swift +++ b/ClaudeMeter/App/AppModel.swift @@ -142,6 +142,7 @@ final class AppModel { self.accountStates[accountId] = done await notificationService.evaluateThresholds( + accountId: account.id, accountLabel: account.label, usageData: data, settings: settings diff --git a/ClaudeMeter/Models/NotificationState.swift b/ClaudeMeter/Models/NotificationState.swift index 8a89700..9db9a08 100644 --- a/ClaudeMeter/Models/NotificationState.swift +++ b/ClaudeMeter/Models/NotificationState.swift @@ -7,52 +7,69 @@ import Foundation -/// Tracks which notification thresholds have been triggered +/// Tracks notification state per Claude account. Earlier versions kept these as global flags, +/// which produced spurious notifications in a multi-account setup: account A bumping +/// `lastPercentage` up to 50 would then make account B (still at 0) repeatedly satisfy the +/// "reset detected" check (`lastPercentage > 0 && current == 0`) on every refresh cycle. struct NotificationState: Codable, Equatable, Sendable { - var hasWarningBeenNotified: Bool = false - var hasCriticalBeenNotified: Bool = false + /// Has the warning threshold notification been fired since utilization last dropped below + /// the threshold, keyed by account id. + var hasWarningBeenNotified: [UUID: Bool] = [:] - /// Last known usage percentage to detect reset - var lastPercentage: Double = 0 + /// Has the critical threshold notification been fired since utilization last dropped below + /// the threshold, keyed by account id. + var hasCriticalBeenNotified: [UUID: Bool] = [:] + + /// Last observed session utilization (0-100) per account — used to detect the 5-hour + /// session reset (transition from > 0 to == 0). + var lastSessionPercentageByAccount: [UUID: Double] = [:] enum CodingKeys: String, CodingKey { - case hasWarningBeenNotified = "warning_notified" - case hasCriticalBeenNotified = "critical_notified" - case lastPercentage = "last_percentage" + case hasWarningBeenNotified = "warning_notified_by_account" + case hasCriticalBeenNotified = "critical_notified_by_account" + case lastSessionPercentageByAccount = "last_session_percentage_by_account" } -} -extension NotificationState { - /// Reset tracking when usage drops below thresholds - mutating func resetIfNeeded(currentPercentage: Double, warningThreshold: Double, criticalThreshold: Double) { - if currentPercentage < warningThreshold { - hasWarningBeenNotified = false - } - if currentPercentage < criticalThreshold { - hasCriticalBeenNotified = false - } - lastPercentage = currentPercentage + init( + hasWarningBeenNotified: [UUID: Bool] = [:], + hasCriticalBeenNotified: [UUID: Bool] = [:], + lastSessionPercentageByAccount: [UUID: Double] = [:] + ) { + self.hasWarningBeenNotified = hasWarningBeenNotified + self.hasCriticalBeenNotified = hasCriticalBeenNotified + self.lastSessionPercentageByAccount = lastSessionPercentageByAccount } - /// Check if threshold should trigger notification + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + hasWarningBeenNotified = try container.decodeIfPresent([UUID: Bool].self, forKey: .hasWarningBeenNotified) ?? [:] + hasCriticalBeenNotified = try container.decodeIfPresent([UUID: Bool].self, forKey: .hasCriticalBeenNotified) ?? [:] + lastSessionPercentageByAccount = try container.decodeIfPresent([UUID: Double].self, forKey: .lastSessionPercentageByAccount) ?? [:] + // Legacy single-account fields (warning_notified, critical_notified, last_percentage) + // are intentionally dropped on upgrade — they were the source of the cross-account + // false-positive reset spam this struct was rewritten to fix. There's no safe migration + // path (we can't attribute a global percentage to a specific account), so the + // per-account dictionaries simply start empty after upgrade. + } +} + +extension NotificationState { + /// Check if a warning- or critical-threshold notification should fire for this account. func shouldNotify( + accountId: UUID, currentPercentage: Double, threshold: Double, isWarning: Bool ) -> Bool { - // Check if crossing warning threshold - if isWarning && !hasWarningBeenNotified && currentPercentage >= threshold { - return true - } - // Check if crossing critical threshold - if !isWarning && !hasCriticalBeenNotified && currentPercentage >= threshold { - return true - } - return false + let alreadyNotified = isWarning + ? (hasWarningBeenNotified[accountId] ?? false) + : (hasCriticalBeenNotified[accountId] ?? false) + return !alreadyNotified && currentPercentage >= threshold } - /// Check if session reset should trigger notification - func shouldNotifyReset(currentPercentage: Double) -> Bool { - lastPercentage > 0 && currentPercentage == 0 + /// Detect the 5-hour session reset for this account (utilization went from > 0 to 0). + func shouldNotifyReset(accountId: UUID, currentPercentage: Double) -> Bool { + let last = lastSessionPercentageByAccount[accountId] ?? 0 + return last > 0 && currentPercentage == 0 } } diff --git a/ClaudeMeter/Services/NotificationService.swift b/ClaudeMeter/Services/NotificationService.swift index 6d438fb..6eccbdd 100644 --- a/ClaudeMeter/Services/NotificationService.swift +++ b/ClaudeMeter/Services/NotificationService.swift @@ -34,8 +34,9 @@ final class NotificationService: NSObject, NotificationServiceProtocol, UNUserNo return granted } - /// Evaluate usage thresholds and send notifications + /// Evaluate usage thresholds and send notifications for a specific account. func evaluateThresholds( + accountId: UUID, accountLabel: String, usageData: UsageData, settings: AppSettings @@ -49,18 +50,20 @@ final class NotificationService: NSObject, NotificationServiceProtocol, UNUserNo let isNotificationEnabled = settings.hasNotificationsEnabled && hasPermission let shouldNotifyWarning = state.shouldNotify( + accountId: accountId, currentPercentage: percentage, threshold: thresholds.warningThreshold, isWarning: true ) let shouldNotifyCritical = state.shouldNotify( + accountId: accountId, currentPercentage: percentage, threshold: thresholds.criticalThreshold, isWarning: false ) let shouldNotifyReset = isNotificationEnabled && thresholds.isNotifiedOnReset - && state.shouldNotifyReset(currentPercentage: percentage) + && state.shouldNotifyReset(accountId: accountId, currentPercentage: percentage) if isNotificationEnabled && shouldNotifyWarning { try? await sendThresholdNotification( @@ -69,7 +72,7 @@ final class NotificationService: NSObject, NotificationServiceProtocol, UNUserNo threshold: .warning, resetTime: resetTime ) - state.hasWarningBeenNotified = true + state.hasWarningBeenNotified[accountId] = true } if isNotificationEnabled && shouldNotifyCritical { @@ -79,21 +82,24 @@ final class NotificationService: NSObject, NotificationServiceProtocol, UNUserNo threshold: .critical, resetTime: resetTime ) - state.hasCriticalBeenNotified = true + state.hasCriticalBeenNotified[accountId] = true } if shouldNotifyReset { try? await sendResetNotification(accountLabel: accountLabel) } + // Re-arm the per-account "notified" flags when utilization drops below the threshold, + // so the *next* crossing fires again. Without this the dedupe would be permanent + // across the whole window. if percentage < thresholds.warningThreshold { - state.hasWarningBeenNotified = false + state.hasWarningBeenNotified[accountId] = false } if percentage < thresholds.criticalThreshold { - state.hasCriticalBeenNotified = false + state.hasCriticalBeenNotified[accountId] = false } - state.lastPercentage = percentage + state.lastSessionPercentageByAccount[accountId] = percentage try? await settingsRepository.saveNotificationState(state) } diff --git a/ClaudeMeter/Services/Protocols/NotificationServiceProtocol.swift b/ClaudeMeter/Services/Protocols/NotificationServiceProtocol.swift index a2875a0..b6dd137 100644 --- a/ClaudeMeter/Services/Protocols/NotificationServiceProtocol.swift +++ b/ClaudeMeter/Services/Protocols/NotificationServiceProtocol.swift @@ -18,7 +18,7 @@ enum UsageThresholdType: String { switch self { case .warning: return "Usage Warning" case .critical: return "Critical Usage" - case .reset: return "Session Reset" + case .reset: return "5-Hour Session Reset" } } @@ -33,7 +33,7 @@ enum UsageThresholdType: String { case .critical: return "Critical: \(Int(percentage))% of session used. Resets \(resetString)" case .reset: - return "Your usage limits have been reset. Fresh capacity available!" + return "Your 5-hour session window has reset. Fresh capacity available!" } } } @@ -47,8 +47,9 @@ protocol NotificationServiceProtocol { /// Request notification authorization from the user func requestAuthorization() async throws -> Bool - /// Evaluate thresholds and send notifications for new usage data + /// Evaluate thresholds and send notifications for new usage data, scoped to one account. func evaluateThresholds( + accountId: UUID, accountLabel: String, usageData: UsageData, settings: AppSettings diff --git a/ClaudeMeterTests/NotificationServiceTests.swift b/ClaudeMeterTests/NotificationServiceTests.swift index 7d00b35..7993f50 100644 --- a/ClaudeMeterTests/NotificationServiceTests.swift +++ b/ClaudeMeterTests/NotificationServiceTests.swift @@ -10,6 +10,9 @@ import XCTest @MainActor final class NotificationServiceTests: XCTestCase { + private let accountId = UUID() + private let otherAccountId = UUID() + func test_userReceivesWarningNotificationWhenUsageCrossesThreshold() async { let settingsRepository = SettingsRepositoryFake() let notificationCenter = NotificationCenterSpy() @@ -25,7 +28,7 @@ final class NotificationServiceTests: XCTestCase { let usageData = makeUsageData(percentage: 80) - await service.evaluateThresholds(accountLabel: "TestAccount", usageData: usageData, settings: settings) + await service.evaluateThresholds(accountId: accountId, accountLabel: "TestAccount", usageData: usageData, settings: settings) XCTAssertEqual(notificationCenter.addedRequests.count, 1) XCTAssertEqual(notificationCenter.addedRequests.first?.content.userInfo["threshold"] as? String, "warning") @@ -45,7 +48,7 @@ final class NotificationServiceTests: XCTestCase { let usageData = makeUsageData(percentage: 80) - await service.evaluateThresholds(accountLabel: "TestAccount", usageData: usageData, settings: settings) + await service.evaluateThresholds(accountId: accountId, accountLabel: "TestAccount", usageData: usageData, settings: settings) XCTAssertTrue(notificationCenter.addedRequests.isEmpty) } @@ -66,7 +69,7 @@ final class NotificationServiceTests: XCTestCase { let usageData = makeUsageData(percentage: 80) - await service.evaluateThresholds(accountLabel: "TestAccount", usageData: usageData, settings: settings) + await service.evaluateThresholds(accountId: accountId, accountLabel: "TestAccount", usageData: usageData, settings: settings) XCTAssertTrue(notificationCenter.addedRequests.isEmpty) } @@ -86,8 +89,8 @@ final class NotificationServiceTests: XCTestCase { let usageData = makeUsageData(percentage: 80) - await service.evaluateThresholds(accountLabel: "TestAccount", usageData: usageData, settings: settings) - await service.evaluateThresholds(accountLabel: "TestAccount", usageData: usageData, settings: settings) + await service.evaluateThresholds(accountId: accountId, accountLabel: "TestAccount", usageData: usageData, settings: settings) + await service.evaluateThresholds(accountId: accountId, accountLabel: "TestAccount", usageData: usageData, settings: settings) XCTAssertEqual(notificationCenter.addedRequests.count, 1) } @@ -107,7 +110,7 @@ final class NotificationServiceTests: XCTestCase { let usageData = makeUsageData(percentage: 95) - await service.evaluateThresholds(accountLabel: "TestAccount", usageData: usageData, settings: settings) + await service.evaluateThresholds(accountId: accountId, accountLabel: "TestAccount", usageData: usageData, settings: settings) let sentCritical = notificationCenter.addedRequests.contains { request in request.content.userInfo["threshold"] as? String == "critical" @@ -128,9 +131,9 @@ final class NotificationServiceTests: XCTestCase { settings.notificationThresholds.warningThreshold = 75 settings.notificationThresholds.criticalThreshold = 90 - await service.evaluateThresholds(accountLabel: "TestAccount", usageData: makeUsageData(percentage: 80), settings: settings) - await service.evaluateThresholds(accountLabel: "TestAccount", usageData: makeUsageData(percentage: 50), settings: settings) - await service.evaluateThresholds(accountLabel: "TestAccount", usageData: makeUsageData(percentage: 80), settings: settings) + await service.evaluateThresholds(accountId: accountId, accountLabel: "TestAccount", usageData: makeUsageData(percentage: 80), settings: settings) + await service.evaluateThresholds(accountId: accountId, accountLabel: "TestAccount", usageData: makeUsageData(percentage: 50), settings: settings) + await service.evaluateThresholds(accountId: accountId, accountLabel: "TestAccount", usageData: makeUsageData(percentage: 80), settings: settings) XCTAssertEqual(notificationCenter.addedRequests.count, 2) } @@ -148,14 +151,67 @@ final class NotificationServiceTests: XCTestCase { settings.notificationThresholds.isNotifiedOnReset = true var state = NotificationState() - state.lastPercentage = 50 + state.lastSessionPercentageByAccount[accountId] = 50 try? await settingsRepository.saveNotificationState(state) - await service.evaluateThresholds(accountLabel: "TestAccount", usageData: makeUsageData(percentage: 0), settings: settings) + await service.evaluateThresholds(accountId: accountId, accountLabel: "TestAccount", usageData: makeUsageData(percentage: 0), settings: settings) XCTAssertEqual(notificationCenter.addedRequests.count, 1) XCTAssertEqual(notificationCenter.addedRequests.first?.content.categoryIdentifier, "usage.reset") } + + func test_resetNotificationFiresExactlyOncePerReset() async { + // Repro for the multi-account reset spam bug: with per-account state, repeated + // refreshes while utilization stays at 0 must NOT keep firing the reset notification. + let settingsRepository = SettingsRepositoryFake() + let notificationCenter = NotificationCenterSpy() + let service = NotificationService( + settingsRepository: settingsRepository, + notificationCenter: notificationCenter + ) + + var settings = AppSettings.default + settings.hasNotificationsEnabled = true + settings.notificationThresholds.isNotifiedOnReset = true + + var state = NotificationState() + state.lastSessionPercentageByAccount[accountId] = 50 + try? await settingsRepository.saveNotificationState(state) + + // First refresh: usage drops to 0 → reset fires. + await service.evaluateThresholds(accountId: accountId, accountLabel: "TestAccount", usageData: makeUsageData(percentage: 0), settings: settings) + // Subsequent refreshes while usage stays at 0 → no more reset notifications. + await service.evaluateThresholds(accountId: accountId, accountLabel: "TestAccount", usageData: makeUsageData(percentage: 0), settings: settings) + await service.evaluateThresholds(accountId: accountId, accountLabel: "TestAccount", usageData: makeUsageData(percentage: 0), settings: settings) + + XCTAssertEqual(notificationCenter.addedRequests.count, 1) + } + + func test_oneAccountActive_doesNotTriggerResetSpamOnAnotherAccountAtZero() async { + // Direct repro for the multi-account false-positive: with the old global lastPercentage, + // every refresh of account B (which sits at 0) would re-detect a "reset" because + // account A had just bumped lastPercentage back to a non-zero value. + let settingsRepository = SettingsRepositoryFake() + let notificationCenter = NotificationCenterSpy() + let service = NotificationService( + settingsRepository: settingsRepository, + notificationCenter: notificationCenter + ) + + var settings = AppSettings.default + settings.hasNotificationsEnabled = true + settings.notificationThresholds.isNotifiedOnReset = true + + // Three rounds of: A at 50%, B at 0%. No reset notification should ever fire for B + // (it never transitioned from > 0 to 0 — it's been at 0 the entire time). + for _ in 0..<3 { + await service.evaluateThresholds(accountId: accountId, accountLabel: "A", usageData: makeUsageData(percentage: 50), settings: settings) + await service.evaluateThresholds(accountId: otherAccountId, accountLabel: "B", usageData: makeUsageData(percentage: 0), settings: settings) + } + + let resetNotifs = notificationCenter.addedRequests.filter { $0.content.categoryIdentifier == "usage.reset" } + XCTAssertEqual(resetNotifs.count, 0, "Account B was never above 0; no reset notif should fire for it") + } } // MARK: - Helpers diff --git a/ClaudeMeterTests/SettingsRepositoryTests.swift b/ClaudeMeterTests/SettingsRepositoryTests.swift index 7905d11..6bb785d 100644 --- a/ClaudeMeterTests/SettingsRepositoryTests.swift +++ b/ClaudeMeterTests/SettingsRepositoryTests.swift @@ -67,10 +67,11 @@ final class SettingsRepositoryTests: XCTestCase { defer { userDefaults?.removePersistentDomain(forName: suiteName) } let repository = SettingsRepository(userDefaults: userDefaults ?? .standard) + let accountId = UUID() var state = NotificationState() - state.hasWarningBeenNotified = true - state.hasCriticalBeenNotified = true - state.lastPercentage = 85 + state.hasWarningBeenNotified[accountId] = true + state.hasCriticalBeenNotified[accountId] = true + state.lastSessionPercentageByAccount[accountId] = 85 try await repository.saveNotificationState(state) let loaded = await repository.loadNotificationState() diff --git a/ClaudeMeterTests/TestDoubles/NotificationServiceSpy.swift b/ClaudeMeterTests/TestDoubles/NotificationServiceSpy.swift index 02f3a5c..d9c7659 100644 --- a/ClaudeMeterTests/TestDoubles/NotificationServiceSpy.swift +++ b/ClaudeMeterTests/TestDoubles/NotificationServiceSpy.swift @@ -24,11 +24,18 @@ final class NotificationServiceSpy: NotificationServiceProtocol { } private(set) var lastEvaluatedAccountLabel: String? + private(set) var lastEvaluatedAccountId: UUID? private(set) var sentThresholdAccountLabel: String? - func evaluateThresholds(accountLabel: String, usageData: UsageData, settings: AppSettings) async { + func evaluateThresholds( + accountId: UUID, + accountLabel: String, + usageData: UsageData, + settings: AppSettings + ) async { lastEvaluatedUsageData = usageData lastEvaluatedAccountLabel = accountLabel + lastEvaluatedAccountId = accountId } func sendThresholdNotification(