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 7fcece2..342181a 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,106 @@ 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( + accountId: account.id, + accountLabel: account.label, 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 +192,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 @@ -184,6 +267,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) @@ -192,10 +276,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 +313,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 +330,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 +346,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/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/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/NotificationService.swift b/ClaudeMeter/Services/NotificationService.swift index ecd91fc..6eccbdd 100644 --- a/ClaudeMeter/Services/NotificationService.swift +++ b/ClaudeMeter/Services/NotificationService.swift @@ -34,8 +34,10 @@ 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 ) async { @@ -48,54 +50,62 @@ 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( + accountLabel: accountLabel, percentage: percentage, threshold: .warning, resetTime: resetTime ) - state.hasWarningBeenNotified = true + state.hasWarningBeenNotified[accountId] = true } if isNotificationEnabled && shouldNotifyCritical { try? await sendThresholdNotification( + accountLabel: accountLabel, percentage: percentage, threshold: .critical, resetTime: resetTime ) - state.hasCriticalBeenNotified = true + state.hasCriticalBeenNotified[accountId] = true } if shouldNotifyReset { - try? await sendResetNotification() + 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) } /// Send threshold notification func sendThresholdNotification( + accountLabel: String?, percentage: Double, threshold: UsageThresholdType, resetTime: Date @@ -104,7 +114,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 +130,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 +155,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..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,21 +47,26 @@ 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 ) 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/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/NotificationServiceTests.swift b/ClaudeMeterTests/NotificationServiceTests.swift index e6ef1d0..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(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(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(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(usageData: usageData, settings: settings) - await service.evaluateThresholds(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(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(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(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(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 7b7964d..6bb785d 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,16 +33,45 @@ 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) 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/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/NotificationServiceSpy.swift b/ClaudeMeterTests/TestDoubles/NotificationServiceSpy.swift index 400b2a9..d9c7659 100644 --- a/ClaudeMeterTests/TestDoubles/NotificationServiceSpy.swift +++ b/ClaudeMeterTests/TestDoubles/NotificationServiceSpy.swift @@ -23,20 +23,33 @@ final class NotificationServiceSpy: NotificationServiceProtocol { return true } - func evaluateThresholds(usageData: UsageData, settings: AppSettings) async { + private(set) var lastEvaluatedAccountLabel: String? + private(set) var lastEvaluatedAccountId: UUID? + private(set) var sentThresholdAccountLabel: String? + + func evaluateThresholds( + accountId: UUID, + accountLabel: String, + usageData: UsageData, + settings: AppSettings + ) async { lastEvaluatedUsageData = usageData + lastEvaluatedAccountLabel = accountLabel + lastEvaluatedAccountId = accountId } 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 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 5de1399..ab70967 100644 Binary files a/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsBatteryStyleWhenWarning.1.png and b/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsBatteryStyleWhenWarning.1.png differ diff --git a/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsCircularStyleWhenWarning.1.png b/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsCircularStyleWhenWarning.1.png index 412483b..d54ce83 100644 Binary files a/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsCircularStyleWhenWarning.1.png and b/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsCircularStyleWhenWarning.1.png differ diff --git a/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsDualBarStyleWhenWarning.1.png b/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsDualBarStyleWhenWarning.1.png index e7abb9f..a2d706e 100644 Binary files a/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsDualBarStyleWhenWarning.1.png and b/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsDualBarStyleWhenWarning.1.png differ diff --git a/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsGaugeStyleWhenWarning.1.png b/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsGaugeStyleWhenWarning.1.png index d7b0a0f..efde8e1 100644 Binary files a/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsGaugeStyleWhenWarning.1.png and b/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsGaugeStyleWhenWarning.1.png differ diff --git a/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsLoadingIndicatorInBatteryStyle.1.png b/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsLoadingIndicatorInBatteryStyle.1.png index 22b1a39..8ccd747 100644 Binary files a/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsLoadingIndicatorInBatteryStyle.1.png and b/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsLoadingIndicatorInBatteryStyle.1.png differ diff --git a/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsMinimalStyleWhenWarning.1.png b/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsMinimalStyleWhenWarning.1.png index 1d89340..09f093c 100644 Binary files a/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsMinimalStyleWhenWarning.1.png and b/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsMinimalStyleWhenWarning.1.png differ diff --git a/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsSegmentsStyleWhenWarning.1.png b/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsSegmentsStyleWhenWarning.1.png index 4bc8727..45b3a2f 100644 Binary files a/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsSegmentsStyleWhenWarning.1.png and b/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsSegmentsStyleWhenWarning.1.png differ diff --git a/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsStaleIndicatorInBatteryStyle.1.png b/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsStaleIndicatorInBatteryStyle.1.png index 310e57d..961370f 100644 Binary files a/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsStaleIndicatorInBatteryStyle.1.png and b/ClaudeMeterTests/__Snapshots__/MenuBarIconSnapshotTests/test_menuBarIcon_showsStaleIndicatorInBatteryStyle.1.png differ