diff --git a/Sources/CodexBar/AppNotifications.swift b/Sources/CodexBar/AppNotifications.swift index 6bd3bc55a..ae52abee4 100644 --- a/Sources/CodexBar/AppNotifications.swift +++ b/Sources/CodexBar/AppNotifications.swift @@ -1,54 +1,161 @@ +import AppKit import CodexBarCore import Foundation @preconcurrency import UserNotifications @MainActor final class AppNotifications { + struct ShortcutRunResult: Sendable { + let succeeded: Bool + let output: String + } + static let shared = AppNotifications() - private let centerProvider: @Sendable () -> UNUserNotificationCenter + private let authorizationStatusProvider: @Sendable () async -> UNAuthorizationStatus? + private let authorizationRequester: @Sendable () async -> Bool + private let requestPoster: @Sendable (UNNotificationRequest) async throws -> Void + private let hookCaller: @Sendable (URL) async throws -> Void + private let urlOpener: @MainActor @Sendable (URL) -> Bool + private let shortcutAvailabilityChecker: @Sendable () -> Bool + private let shortcutRunner: @Sendable (String, String?) async -> ShortcutRunResult + private let soundPlayer: @MainActor @Sendable (NotificationSoundOption, Double) -> Bool + private let allowsPostingWhenRunningUnderTests: Bool private let logger = CodexBarLog.logger(LogCategories.notifications) private var authorizationTask: Task? + private nonisolated static var shortcutRunTimeout: TimeInterval { + 60 + } - init(centerProvider: @escaping @Sendable () -> UNUserNotificationCenter = { UNUserNotificationCenter.current() }) { - self.centerProvider = centerProvider + init( + authorizationStatusProvider: @escaping @Sendable () async -> UNAuthorizationStatus? = { + let center = UNUserNotificationCenter.current() + return await withCheckedContinuation { continuation in + center.getNotificationSettings { settings in + continuation.resume(returning: settings.authorizationStatus) + } + } + }, + authorizationRequester: @escaping @Sendable () async -> Bool = { + let center = UNUserNotificationCenter.current() + return await withCheckedContinuation { continuation in + center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in + continuation.resume(returning: granted) + } + } + }, + requestPoster: @escaping @Sendable (UNNotificationRequest) async throws -> Void = { request in + try await UNUserNotificationCenter.current().add(request) + }, + hookCaller: @escaping @Sendable (URL) async throws -> Void = { url in + var request = URLRequest(url: url) + request.httpMethod = "GET" + let (_, response) = try await URLSession.shared.data(for: request, delegate: nil) + if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + struct HookCallError: Error, LocalizedError { + let statusCode: Int + + var errorDescription: String? { + "HTTP \(self.statusCode)" + } + } + throw HookCallError(statusCode: http.statusCode) + } + }, + urlOpener: @escaping @MainActor @Sendable (URL) -> Bool = { url in + NSWorkspace.shared.open(url) + }, + shortcutAvailabilityChecker: @escaping @Sendable () -> Bool = { + FileManager.default.isExecutableFile(atPath: "/usr/bin/shortcuts") + }, + shortcutRunner: @escaping @Sendable (String, String?) async -> ShortcutRunResult = { name, provider in + await AppNotifications.runShortcutCommand(name: name, provider: provider) + }, + soundPlayer: @escaping @MainActor @Sendable (NotificationSoundOption, Double) -> Bool = { sound, volume in + NotificationSoundPlayer.play(sound, volume: volume) + }, + allowsPostingWhenRunningUnderTests: Bool = false) + { + self.authorizationStatusProvider = authorizationStatusProvider + self.authorizationRequester = authorizationRequester + self.requestPoster = requestPoster + self.hookCaller = hookCaller + self.urlOpener = urlOpener + self.shortcutAvailabilityChecker = shortcutAvailabilityChecker + self.shortcutRunner = shortcutRunner + self.soundPlayer = soundPlayer + self.allowsPostingWhenRunningUnderTests = allowsPostingWhenRunningUnderTests } - func requestAuthorizationOnStartup() { - guard !Self.isRunningUnderTests else { return } + func requestAuthorizationOnStartup(notificationsEnabled: Bool = true) { + guard notificationsEnabled, self.canPostInCurrentEnvironment else { return } _ = self.ensureAuthorizationTask() } - func post(idPrefix: String, title: String, body: String, badge: NSNumber? = nil) { - guard !Self.isRunningUnderTests else { return } - let center = self.centerProvider() - let logger = self.logger + @discardableResult + func post( + idPrefix: String, + title: String, + body: String, + badge: NSNumber? = nil, + event: AppNotificationEvent? = nil, + provider: String? = nil, + notificationsEnabled: Bool = true, + notificationVolume: Double = 1.0, + settings: NotificationDeliverySettings? = nil) -> Task? + { + guard self.canPostInCurrentEnvironment else { return nil } - Task { @MainActor in - let granted = await self.ensureAuthorized() - guard granted else { - logger.debug("not authorized; skipping post", metadata: ["prefix": idPrefix]) + return Task { @MainActor in + let deliverySettings = (settings ?? .localDefault).normalized + guard notificationsEnabled, deliverySettings.enabled else { + self.logger.debug( + "disabled; skipping notification", + metadata: self.metadata(event: event, idPrefix: idPrefix, provider: provider)) return } - let content = UNMutableNotificationContent() - content.title = title - content.body = body - content.sound = .default - content.badge = badge + let granted = await self.ensureAuthorized() + if granted { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = deliverySettings.sound == .systemDefault ? .default : nil + content.badge = badge - let request = UNNotificationRequest( - identifier: "codexbar-\(idPrefix)-\(UUID().uuidString)", - content: content, - trigger: nil) + let request = UNNotificationRequest( + identifier: "codexbar-\(idPrefix)-\(UUID().uuidString)", + content: content, + trigger: nil) - logger.info("posting", metadata: ["prefix": idPrefix]) - do { - try await center.add(request) - } catch { - let errorText = String(describing: error) - logger.error("failed to post", metadata: ["prefix": idPrefix, "error": errorText]) + self.logger.info( + "posting", + metadata: self.metadata(event: event, idPrefix: idPrefix, provider: provider)) + do { + try await self.requestPoster(request) + self.playSoundIfNeeded( + event: event, + idPrefix: idPrefix, + provider: provider, + settings: deliverySettings, + notificationVolume: notificationVolume) + } catch { + var metadata = self.metadata(event: event, idPrefix: idPrefix, provider: provider) + metadata["error"] = "\(error)" + self.logger.error("failed to post", metadata: metadata) + } + } else { + self.logger.debug( + "not authorized; skipping notification", + metadata: self.metadata(event: event, idPrefix: idPrefix, provider: provider)) } + + await self.deliverExternalActions( + event: event, + idPrefix: idPrefix, + provider: provider, + notificationsEnabled: notificationsEnabled, + settings: deliverySettings) } } @@ -68,7 +175,7 @@ final class AppNotifications { } private func requestAuthorization() async -> Bool { - if let existing = await self.notificationAuthorizationStatus() { + if let existing = await self.authorizationStatusProvider() { if existing == .authorized || existing == .provisional { return true } @@ -77,21 +184,118 @@ final class AppNotifications { } } - let center = self.centerProvider() - return await withCheckedContinuation { continuation in - center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in - continuation.resume(returning: granted) - } + return await self.authorizationRequester() + } + + private var canPostInCurrentEnvironment: Bool { + self.allowsPostingWhenRunningUnderTests || !Self.isRunningUnderTests + } + + private func deliverExternalActions( + event: AppNotificationEvent?, + idPrefix: String, + provider: String?, + notificationsEnabled: Bool, + settings: NotificationDeliverySettings) async + { + let normalized = settings.normalized + guard notificationsEnabled, normalized.enabled else { return } + + if !normalized.hookCallURL.isEmpty { + await self.deliverHookCall( + event: event, + idPrefix: idPrefix, + provider: provider, + hookCallURL: normalized.hookCallURL) + } + if !normalized.shortcutName.isEmpty { + await self.runShortcut(event: event, idPrefix: idPrefix, provider: provider, name: normalized.shortcutName) } } - private func notificationAuthorizationStatus() async -> UNAuthorizationStatus? { - let center = self.centerProvider() - return await withCheckedContinuation { continuation in - center.getNotificationSettings { settings in - continuation.resume(returning: settings.authorizationStatus) + private func deliverHookCall( + event: AppNotificationEvent?, + idPrefix: String, + provider: String?, + hookCallURL: String) async + { + let renderedURL = Self.renderProviderPlaceholder(in: hookCallURL, provider: provider) + guard let url = URL(string: renderedURL) else { + var metadata = self.metadata(event: event, idPrefix: idPrefix, provider: provider) + metadata["url"] = renderedURL + self.logger.error("invalid hook url", metadata: metadata) + return + } + + let scheme = url.scheme?.lowercased() ?? "" + if scheme == "http" || scheme == "https" { + do { + try await self.hookCaller(url) + var metadata = self.metadata(event: event, idPrefix: idPrefix, provider: provider) + metadata["url"] = renderedURL + self.logger.info("hook delivered", metadata: metadata) + } catch { + var metadata = self.metadata(event: event, idPrefix: idPrefix, provider: provider) + metadata["url"] = renderedURL + metadata["error"] = "\(error)" + self.logger.error("hook delivery failed", metadata: metadata) } + return + } + + _ = self.urlOpener(url) + var metadata = self.metadata(event: event, idPrefix: idPrefix, provider: provider) + metadata["url"] = renderedURL + self.logger.info("custom scheme hook opened", metadata: metadata) + } + + private func runShortcut(event: AppNotificationEvent?, idPrefix: String, provider: String?, name: String) async { + guard self.shortcutAvailabilityChecker() else { + var metadata = self.metadata(event: event, idPrefix: idPrefix, provider: provider) + metadata["shortcut"] = name + self.logger.debug("shortcuts command unavailable; skipping", metadata: metadata) + return } + + let result = await self.shortcutRunner(name, Self.normalizedProvider(provider)) + var metadata = self.metadata(event: event, idPrefix: idPrefix, provider: provider) + metadata["shortcut"] = name + if result.succeeded { + self.logger.info("shortcut ran", metadata: metadata) + } else { + metadata["output"] = result.output + self.logger.debug("shortcut run failed; skipping", metadata: metadata) + } + } + + private func playSoundIfNeeded( + event: AppNotificationEvent?, + idPrefix: String, + provider: String?, + settings: NotificationDeliverySettings, + notificationVolume: Double) + { + guard settings.sound != .none, settings.sound != .systemDefault else { return } + var metadata = self.metadata(event: event, idPrefix: idPrefix, provider: provider) + metadata["sound"] = settings.sound.rawValue + metadata["volume"] = "\(notificationVolume)" + + if self.soundPlayer(settings.sound, notificationVolume) { + self.logger.info("played sound", metadata: metadata) + } else { + self.logger.error("failed to play sound", metadata: metadata) + } + } + + private func metadata(event: AppNotificationEvent?, idPrefix: String, provider: String?) -> [String: String] { + var metadata = [ + "event": event?.rawValue ?? "legacy", + "prefix": idPrefix, + ] + if let provider = Self.normalizedProvider(provider) { + metadata["provider"] = provider + } + return metadata } private static var isRunningUnderTests: Bool { @@ -106,4 +310,66 @@ final class AppNotifications { if env["SWIFT_TESTING"] != nil { return true } return NSClassFromString("XCTestCase") != nil } + + private nonisolated static func renderProviderPlaceholder(in template: String, provider: String?) -> String { + guard let provider = self.normalizedProvider(provider) else { return template } + return template.replacingOccurrences(of: "{provider}", with: self.urlEncodedProvider(provider)) + } + + private nonisolated static func normalizedProvider(_ provider: String?) -> String? { + let trimmed = provider?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let trimmed, !trimmed.isEmpty else { return nil } + return trimmed + } + + private nonisolated static func urlEncodedProvider(_ provider: String) -> String { + var allowed = CharacterSet.alphanumerics + allowed.insert(charactersIn: "-._~") + return provider.addingPercentEncoding(withAllowedCharacters: allowed) ?? provider + } + + private nonisolated static func runShortcutCommand(name: String, provider: String?) async -> ShortcutRunResult { + let inputURL: URL? + let arguments: [String] + if let provider = self.normalizedProvider(provider) { + do { + let payload = ["provider": provider] + let data = try JSONSerialization.data(withJSONObject: payload, options: []) + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-shortcut-\(UUID().uuidString)") + .appendingPathExtension("json") + try data.write(to: url, options: .atomic) + inputURL = url + arguments = ["run", name, "--input-path", url.path] + } catch { + return ShortcutRunResult( + succeeded: false, + output: "Failed to prepare shortcut input: \(error)") + } + } else { + inputURL = nil + arguments = ["run", name] + } + defer { + if let inputURL { + try? FileManager.default.removeItem(at: inputURL) + } + } + + do { + let result = try await SubprocessRunner.run( + binary: "/usr/bin/shortcuts", + arguments: arguments, + environment: ProcessInfo.processInfo.environment, + timeout: self.shortcutRunTimeout, + label: "notification-shortcut") + + let output = (result.stdout + result.stderr) + .trimmingCharacters(in: .whitespacesAndNewlines) + + return ShortcutRunResult(succeeded: true, output: output) + } catch { + return ShortcutRunResult(succeeded: false, output: "\(error)") + } + } } diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index ccaad6969..6942790b9 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -303,7 +303,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func applicationDidFinishLaunching(_ notification: Notification) { - AppNotifications.shared.requestAuthorizationOnStartup() + AppNotifications.shared.requestAuthorizationOnStartup( + notificationsEnabled: self.settings?.notificationsEnabled ?? true) self.ensureStatusController() KeyboardShortcuts.onKeyUp(for: .openMenu) { [weak self] in Task { @MainActor [weak self] in diff --git a/Sources/CodexBar/NotificationSettings.swift b/Sources/CodexBar/NotificationSettings.swift new file mode 100644 index 000000000..6e94c45ec --- /dev/null +++ b/Sources/CodexBar/NotificationSettings.swift @@ -0,0 +1,247 @@ +import AppKit +import Foundation + +struct NotificationDeliverySettings: Equatable, Sendable { + var enabled: Bool + var sound: NotificationSoundOption + var hookCallURL: String + var shortcutName: String + + static let localDefault = NotificationDeliverySettings( + enabled: true, + sound: .systemDefault, + hookCallURL: "", + shortcutName: "") + + var normalized: NotificationDeliverySettings { + NotificationDeliverySettings( + enabled: self.enabled, + sound: self.sound, + hookCallURL: self.hookCallURL.trimmingCharacters(in: .whitespacesAndNewlines), + shortcutName: self.shortcutName.trimmingCharacters(in: .whitespacesAndNewlines)) + } +} + +enum NotificationSoundOption: String, CaseIterable, Identifiable, Sendable { + case none + case systemDefault + case basso + case blow + case bottle + case frog + case funk + case glass + case hero + case morse + case ping + case pop + case purr + case sosumi + case submarine + case tink + + var id: String { + self.rawValue + } + + var label: String { + switch self { + case .none: + "None" + case .systemDefault: + "System Default" + case .basso: + "Basso" + case .blow: + "Blow" + case .bottle: + "Bottle" + case .frog: + "Frog" + case .funk: + "Funk" + case .glass: + "Glass" + case .hero: + "Hero" + case .morse: + "Morse" + case .ping: + "Ping" + case .pop: + "Pop" + case .purr: + "Purr" + case .sosumi: + "Sosumi" + case .submarine: + "Submarine" + case .tink: + "Tink" + } + } + + var systemSoundName: String? { + switch self { + case .none, .systemDefault: + nil + case .basso: + "Basso" + case .blow: + "Blow" + case .bottle: + "Bottle" + case .frog: + "Frog" + case .funk: + "Funk" + case .glass: + "Glass" + case .hero: + "Hero" + case .morse: + "Morse" + case .ping: + "Ping" + case .pop: + "Pop" + case .purr: + "Purr" + case .sosumi: + "Sosumi" + case .submarine: + "Submarine" + case .tink: + "Tink" + } + } +} + +@MainActor +enum NotificationSoundPlayer { + @discardableResult + static func playPreview(_ sound: NotificationSoundOption, volume: Double) -> Bool { + switch sound { + case .none: + return false + case .systemDefault: + NSSound.beep() + return true + default: + return self.play(sound, volume: volume) + } + } + + @discardableResult + static func play(_ sound: NotificationSoundOption, volume: Double = 1.0) -> Bool { + guard let name = sound.systemSoundName else { return false } + guard let sound = NSSound(named: NSSound.Name(name)) else { return false } + sound.stop() + sound.volume = Float(min(max(volume, 0.0), 1.0)) + return sound.play() + } +} + +enum AppNotificationEvent: String, CaseIterable, Identifiable, Sendable { + case sessionQuotaDepleted + case sessionQuotaRestored + case providerLogin + case augmentSessionExpired + + var id: String { + self.rawValue + } + + var settingsTitle: String { + switch self { + case .sessionQuotaDepleted: + "Session quota depleted" + case .sessionQuotaRestored: + "Session quota restored" + case .providerLogin: + "Provider login successful" + case .augmentSessionExpired: + "Augment session expired" + } + } + + var settingsSubtitle: String { + switch self { + case .sessionQuotaDepleted: + "When a tracked provider hits 0% remaining in the current 5-hour session." + case .sessionQuotaRestored: + "When a tracked provider becomes available again after a depleted session." + case .providerLogin: + "After a provider login flow launched from CodexBar completes successfully." + case .augmentSessionExpired: + "When Augment recovery still needs a manual browser login." + } + } + + var hookPlaceholder: String { + switch self { + case .sessionQuotaDepleted: + "https://example.com/hooks/session-depleted" + case .sessionQuotaRestored: + "https://example.com/hooks/session-restored" + case .providerLogin: + "https://example.com/hooks/provider-login" + case .augmentSessionExpired: + "https://example.com/hooks/augment-expired" + } + } + + var recommendedShortcutName: String { + switch self { + case .sessionQuotaDepleted: + "CodexBar Session Quota Depleted" + case .sessionQuotaRestored: + "CodexBar Session Quota Restored" + case .providerLogin: + "CodexBar Provider Login" + case .augmentSessionExpired: + "CodexBar Augment Session Expired" + } + } + + var shortcutPlaceholder: String { + self.recommendedShortcutName + } + + var defaultSound: NotificationSoundOption { + switch self { + case .sessionQuotaDepleted: + .basso + case .sessionQuotaRestored: + .glass + case .providerLogin: + .hero + case .augmentSessionExpired: + .submarine + } + } + + var defaultSettings: NotificationDeliverySettings { + NotificationDeliverySettings( + enabled: true, + sound: self.defaultSound, + hookCallURL: "", + shortcutName: "") + } + + var enabledDefaultsKey: String { + "notification.\(self.rawValue).enabled" + } + + var soundDefaultsKey: String { + "notification.\(self.rawValue).sound" + } + + var hookCallURLDefaultsKey: String { + "notification.\(self.rawValue).hookCallURL" + } + + var shortcutNameDefaultsKey: String { + "notification.\(self.rawValue).shortcutName" + } +} diff --git a/Sources/CodexBar/PreferencesComponents.swift b/Sources/CodexBar/PreferencesComponents.swift index d0fb56a0d..81a2d83e3 100644 --- a/Sources/CodexBar/PreferencesComponents.swift +++ b/Sources/CodexBar/PreferencesComponents.swift @@ -25,6 +25,78 @@ struct PreferenceToggleRow: View { } } +@MainActor +struct NotificationSettingsRow: View { + let title: String + let subtitle: String + let hookPlaceholder: String + let shortcutPlaceholder: String + let globalEnabled: Bool + let onSoundChange: @MainActor (NotificationSoundOption) -> Void + @Binding var isEnabled: Bool + @Binding var sound: NotificationSoundOption + @Binding var hookCallURL: String + @Binding var shortcutName: String + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Toggle(isOn: self.$isEnabled) { + Text(self.title) + .font(.body) + } + .toggleStyle(.checkbox) + + Text(self.subtitle) + .font(.footnote) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Text("Sound") + .font(.footnote) + .foregroundStyle(.secondary) + Picker("Sound", selection: self.$sound) { + ForEach(NotificationSoundOption.allCases) { option in + Text(option.label).tag(option) + } + } + .pickerStyle(.menu) + .labelsHidden() + } + + VStack(alignment: .leading, spacing: 4) { + Text("Hook URL") + .font(.footnote) + .foregroundStyle(.secondary) + TextField(self.hookPlaceholder, text: self.$hookCallURL) + .textFieldStyle(.roundedBorder) + Text("Use {provider} anywhere in the URL to insert the provider name.") + .font(.footnote) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Shortcut") + .font(.footnote) + .foregroundStyle(.secondary) + TextField(self.shortcutPlaceholder, text: self.$shortcutName) + .textFieldStyle(.roundedBorder) + Text("Shortcuts receive JSON input with a provider field, for example {\"provider\":\"Codex\"}.") + .font(.footnote) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + .disabled(!self.globalEnabled || !self.isEnabled) + } + .onChange(of: self.sound) { _, newValue in + self.onSoundChange(newValue) + } + } +} + @MainActor struct SettingsSection: View { let title: String? diff --git a/Sources/CodexBar/PreferencesDebugPane.swift b/Sources/CodexBar/PreferencesDebugPane.swift index a86a55434..923676c91 100644 --- a/Sources/CodexBar/PreferencesDebugPane.swift +++ b/Sources/CodexBar/PreferencesDebugPane.swift @@ -243,7 +243,7 @@ struct DebugPane: View { SettingsSection( title: "Notifications", - caption: "Trigger test notifications for the 5-hour session window (depleted/restored).") + caption: "Trigger test notifications for the configured notification events.") { Picker("Provider", selection: self.$currentLogProvider) { Text("Codex").tag(UsageProvider.codex) @@ -267,6 +267,22 @@ struct DebugPane: View { } .controlSize(.small) } + + HStack(spacing: 12) { + Button { + self.postProviderLoginNotification(provider: self.currentLogProvider) + } label: { + Label("Post login", systemImage: "person.crop.circle.badge.checkmark") + } + .controlSize(.small) + + Button { + self.postAugmentExpiredNotification() + } label: { + Label("Post Augment expired", systemImage: "exclamationmark.triangle") + } + .controlSize(.small) + } } SettingsSection( @@ -490,7 +506,32 @@ struct DebugPane: View { } private func postSessionNotification(_ transition: SessionQuotaTransition, provider: UsageProvider) { - SessionQuotaNotifier().post(transition: transition, provider: provider, badge: 1) + SessionQuotaNotifier(settings: self.settings).post(transition: transition, provider: provider, badge: 1) + } + + private func postProviderLoginNotification(provider: UsageProvider) { + let providerName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName + AppNotifications.shared.post( + idPrefix: "debug-login-\(provider.rawValue)", + title: "\(providerName) login successful", + body: "You can return to the app; authentication finished.", + event: .providerLogin, + provider: providerName, + notificationsEnabled: self.settings.notificationsEnabled, + notificationVolume: self.settings.notificationVolume, + settings: self.settings.notificationSettings(for: .providerLogin)) + } + + private func postAugmentExpiredNotification() { + AppNotifications.shared.post( + idPrefix: "debug-augment-session-expired", + title: "Augment Session Expired", + body: "Please log in to app.augmentcode.com to restore your session.", + event: .augmentSessionExpired, + provider: ProviderDescriptorRegistry.descriptor(for: .augment).metadata.displayName, + notificationsEnabled: self.settings.notificationsEnabled, + notificationVolume: self.settings.notificationVolume, + settings: self.settings.notificationSettings(for: .augmentSessionExpired)) } private func clearCostCache() async { diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 39a95a55f..6fb488e80 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -91,13 +91,7 @@ struct GeneralPane: View { subtitle: "Polls OpenAI/Claude status pages and Google Workspace for " + "Gemini/Antigravity, surfacing incidents in the icon and menu.", binding: self.$settings.statusChecksEnabled) - PreferenceToggleRow( - title: "Session quota notifications", - subtitle: "Notifies when the 5-hour session quota hits 0% and when it becomes " + - "available again.", - binding: self.$settings.sessionQuotaNotificationsEnabled) } - Divider() SettingsSection(contentSpacing: 12) { diff --git a/Sources/CodexBar/PreferencesNotificationsPane.swift b/Sources/CodexBar/PreferencesNotificationsPane.swift new file mode 100644 index 000000000..25a260320 --- /dev/null +++ b/Sources/CodexBar/PreferencesNotificationsPane.swift @@ -0,0 +1,93 @@ +import SwiftUI + +@MainActor +struct NotificationsPane: View { + @Bindable var settings: SettingsStore + + var body: some View { + ScrollView(.vertical, showsIndicators: true) { + VStack(alignment: .leading, spacing: 16) { + SettingsSection( + title: "Notifications", + caption: "CodexBar can show local macOS notifications, call webhook-style URLs, and run Apple " + + "Shortcuts when important events happen.") + { + PreferenceToggleRow( + title: "Enable notifications", + subtitle: "Master switch for all notification events. Per-event settings stay saved.", + binding: self.$settings.notificationsEnabled) + + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Sound volume") + .font(.body) + Text("Global level for custom notification sounds and sound previews.") + .font(.footnote) + .foregroundStyle(.tertiary) + } + Spacer() + Text(self.volumeLabel) + .font(.footnote.monospacedDigit()) + .foregroundStyle(.secondary) + } + + Slider(value: self.$settings.notificationVolume, in: 0...1) + } + + Text("Local banners and sounds still follow the macOS notification permission for CodexBar.") + .font(.footnote) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + + SettingsSection( + title: "Events", + caption: "Each event can use its own sound, webhook URL, and Shortcut name.") + { + ForEach(Array(AppNotificationEvent.allCases.enumerated()), id: \.element.id) { index, event in + NotificationSettingsRow( + title: event.settingsTitle, + subtitle: event.settingsSubtitle, + hookPlaceholder: event.hookPlaceholder, + shortcutPlaceholder: event.shortcutPlaceholder, + globalEnabled: self.settings.notificationsEnabled, + onSoundChange: self.previewSound, + isEnabled: self.binding(for: event, field: \.enabled), + sound: self.binding(for: event, field: \.sound), + hookCallURL: self.binding(for: event, field: \.hookCallURL), + shortcutName: self.binding(for: event, field: \.shortcutName)) + + if index < AppNotificationEvent.allCases.count - 1 { + Divider() + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + } + + private func binding( + for event: AppNotificationEvent, + field: WritableKeyPath) -> Binding + { + Binding( + get: { self.settings.notificationSettings(for: event)[keyPath: field] }, + set: { newValue in + var settings = self.settings.notificationSettings(for: event) + settings[keyPath: field] = newValue + self.settings.setNotificationSettings(settings, for: event) + }) + } + + private func previewSound(_ sound: NotificationSoundOption) { + _ = NotificationSoundPlayer.playPreview(sound, volume: self.settings.notificationVolume) + } + + private var volumeLabel: String { + "\(Int((self.settings.notificationVolume * 100).rounded()))%" + } +} diff --git a/Sources/CodexBar/PreferencesView.swift b/Sources/CodexBar/PreferencesView.swift index 408a83d6d..f89d1d59e 100644 --- a/Sources/CodexBar/PreferencesView.swift +++ b/Sources/CodexBar/PreferencesView.swift @@ -5,6 +5,7 @@ enum PreferencesTab: String, CaseIterable, Hashable { case general case providers case display + case notifications case advanced case about case debug @@ -18,6 +19,7 @@ enum PreferencesTab: String, CaseIterable, Hashable { case .general: "General" case .providers: "Providers" case .display: "Display" + case .notifications: "Notifications" case .advanced: "Advanced" case .about: "About" case .debug: "Debug" @@ -25,7 +27,12 @@ enum PreferencesTab: String, CaseIterable, Hashable { } var preferredWidth: CGFloat { - self == .providers ? PreferencesTab.providersWidth : PreferencesTab.defaultWidth + switch self { + case .providers: + PreferencesTab.providersWidth + default: + PreferencesTab.defaultWidth + } } var preferredHeight: CGFloat { @@ -82,6 +89,10 @@ struct PreferencesView: View { .tabItem { Label("Display", systemImage: "eye") } .tag(PreferencesTab.display) + NotificationsPane(settings: self.settings) + .tabItem { Label("Notifications", systemImage: "bell.badge") } + .tag(PreferencesTab.notifications) + AdvancedPane(settings: self.settings) .tabItem { Label("Advanced", systemImage: "slider.horizontal.3") } .tag(PreferencesTab.advanced) diff --git a/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift b/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift index b2593c546..cabbaf3fa 100644 --- a/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift +++ b/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift @@ -75,7 +75,23 @@ final class AugmentProviderRuntime: ProviderRuntime { await store.refreshProvider(.augment) } - self.keepalive = AugmentSessionKeepalive(logger: logger, onSessionRecovered: onSessionRecovered) + let onLoginRequired: () -> Void = { [settings = context.settings] in + let providerName = ProviderDescriptorRegistry.descriptor(for: .augment).metadata.displayName + AppNotifications.shared.post( + idPrefix: "augment-session-expired", + title: "Augment Session Expired", + body: "Please log in to app.augmentcode.com to restore your session.", + event: .augmentSessionExpired, + provider: providerName, + notificationsEnabled: settings.notificationsEnabled, + notificationVolume: settings.notificationVolume, + settings: settings.notificationSettings(for: .augmentSessionExpired)) + } + + self.keepalive = AugmentSessionKeepalive( + logger: logger, + onSessionRecovered: onSessionRecovered, + onLoginRequired: onLoginRequired) self.keepalive?.start() context.store.augmentLogger.info("Augment keepalive started") #endif diff --git a/Sources/CodexBar/SessionQuotaNotifications.swift b/Sources/CodexBar/SessionQuotaNotifications.swift index 962b5a61f..a7ac41d8f 100644 --- a/Sources/CodexBar/SessionQuotaNotifications.swift +++ b/Sources/CodexBar/SessionQuotaNotifications.swift @@ -1,6 +1,5 @@ import CodexBarCore import Foundation -@preconcurrency import UserNotifications enum SessionQuotaTransition: Equatable { case none @@ -37,27 +36,47 @@ protocol SessionQuotaNotifying: AnyObject { @MainActor final class SessionQuotaNotifier: SessionQuotaNotifying { private let logger = CodexBarLog.logger(LogCategories.sessionQuotaNotifications) + private let settings: SettingsStore - init() {} + init(settings: SettingsStore) { + self.settings = settings + } func post(transition: SessionQuotaTransition, provider: UsageProvider, badge: NSNumber? = nil) { guard transition != .none else { return } let providerName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName - - let (title, body) = switch transition { + let event: AppNotificationEvent + let title: String + let body: String + switch transition { case .none: - ("", "") + event = .sessionQuotaDepleted + title = "" + body = "" case .depleted: - ("\(providerName) session depleted", "0% left. Will notify when it's available again.") + event = .sessionQuotaDepleted + title = "\(providerName) session depleted" + body = "0% left. Will notify when it's available again." case .restored: - ("\(providerName) session restored", "Session quota is available again.") + event = .sessionQuotaRestored + title = "\(providerName) session restored" + body = "Session quota is available again." } let providerText = provider.rawValue let transitionText = String(describing: transition) let idPrefix = "session-\(providerText)-\(transitionText)" self.logger.info("enqueuing", metadata: ["prefix": idPrefix]) - AppNotifications.shared.post(idPrefix: idPrefix, title: title, body: body, badge: badge) + AppNotifications.shared.post( + idPrefix: idPrefix, + title: title, + body: body, + badge: badge, + event: event, + provider: providerName, + notificationsEnabled: self.settings.notificationsEnabled, + notificationVolume: self.settings.notificationVolume, + settings: self.settings.notificationSettings(for: event)) } } diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index ab32e5797..88dc7bef4 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -95,14 +95,53 @@ extension SettingsStore { } } + var notificationsEnabled: Bool { + get { self.defaultsState.notificationsEnabled } + set { + self.defaultsState.notificationsEnabled = newValue + self.userDefaults.set(newValue, forKey: "notificationsEnabled") + } + } + + var notificationVolume: Double { + get { self.defaultsState.notificationVolume } + set { + let normalized = min(max(newValue, 0.0), 1.0) + self.defaultsState.notificationVolume = normalized + self.userDefaults.set(normalized, forKey: "notificationVolume") + } + } + var sessionQuotaNotificationsEnabled: Bool { - get { self.defaultsState.sessionQuotaNotificationsEnabled } + get { + self.notificationSettings(for: .sessionQuotaDepleted).enabled || + self.notificationSettings(for: .sessionQuotaRestored).enabled + } set { - self.defaultsState.sessionQuotaNotificationsEnabled = newValue - self.userDefaults.set(newValue, forKey: "sessionQuotaNotificationsEnabled") + var depleted = self.notificationSettings(for: .sessionQuotaDepleted) + depleted.enabled = newValue + self.setNotificationSettings(depleted, for: .sessionQuotaDepleted) + + var restored = self.notificationSettings(for: .sessionQuotaRestored) + restored.enabled = newValue + self.setNotificationSettings(restored, for: .sessionQuotaRestored) } } + func notificationSettings(for event: AppNotificationEvent) -> NotificationDeliverySettings { + self.defaultsState.notificationSettings[event] ?? event.defaultSettings + } + + func setNotificationSettings(_ settings: NotificationDeliverySettings, for event: AppNotificationEvent) { + let normalized = settings.normalized + self.defaultsState.notificationSettings[event] = normalized + self.userDefaults.set(normalized.enabled, forKey: event.enabledDefaultsKey) + self.userDefaults.set(normalized.sound.rawValue, forKey: event.soundDefaultsKey) + self.persistNotificationString(normalized.hookCallURL, key: event.hookCallURLDefaultsKey) + self.persistNotificationString(normalized.shortcutName, key: event.shortcutNameDefaultsKey) + self.persistLegacySessionQuotaNotificationsFlag() + } + var usageBarsShowUsed: Bool { get { self.defaultsState.usageBarsShowUsed } set { @@ -111,6 +150,21 @@ extension SettingsStore { } } + private func persistNotificationString(_ value: String, key: String) { + if value.isEmpty { + self.userDefaults.removeObject(forKey: key) + } else { + self.userDefaults.set(value, forKey: key) + } + } + + private func persistLegacySessionQuotaNotificationsFlag() { + let enabled = self.notificationSettings(for: .sessionQuotaDepleted).enabled || + self.notificationSettings(for: .sessionQuotaRestored).enabled + self.defaultsState.sessionQuotaNotificationsEnabled = enabled + self.userDefaults.set(enabled, forKey: "sessionQuotaNotificationsEnabled") + } + var resetTimesShowAbsolute: Bool { get { self.defaultsState.resetTimesShowAbsolute } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 73786c47f..95943ead9 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -10,7 +10,11 @@ extension SettingsStore { _ = self.debugDisableKeychainAccess _ = self.debugKeepCLISessionsAlive _ = self.statusChecksEnabled + _ = self.notificationsEnabled _ = self.sessionQuotaNotificationsEnabled + for event in AppNotificationEvent.allCases { + _ = self.notificationSettings(for: event) + } _ = self.usageBarsShowUsed _ = self.resetTimesShowAbsolute _ = self.menuBarShowsBrandIconWithPercent diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index b006f6d0e..d0f39f9e8 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -72,6 +72,8 @@ final class SettingsStore { if env["XCTestConfigurationFilePath"] != nil { return true } if env["TESTING_LIBRARY_VERSION"] != nil { return true } if env["SWIFT_TESTING"] != nil { return true } + if Bundle.main.bundlePath.contains(".xctest") { return true } + if ProcessInfo.processInfo.arguments.contains(where: { $0.contains(".xctest") }) { return true } return NSClassFromString("XCTestCase") != nil }() @@ -127,7 +129,11 @@ final class SettingsStore { tokenAccountStore: any ProviderTokenAccountStoring = FileTokenAccountStore()) { let appGroupID = AppGroupSupport.currentGroupID() - let appGroupMigration = AppGroupSupport.migrateLegacyDataIfNeeded(standardDefaults: userDefaults) + let appGroupMigration = if Self.isRunningTests { + AppGroupSupport.MigrationResult(status: .alreadyCompleted) + } else { + AppGroupSupport.migrateLegacyDataIfNeeded(standardDefaults: userDefaults) + } let sharedDefaultsAvailable = Self.sharedDefaults != nil if !Self.isRunningTests { CodexBarLog.logger(LogCategories.settings).info( @@ -232,6 +238,19 @@ extension SettingsStore { let debugLoadingPatternRaw = userDefaults.string(forKey: "debugLoadingPattern") let debugKeepCLISessionsAlive = userDefaults.object(forKey: "debugKeepCLISessionsAlive") as? Bool ?? false let statusChecksEnabled = userDefaults.object(forKey: "statusChecksEnabled") as? Bool ?? true + let notificationsEnabledDefault = userDefaults.object(forKey: "notificationsEnabled") as? Bool + let notificationsEnabled = notificationsEnabledDefault ?? true + if notificationsEnabledDefault == nil { + userDefaults.set(true, forKey: "notificationsEnabled") + } + let notificationVolume: Double = { + let stored = userDefaults.object(forKey: "notificationVolume") as? Double + let resolved = min(max(stored ?? 1.0, 0.0), 1.0) + if stored == nil || stored != resolved { + userDefaults.set(resolved, forKey: "notificationVolume") + } + return resolved + }() let sessionQuotaDefault = userDefaults.object(forKey: "sessionQuotaNotificationsEnabled") as? Bool let sessionQuotaNotificationsEnabled = sessionQuotaDefault ?? true if sessionQuotaDefault == nil { @@ -281,6 +300,10 @@ extension SettingsStore { forKey: "mergedOverviewSelectedProviders") as? [String] ?? [] let selectedMenuProviderRaw = userDefaults.string(forKey: "selectedMenuProvider") let providerDetectionCompleted = userDefaults.object(forKey: "providerDetectionCompleted") as? Bool ?? false + let notificationSettings = Dictionary( + uniqueKeysWithValues: AppNotificationEvent.allCases.map { event in + (event, Self.loadNotificationSettings(event: event, userDefaults: userDefaults)) + }) return SettingsDefaultsState( refreshFrequency: refreshFrequency, @@ -292,6 +315,8 @@ extension SettingsStore { debugLoadingPatternRaw: debugLoadingPatternRaw, debugKeepCLISessionsAlive: debugKeepCLISessionsAlive, statusChecksEnabled: statusChecksEnabled, + notificationsEnabled: notificationsEnabled, + notificationVolume: notificationVolume, sessionQuotaNotificationsEnabled: sessionQuotaNotificationsEnabled, usageBarsShowUsed: usageBarsShowUsed, resetTimesShowAbsolute: resetTimesShowAbsolute, @@ -317,7 +342,47 @@ extension SettingsStore { mergedMenuLastSelectedWasOverview: mergedMenuLastSelectedWasOverview, mergedOverviewSelectedProvidersRaw: mergedOverviewSelectedProvidersRaw, selectedMenuProviderRaw: selectedMenuProviderRaw, - providerDetectionCompleted: providerDetectionCompleted) + providerDetectionCompleted: providerDetectionCompleted, + notificationSettings: notificationSettings) + } + + private static func loadNotificationSettings( + event: AppNotificationEvent, + userDefaults: UserDefaults) -> NotificationDeliverySettings + { + let enabled: Bool = { + if let stored = userDefaults.object(forKey: event.enabledDefaultsKey) as? Bool { + return stored + } + if event == .sessionQuotaDepleted || event == .sessionQuotaRestored, + let legacy = userDefaults.object(forKey: "sessionQuotaNotificationsEnabled") as? Bool + { + userDefaults.set(legacy, forKey: event.enabledDefaultsKey) + return legacy + } + let defaultValue = event.defaultSettings.enabled + userDefaults.set(defaultValue, forKey: event.enabledDefaultsKey) + return defaultValue + }() + + let sound: NotificationSoundOption = { + if let raw = userDefaults.string(forKey: event.soundDefaultsKey), + let option = NotificationSoundOption(rawValue: raw) + { + return option + } + let defaultValue = event.defaultSettings.sound + userDefaults.set(defaultValue.rawValue, forKey: event.soundDefaultsKey) + return defaultValue + }() + + return NotificationDeliverySettings( + enabled: enabled, + sound: sound, + hookCallURL: userDefaults.string(forKey: event.hookCallURLDefaultsKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "", + shortcutName: userDefaults.string(forKey: event.shortcutNameDefaultsKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "") } } diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index a65fb45d5..922a287fe 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -10,6 +10,8 @@ struct SettingsDefaultsState { var debugLoadingPatternRaw: String? var debugKeepCLISessionsAlive: Bool var statusChecksEnabled: Bool + var notificationsEnabled: Bool + var notificationVolume: Double var sessionQuotaNotificationsEnabled: Bool var usageBarsShowUsed: Bool var resetTimesShowAbsolute: Bool @@ -36,4 +38,5 @@ struct SettingsDefaultsState { var mergedOverviewSelectedProvidersRaw: [String] var selectedMenuProviderRaw: String? var providerDetectionCompleted: Bool + var notificationSettings: [AppNotificationEvent: NotificationDeliverySettings] } diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index e9fcf6f66..e7bc13f78 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -396,7 +396,15 @@ extension StatusItemController { let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName let title = "\(name) login successful" let body = "You can return to the app; authentication finished." - AppNotifications.shared.post(idPrefix: "login-\(provider.rawValue)", title: title, body: body) + AppNotifications.shared.post( + idPrefix: "login-\(provider.rawValue)", + title: title, + body: body, + event: .providerLogin, + provider: name, + notificationsEnabled: self.settings.notificationsEnabled, + notificationVolume: self.settings.notificationVolume, + settings: self.settings.notificationSettings(for: .providerLogin)) } func presentCursorLoginResult(_ result: CursorLoginRunner.Result) { diff --git a/Sources/CodexBar/UsageStore+HistoricalPace.swift b/Sources/CodexBar/UsageStore+HistoricalPace.swift index cc26cd0bf..6c62adf9f 100644 --- a/Sources/CodexBar/UsageStore+HistoricalPace.swift +++ b/Sources/CodexBar/UsageStore+HistoricalPace.swift @@ -40,18 +40,19 @@ extension UsageStore { return resolved } - func recordCodexHistoricalSampleIfNeeded(snapshot: UsageSnapshot) { - guard self.settings.historicalTrackingEnabled else { return } + @discardableResult + func recordCodexHistoricalSampleIfNeeded(snapshot: UsageSnapshot) -> Task? { + guard self.settings.historicalTrackingEnabled else { return nil } let projection = self.codexConsumerProjection( surface: .liveCard, snapshotOverride: snapshot, now: snapshot.updatedAt) - guard let weekly = projection.rateWindow(for: .weekly) else { return } + guard let weekly = projection.rateWindow(for: .weekly) else { return nil } let sampledAt = snapshot.updatedAt let ownership = self.codexOwnershipContext(preferredEmail: snapshot.accountEmail(for: .codex)) let historyStore = self.historicalUsageHistoryStore - Task.detached(priority: .utility) { [weak self] in + return Task.detached(priority: .utility) { [weak self] in _ = await historyStore.recordCodexWeekly( window: weekly, sampledAt: sampledAt, diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index 73a8a07e9..c8294a4dd 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -15,6 +15,9 @@ extension UsageStore { await override(snapshot) return } + if SettingsStore.isRunningTests { + return + } await Task.detached(priority: .utility) { WidgetSnapshotStore.save(snapshot) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 3704feeda..ae9973600 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -51,7 +51,11 @@ extension UsageStore { withObservationTracking { _ = self.settings.refreshFrequency _ = self.settings.statusChecksEnabled + _ = self.settings.notificationsEnabled _ = self.settings.sessionQuotaNotificationsEnabled + for event in AppNotificationEvent.allCases { + _ = self.settings.notificationSettings(for: event) + } _ = self.settings.usageBarsShowUsed _ = self.settings.costUsageEnabled _ = self.settings.randomBlinkEnabled @@ -221,7 +225,7 @@ final class UsageStore { registry: ProviderRegistry = .shared, historicalUsageHistoryStore: HistoricalUsageHistoryStore = HistoricalUsageHistoryStore(), planUtilizationHistoryStore: PlanUtilizationHistoryStore = .defaultAppSupport(), - sessionQuotaNotifier: any SessionQuotaNotifying = SessionQuotaNotifier(), + sessionQuotaNotifier: (any SessionQuotaNotifying)? = nil, startupBehavior: StartupBehavior = .automatic, environmentBase: [String: String] = ProcessInfo.processInfo.environment) { @@ -234,7 +238,7 @@ final class UsageStore { self.environmentBase = environmentBase self.historicalUsageHistoryStore = historicalUsageHistoryStore self.planUtilizationHistoryStore = planUtilizationHistoryStore - self.sessionQuotaNotifier = sessionQuotaNotifier + self.sessionQuotaNotifier = sessionQuotaNotifier ?? SessionQuotaNotifier(settings: settings) self.startupBehavior = startupBehavior.resolved(isRunningTests: Self.isRunningTestsProcess()) self.planUtilizationPersistenceCoordinator = PlanUtilizationHistoryPersistenceCoordinator( store: planUtilizationHistoryStore) @@ -647,7 +651,12 @@ final class UsageStore { self.lastKnownSessionWindowSource[provider] = currentSource } - guard self.settings.sessionQuotaNotificationsEnabled else { + let depletedEnabled = self.settings.notificationsEnabled && + self.settings.notificationSettings(for: .sessionQuotaDepleted).enabled + let restoredEnabled = self.settings.notificationsEnabled && + self.settings.notificationSettings(for: .sessionQuotaRestored).enabled + + guard depletedEnabled || restoredEnabled else { if SessionQuotaNotificationLogic.isDepleted(currentRemaining) || SessionQuotaNotificationLogic.isDepleted(previousRemaining) { @@ -661,7 +670,7 @@ final class UsageStore { } guard previousRemaining != nil else { - if SessionQuotaNotificationLogic.isDepleted(currentRemaining) { + if SessionQuotaNotificationLogic.isDepleted(currentRemaining), depletedEnabled { let providerText = provider.rawValue let message = "startup depleted: provider=\(providerText) curr=\(currentRemaining)" self.sessionQuotaLogger.info(message) @@ -693,6 +702,8 @@ final class UsageStore { "prev=\(previousRemaining ?? -1) curr=\(currentRemaining)" self.sessionQuotaLogger.info(message) + if transition == .depleted, !depletedEnabled { return } + if transition == .restored, !restoredEnabled { return } self.sessionQuotaNotifier.post(transition: transition, provider: provider, badge: nil) } diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift index 06ef8f5e6..db806dac6 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift @@ -12,6 +12,9 @@ final class NavigationDelegate: NSObject, WKNavigationDelegate { private var postCommitTask: Task? static var associationKey: UInt8 = 0 nonisolated static let postCommitSuccessDelay: TimeInterval = 0.75 + #if DEBUG + static var testPostCommitSuccessDelayOverride: TimeInterval? + #endif init(completion: @escaping (Result) -> Void) { self.completion = completion @@ -34,9 +37,14 @@ final class NavigationDelegate: NSObject, WKNavigationDelegate { func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { guard !self.hasCompleted else { return } self.postCommitTask?.cancel() + let delay = Self.resolvedPostCommitSuccessDelay + guard delay > 0 else { + self.completeOnce(.success(())) + return + } self.postCommitTask = Task { @MainActor [weak self] in guard let self else { return } - let nanoseconds = UInt64(Self.postCommitSuccessDelay * 1_000_000_000) + let nanoseconds = UInt64(delay * 1_000_000_000) try? await Task.sleep(nanoseconds: nanoseconds) self.completeOnce(.success(())) } @@ -65,6 +73,14 @@ final class NavigationDelegate: NSObject, WKNavigationDelegate { return false } + private static var resolvedPostCommitSuccessDelay: TimeInterval { + #if DEBUG + self.testPostCommitSuccessDelayOverride ?? self.postCommitSuccessDelay + #else + self.postCommitSuccessDelay + #endif + } + private func completeOnce(_ result: Result) { guard !self.hasCompleted else { return } self.hasCompleted = true diff --git a/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift b/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift index 9485c8ef7..288d1772c 100644 --- a/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift +++ b/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift @@ -2,7 +2,6 @@ import Foundation #if os(macOS) import AppKit -import UserNotifications /// Manages automatic session keepalive for Augment to prevent cookie expiration. /// @@ -32,6 +31,7 @@ public final class AugmentSessionKeepalive { private var isRefreshing = false private let logger: ((String) -> Void)? private var onSessionRecovered: (() async -> Void)? + private let onLoginRequired: (() -> Void)? /// Track consecutive failures to stop retrying after too many failures private var consecutiveFailures = 0 @@ -40,9 +40,14 @@ public final class AugmentSessionKeepalive { // MARK: - Initialization - public init(logger: ((String) -> Void)? = nil, onSessionRecovered: (() async -> Void)? = nil) { + public init( + logger: ((String) -> Void)? = nil, + onSessionRecovered: (() async -> Void)? = nil, + onLoginRequired: (() -> Void)? = nil) + { self.logger = logger self.onSessionRecovered = onSessionRecovered + self.onLoginRequired = onLoginRequired } deinit { @@ -297,45 +302,8 @@ public final class AugmentSessionKeepalive { /// Notify the user that they need to log in to Augment private func notifyUserLoginRequired() { - #if os(macOS) self.log("📢 Sending notification: Augment session expired") - - Task { - let center = UNUserNotificationCenter.current() - - // Request authorization if needed - do { - let granted = try await center.requestAuthorization(options: [.alert, .sound]) - guard granted else { - self.log("⚠️ Notification permission denied") - return - } - } catch { - self.log("✗ Failed to request notification permission: \(error)") - return - } - - // Create notification content - let content = UNMutableNotificationContent() - content.title = "Augment Session Expired" - content.body = "Please log in to app.augmentcode.com to restore your session." - content.sound = .default - - // Create trigger (deliver immediately) - let request = UNNotificationRequest( - identifier: "augment-session-expired-\(UUID().uuidString)", - content: content, - trigger: nil) - - // Deliver notification - do { - try await center.add(request) - self.log("✅ Notification delivered successfully") - } catch { - self.log("✗ Failed to deliver notification: \(error)") - } - } - #endif + self.onLoginRequired?() } /// Ping Augment's session endpoint to trigger cookie refresh diff --git a/Tests/CodexBarTests/AppGroupSupportTests.swift b/Tests/CodexBarTests/AppGroupSupportTests.swift index ba55d2acd..0c053e25c 100644 --- a/Tests/CodexBarTests/AppGroupSupportTests.swift +++ b/Tests/CodexBarTests/AppGroupSupportTests.swift @@ -91,6 +91,11 @@ struct AppGroupSupportTests { @Test func `legacy migration preserves existing target shared defaults`() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + let standardSuite = "AppGroupSupportTests-standard-existing-\(UUID().uuidString)" let currentSuite = "AppGroupSupportTests-current-existing-\(UUID().uuidString)" let legacySuite = "AppGroupSupportTests-legacy-existing-\(UUID().uuidString)" @@ -111,7 +116,9 @@ struct AppGroupSupportTests { bundleID: "com.steipete.codexbar", standardDefaults: standardDefaults, currentDefaultsOverride: currentDefaults, - legacyDefaultsOverride: legacyDefaults) + legacyDefaultsOverride: legacyDefaults, + currentSnapshotURLOverride: root.appendingPathComponent("current/widget-snapshot.json"), + legacySnapshotURLOverride: root.appendingPathComponent("legacy/widget-snapshot.json")) #expect(result.status == .noChangesNeeded) #expect(result.copiedDefaults == 0) diff --git a/Tests/CodexBarTests/AppNotificationsTests.swift b/Tests/CodexBarTests/AppNotificationsTests.swift new file mode 100644 index 000000000..123b2c364 --- /dev/null +++ b/Tests/CodexBarTests/AppNotificationsTests.swift @@ -0,0 +1,356 @@ +import Foundation +import Testing +@preconcurrency import UserNotifications +@testable import CodexBar + +@MainActor +struct AppNotificationsTests { + private final class Recorder: @unchecked Sendable { + struct ShortcutRun: Equatable { + let name: String + let provider: String? + } + + private let lock = NSLock() + private var requests: [UNNotificationRequest] = [] + private var hooks: [URL] = [] + private var openedURLs: [URL] = [] + private var shortcuts: [ShortcutRun] = [] + private var sounds: [(sound: NotificationSoundOption, volume: Double)] = [] + private var deliveryEvents: [String] = [] + + func record(request: UNNotificationRequest) { + self.lock.withLock { + self.requests.append(request) + self.deliveryEvents.append("request") + } + } + + func record(hook: URL) { + self.lock.withLock { + self.hooks.append(hook) + self.deliveryEvents.append("hook") + } + } + + func record(openedURL: URL) { + self.lock.withLock { + self.openedURLs.append(openedURL) + self.deliveryEvents.append("openURL") + } + } + + func record(shortcut: String, provider: String?) { + self.lock.withLock { + self.shortcuts.append(ShortcutRun(name: shortcut, provider: provider)) + self.deliveryEvents.append("shortcut") + } + } + + func record(sound: NotificationSoundOption, volume: Double) { + self.lock.withLock { + self.sounds.append((sound: sound, volume: volume)) + self.deliveryEvents.append("sound") + } + } + + func requestCount() -> Int { + self.lock.withLock { + self.requests.count + } + } + + func hookURLs() -> [URL] { + self.lock.withLock { + self.hooks + } + } + + func openedURLsSnapshot() -> [URL] { + self.lock.withLock { + self.openedURLs + } + } + + func shortcutsSnapshot() -> [ShortcutRun] { + self.lock.withLock { + self.shortcuts + } + } + + func soundsSnapshot() -> [(sound: NotificationSoundOption, volume: Double)] { + self.lock.withLock { + self.sounds + } + } + + func deliveryEventsSnapshot() -> [String] { + self.lock.withLock { + self.deliveryEvents + } + } + } + + @Test + func `disabled notification skips all delivery paths`() async { + let recorder = Recorder() + let notifications = Self.makeNotifications(recorder: recorder, authorizationStatus: .authorized) + + await Self.post( + notifications, + event: .providerLogin, + notificationsEnabled: true, + notificationVolume: 0.8, + settings: NotificationDeliverySettings( + enabled: false, + sound: .hero, + hookCallURL: "https://example.com/hook", + shortcutName: "Codex Login")) + + #expect(recorder.requestCount() == 0) + #expect(recorder.hookURLs().isEmpty) + #expect(recorder.openedURLsSnapshot().isEmpty) + #expect(recorder.shortcutsSnapshot().isEmpty) + #expect(recorder.soundsSnapshot().isEmpty) + } + + @Test + func `enabled notification delivers hook and shortcut command even when local notifications are denied`() async { + let recorder = Recorder() + let notifications = Self.makeNotifications(recorder: recorder, authorizationStatus: .denied) + + await Self.post( + notifications, + event: .providerLogin, + notificationsEnabled: true, + notificationVolume: 0.8, + settings: NotificationDeliverySettings( + enabled: true, + sound: .hero, + hookCallURL: "https://example.com/hook", + shortcutName: "Provider Login")) + + #expect(recorder.requestCount() == 0) + #expect(recorder.hookURLs().map(\.absoluteString) == ["https://example.com/hook"]) + #expect(recorder.openedURLsSnapshot().isEmpty) + #expect(recorder.shortcutsSnapshot().map(\.name) == ["Provider Login"]) + #expect(recorder.soundsSnapshot().isEmpty) + } + + @Test + func `custom scheme hook uses url opener and still posts local notification`() async { + let recorder = Recorder() + let notifications = Self.makeNotifications(recorder: recorder, authorizationStatus: .authorized) + + await Self.post( + notifications, + event: .sessionQuotaDepleted, + notificationsEnabled: true, + notificationVolume: 0.35, + settings: NotificationDeliverySettings( + enabled: true, + sound: .submarine, + hookCallURL: "raycast://extensions/test/run", + shortcutName: "Quota Alert")) + + #expect(recorder.requestCount() == 1) + #expect(recorder.hookURLs().isEmpty) + #expect(recorder.openedURLsSnapshot().map(\.absoluteString) == [ + "raycast://extensions/test/run", + ]) + #expect(recorder.shortcutsSnapshot().map(\.name) == ["Quota Alert"]) + #expect(recorder.soundsSnapshot().count == 1) + #expect(recorder.soundsSnapshot().first?.sound == .submarine) + #expect(recorder.soundsSnapshot().first?.volume == 0.35) + } + + @Test + func `local notification posts before external actions`() async { + let recorder = Recorder() + let notifications = Self.makeNotifications(recorder: recorder, authorizationStatus: .authorized) + + await Self.post( + notifications, + event: .providerLogin, + notificationsEnabled: true, + notificationVolume: 0.8, + settings: NotificationDeliverySettings( + enabled: true, + sound: .none, + hookCallURL: "https://example.com/hook", + shortcutName: "Provider Login")) + + #expect(recorder.deliveryEventsSnapshot() == ["request", "hook", "shortcut"]) + } + + @Test + func `global notifications toggle disables all delivery paths`() async { + let recorder = Recorder() + let notifications = Self.makeNotifications(recorder: recorder, authorizationStatus: .authorized) + + await Self.post( + notifications, + event: .sessionQuotaRestored, + notificationsEnabled: false, + notificationVolume: 0.5, + settings: NotificationDeliverySettings( + enabled: true, + sound: .glass, + hookCallURL: "https://example.com/hook", + shortcutName: "Quota Restored")) + + #expect(recorder.requestCount() == 0) + #expect(recorder.hookURLs().isEmpty) + #expect(recorder.openedURLsSnapshot().isEmpty) + #expect(recorder.shortcutsSnapshot().isEmpty) + #expect(recorder.soundsSnapshot().isEmpty) + } + + @Test + func `missing shortcuts app skips shortcut delivery quietly`() async { + let recorder = Recorder() + let notifications = Self.makeNotifications( + recorder: recorder, + authorizationStatus: .denied, + shortcutAvailable: false) + + await Self.post( + notifications, + event: .providerLogin, + notificationsEnabled: true, + notificationVolume: 0.8, + settings: NotificationDeliverySettings( + enabled: true, + sound: .hero, + hookCallURL: "", + shortcutName: "Provider Login")) + + #expect(recorder.requestCount() == 0) + #expect(recorder.hookURLs().isEmpty) + #expect(recorder.openedURLsSnapshot().isEmpty) + #expect(recorder.shortcutsSnapshot().isEmpty) + #expect(recorder.soundsSnapshot().isEmpty) + } + + @Test + func `missing shortcut is handled by command runner without opening Shortcuts UI`() async { + let recorder = Recorder() + let notifications = Self.makeNotifications( + recorder: recorder, + authorizationStatus: .denied, + shortcutResult: .init(succeeded: false, output: "Could not find the shortcut.")) + + await Self.post( + notifications, + event: .sessionQuotaDepleted, + notificationsEnabled: true, + notificationVolume: 0.8, + settings: NotificationDeliverySettings( + enabled: true, + sound: .none, + hookCallURL: "", + shortcutName: "CodexBar Session Quota Depleted")) + + #expect(recorder.requestCount() == 0) + #expect(recorder.openedURLsSnapshot().isEmpty) + #expect(recorder.shortcutsSnapshot().map(\.name) == ["CodexBar Session Quota Depleted"]) + #expect(recorder.soundsSnapshot().isEmpty) + } + + @Test + func `hook URL expands provider placeholder`() async { + let recorder = Recorder() + let notifications = Self.makeNotifications(recorder: recorder, authorizationStatus: .denied) + + await Self.post( + notifications, + event: .sessionQuotaRestored, + provider: "OpenCode Go", + notificationsEnabled: true, + notificationVolume: 0.8, + settings: NotificationDeliverySettings( + enabled: true, + sound: .none, + hookCallURL: "https://example.com/hooks/{provider}?provider={provider}", + shortcutName: "")) + + #expect(recorder.requestCount() == 0) + #expect(recorder.hookURLs().map(\.absoluteString) == [ + "https://example.com/hooks/OpenCode%20Go?provider=OpenCode%20Go", + ]) + #expect(recorder.shortcutsSnapshot().isEmpty) + } + + @Test + func `shortcut runner receives provider value`() async { + let recorder = Recorder() + let notifications = Self.makeNotifications(recorder: recorder, authorizationStatus: .denied) + + await Self.post( + notifications, + event: .providerLogin, + provider: "Codex", + notificationsEnabled: true, + notificationVolume: 0.8, + settings: NotificationDeliverySettings( + enabled: true, + sound: .none, + hookCallURL: "", + shortcutName: "Provider Login")) + + #expect(recorder.requestCount() == 0) + #expect(recorder.shortcutsSnapshot() == [ + Recorder.ShortcutRun(name: "Provider Login", provider: "Codex"), + ]) + } + + private static func makeNotifications( + recorder: Recorder, + authorizationStatus: UNAuthorizationStatus, + shortcutAvailable: Bool = true, + shortcutResult: AppNotifications.ShortcutRunResult = .init(succeeded: true, output: "")) + -> AppNotifications + { + AppNotifications( + authorizationStatusProvider: { authorizationStatus }, + authorizationRequester: { authorizationStatus == .authorized || authorizationStatus == .provisional }, + requestPoster: { request in recorder.record(request: request) }, + hookCaller: { url in recorder.record(hook: url) }, + urlOpener: { url in + recorder.record(openedURL: url) + return true + }, + shortcutAvailabilityChecker: { shortcutAvailable }, + shortcutRunner: { shortcut, provider in + recorder.record(shortcut: shortcut, provider: provider) + return shortcutResult + }, + soundPlayer: { sound, volume in + recorder.record(sound: sound, volume: volume) + return true + }, + allowsPostingWhenRunningUnderTests: true) + } + + private static func post( + _ notifications: AppNotifications, + event: AppNotificationEvent, + provider: String? = nil, + notificationsEnabled: Bool, + notificationVolume: Double, + settings: NotificationDeliverySettings) async + { + let task = notifications.post( + idPrefix: "test-\(event.rawValue)", + title: event.settingsTitle, + body: event.settingsSubtitle, + event: event, + provider: provider, + notificationsEnabled: notificationsEnabled, + notificationVolume: notificationVolume, + settings: settings) + if let task { + await task.value + } + } +} diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift index 6261162b3..13b9a172d 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift @@ -252,6 +252,13 @@ struct CodexAccountScopedRefreshTests { return self.credits(remaining: 1) } defer { store._test_codexCreditsLoaderOverride = nil } + store.lastFetchAttempts[.codex] = [ + ProviderFetchAttempt( + strategyID: "test-no-live-account", + kind: .oauth, + wasAvailable: false, + errorDescription: nil), + ] let startedAt = ContinuousClock.now await store.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: Date()) diff --git a/Tests/CodexBarTests/HistoricalUsagePaceOwnershipTests.swift b/Tests/CodexBarTests/HistoricalUsagePaceOwnershipTests.swift index 7c32b1974..4f5575dbd 100644 --- a/Tests/CodexBarTests/HistoricalUsagePaceOwnershipTests.swift +++ b/Tests/CodexBarTests/HistoricalUsagePaceOwnershipTests.swift @@ -314,7 +314,8 @@ extension HistoricalUsagePaceTests { resetsAt: updatedAt.addingTimeInterval(2 * 24 * 60 * 60), updatedAt: updatedAt) - store.recordCodexHistoricalSampleIfNeeded(snapshot: snapshot) + let writeTask = store.recordCodexHistoricalSampleIfNeeded(snapshot: snapshot) + await writeTask?.value let expectedKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount(id: "acct-123"))) let records = try await Self.waitForHistoricalWrite( store: store, @@ -343,7 +344,8 @@ extension HistoricalUsagePaceTests { resetsAt: updatedAt.addingTimeInterval(2 * 24 * 60 * 60), updatedAt: updatedAt) - store.recordCodexHistoricalSampleIfNeeded(snapshot: snapshot) + let writeTask = store.recordCodexHistoricalSampleIfNeeded(snapshot: snapshot) + await writeTask?.value let expectedKey = CodexHistoryOwnership.canonicalEmailHashKey(for: "person@example.com") let records = try await Self.waitForHistoricalWrite( store: store, diff --git a/Tests/CodexBarTests/NotificationSettingsTests.swift b/Tests/CodexBarTests/NotificationSettingsTests.swift new file mode 100644 index 000000000..746ca138c --- /dev/null +++ b/Tests/CodexBarTests/NotificationSettingsTests.swift @@ -0,0 +1,170 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct NotificationSettingsTests { + @Test + func `defaults session quota notifications to enabled`() throws { + let key = "sessionQuotaNotificationsEnabled" + let suite = "NotificationSettingsTests-session-quota-default" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.sessionQuotaNotificationsEnabled == true) + #expect(defaults.bool(forKey: key) == true) + #expect(store.notificationSettings(for: .sessionQuotaDepleted).enabled == true) + #expect(store.notificationSettings(for: .sessionQuotaRestored).enabled == true) + } + + @Test + func `defaults all notification settings to enabled with defaults`() throws { + let suite = "NotificationSettingsTests-defaults" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + for event in AppNotificationEvent.allCases { + let settings = store.notificationSettings(for: event) + #expect(settings.enabled == true) + #expect(settings.sound == event.defaultSound) + #expect(settings.hookCallURL.isEmpty) + #expect(settings.shortcutName.isEmpty) + #expect(defaults.object(forKey: event.enabledDefaultsKey) as? Bool == true) + #expect(defaults.string(forKey: event.soundDefaultsKey) == event.defaultSound.rawValue) + } + } + + @Test + func `defaults global notifications to enabled`() throws { + let suite = "NotificationSettingsTests-global-default" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.notificationsEnabled == true) + #expect(defaults.object(forKey: "notificationsEnabled") as? Bool == true) + #expect(store.notificationVolume == 1.0) + #expect(defaults.object(forKey: "notificationVolume") as? Double == 1.0) + } + + @Test + func `persists provider login notification integrations across instances`() throws { + let suite = "NotificationSettingsTests-provider-login" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + storeA.setNotificationSettings( + NotificationDeliverySettings( + enabled: false, + sound: .ping, + hookCallURL: " https://example.com/login-hook ", + shortcutName: " Login Shortcut "), + for: .providerLogin) + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.notificationSettings(for: .providerLogin) == NotificationDeliverySettings( + enabled: false, + sound: .ping, + hookCallURL: "https://example.com/login-hook", + shortcutName: "Login Shortcut")) + } + + @Test + func `persists global notification volume across instances`() throws { + let suite = "NotificationSettingsTests-global-volume" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + storeA.notificationVolume = 0.42 + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.notificationVolume == 0.42) + } + + @Test + func `session quota notification settings honor legacy enabled key`() throws { + let suite = "NotificationSettingsTests-session-quota-legacy" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(false, forKey: "sessionQuotaNotificationsEnabled") + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.notificationSettings(for: .sessionQuotaDepleted).enabled == false) + #expect(store.notificationSettings(for: .sessionQuotaRestored).enabled == false) + #expect(defaults.object(forKey: AppNotificationEvent.sessionQuotaDepleted.enabledDefaultsKey) as? Bool == false) + #expect(defaults.object(forKey: AppNotificationEvent.sessionQuotaRestored.enabledDefaultsKey) as? Bool == false) + } + + @Test + func `session quota legacy flag stays true when one split event remains enabled`() throws { + let suite = "NotificationSettingsTests-session-quota-split-legacy" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + var depleted = store.notificationSettings(for: .sessionQuotaDepleted) + depleted.enabled = false + store.setNotificationSettings(depleted, for: .sessionQuotaDepleted) + + #expect(defaults.object(forKey: "sessionQuotaNotificationsEnabled") as? Bool == true) + #expect(store.sessionQuotaNotificationsEnabled == true) + } +} diff --git a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift index 0ee8e0e70..c38b134d0 100644 --- a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift @@ -66,6 +66,8 @@ struct OpenAIDashboardNavigationDelegateTests { var result: Result? let box = DelegateBox() box.delegate = NavigationDelegate { result = $0 } + NavigationDelegate.testPostCommitSuccessDelayOverride = 0.01 + defer { NavigationDelegate.testPostCommitSuccessDelayOverride = nil } box.delegate?.webView(webView, didCommit: nil) #expect(result == nil) diff --git a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift index b71448ef1..e87a05326 100644 --- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift +++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift @@ -11,6 +11,7 @@ struct PreferencesPaneSmokeTests { let store = Self.makeUsageStore(settings: settings) _ = GeneralPane(settings: settings, store: store).body + _ = NotificationsPane(settings: settings).body _ = DisplayPane(settings: settings, store: store).body _ = AdvancedPane(settings: settings).body _ = ProvidersPane(settings: settings, store: store).body @@ -36,6 +37,7 @@ struct PreferencesPaneSmokeTests { store._setErrorForTesting("Example error", provider: .codex) _ = GeneralPane(settings: settings, store: store).body + _ = NotificationsPane(settings: settings).body _ = DisplayPane(settings: settings, store: store).body _ = AdvancedPane(settings: settings).body _ = ProvidersPane(settings: settings, store: store).body diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 9493cdb45..c59070b9b 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -475,22 +475,6 @@ struct SettingsStoreTests { #expect(storeB.opencodeWorkspaceID == "wrk_01KEJ50SHK9YR41HSRSJ6QTFCM") } - @Test - func `defaults session quota notifications to enabled`() throws { - let key = "sessionQuotaNotificationsEnabled" - let suite = "SettingsStoreTests-sessionQuotaNotifications" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let store = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - #expect(store.sessionQuotaNotificationsEnabled == true) - #expect(defaults.bool(forKey: key) == true) - } - @Test func `defaults claude usage source to auto`() throws { let suite = "SettingsStoreTests-claude-source" diff --git a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift index 168ebe3d9..7d91ad5b9 100644 --- a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift +++ b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift @@ -16,8 +16,10 @@ struct UsageStoreSessionQuotaTransitionTests { @Test func `copilot switch from primary to secondary resets baseline`() { + let suite = "UsageStoreSessionQuotaTransitionTests-primary-secondary" let settings = SettingsStore( - configStore: testConfigStore(suiteName: "UsageStoreSessionQuotaTransitionTests-primary-secondary"), + userDefaults: Self.testDefaults(suiteName: suite), + configStore: testConfigStore(suiteName: suite), zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) settings.refreshFrequency = .manual @@ -29,7 +31,8 @@ struct UsageStoreSessionQuotaTransitionTests { fetcher: UsageFetcher(), browserDetection: BrowserDetection(cacheTTL: 0), settings: settings, - sessionQuotaNotifier: notifier) + sessionQuotaNotifier: notifier, + startupBehavior: .testing) let primarySnapshot = UsageSnapshot( primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), @@ -48,8 +51,10 @@ struct UsageStoreSessionQuotaTransitionTests { @Test func `copilot switch from secondary to primary resets baseline`() { + let suite = "UsageStoreSessionQuotaTransitionTests-secondary-primary" let settings = SettingsStore( - configStore: testConfigStore(suiteName: "UsageStoreSessionQuotaTransitionTests-secondary-primary"), + userDefaults: Self.testDefaults(suiteName: suite), + configStore: testConfigStore(suiteName: suite), zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) settings.refreshFrequency = .manual @@ -61,7 +66,8 @@ struct UsageStoreSessionQuotaTransitionTests { fetcher: UsageFetcher(), browserDetection: BrowserDetection(cacheTTL: 0), settings: settings, - sessionQuotaNotifier: notifier) + sessionQuotaNotifier: notifier, + startupBehavior: .testing) let secondarySnapshot = UsageSnapshot( primary: nil, @@ -77,4 +83,58 @@ struct UsageStoreSessionQuotaTransitionTests { #expect(notifier.posts.isEmpty) } + + @Test + func `split session quota toggles only notify for enabled transition`() { + let suite = "UsageStoreSessionQuotaTransitionTests-split-events" + let settings = SettingsStore( + userDefaults: Self.testDefaults(suiteName: suite), + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.notificationsEnabled = true + + var depleted = settings.notificationSettings(for: .sessionQuotaDepleted) + depleted.enabled = false + settings.setNotificationSettings(depleted, for: .sessionQuotaDepleted) + + var restored = settings.notificationSettings(for: .sessionQuotaRestored) + restored.enabled = true + settings.setNotificationSettings(restored, for: .sessionQuotaRestored) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier, + startupBehavior: .testing) + + let depletedSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .codex, snapshot: depletedSnapshot) + + let restoredSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .codex, snapshot: restoredSnapshot) + + #expect(notifier.posts.count == 1) + #expect(notifier.posts.first?.transition == .restored) + #expect(notifier.posts.first?.provider == .codex) + } + + private static func testDefaults(suiteName: String) -> UserDefaults { + guard let defaults = UserDefaults(suiteName: suiteName) else { + preconditionFailure("Could not create defaults suite \(suiteName)") + } + defaults.removePersistentDomain(forName: suiteName) + defaults.set(AppGroupSupport.migrationVersion, forKey: AppGroupSupport.migrationVersionKey) + return defaults + } }