From 8a2f7989906cd6a652924d9df8cb1474ead57c0a Mon Sep 17 00:00:00 2001 From: kibermaks Date: Mon, 27 Apr 2026 16:36:50 +0100 Subject: [PATCH 1/2] Add notification automation settings --- Sources/CodexBar/AppNotifications.swift | 322 +++++++++++++++-- Sources/CodexBar/CodexbarApp.swift | 3 +- Sources/CodexBar/NotificationSettings.swift | 247 +++++++++++++ Sources/CodexBar/PreferencesComponents.swift | 72 ++++ Sources/CodexBar/PreferencesDebugPane.swift | 45 ++- Sources/CodexBar/PreferencesGeneralPane.swift | 6 - .../PreferencesNotificationsPane.swift | 93 +++++ Sources/CodexBar/PreferencesView.swift | 12 +- .../Augment/AugmentProviderRuntime.swift | 18 +- .../CodexBar/SessionQuotaNotifications.swift | 35 +- Sources/CodexBar/SettingsStore+Defaults.swift | 60 +++- .../SettingsStore+MenuObservation.swift | 4 + Sources/CodexBar/SettingsStore.swift | 61 +++- Sources/CodexBar/SettingsStoreState.swift | 3 + .../StatusItemController+Actions.swift | 10 +- .../CodexBar/UsageStore+HistoricalPace.swift | 9 +- .../CodexBar/UsageStore+WidgetSnapshot.swift | 3 + Sources/CodexBar/UsageStore.swift | 19 +- .../OpenAIDashboardNavigationDelegate.swift | 18 +- .../Augment/AugmentSessionKeepalive.swift | 48 +-- .../CodexBarTests/AppNotificationsTests.swift | 325 ++++++++++++++++++ .../CodexAccountScopedRefreshTests.swift | 7 + .../HistoricalUsagePaceOwnershipTests.swift | 6 +- .../NotificationSettingsTests.swift | 170 +++++++++ ...enAIDashboardNavigationDelegateTests.swift | 8 +- .../PreferencesPaneSmokeTests.swift | 2 + Tests/CodexBarTests/SettingsStoreTests.swift | 16 - ...sageStoreSessionQuotaTransitionTests.swift | 42 +++ 28 files changed, 1542 insertions(+), 122 deletions(-) create mode 100644 Sources/CodexBar/NotificationSettings.swift create mode 100644 Sources/CodexBar/PreferencesNotificationsPane.swift create mode 100644 Tests/CodexBarTests/AppNotificationsTests.swift create mode 100644 Tests/CodexBarTests/NotificationSettingsTests.swift diff --git a/Sources/CodexBar/AppNotifications.swift b/Sources/CodexBar/AppNotifications.swift index 6bd3bc55a..840cff7c5 100644 --- a/Sources/CodexBar/AppNotifications.swift +++ b/Sources/CodexBar/AppNotifications.swift @@ -1,40 +1,135 @@ +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? - 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 } + + return Task { @MainActor in + let deliverySettings = settings ?? .localDefault + await self.deliverExternalActions( + event: event, + idPrefix: idPrefix, + provider: provider, + notificationsEnabled: notificationsEnabled, + settings: deliverySettings) + guard notificationsEnabled, deliverySettings.enabled else { + self.logger.debug( + "disabled; skipping notification", + metadata: self.metadata(event: event, idPrefix: idPrefix, provider: provider)) + return + } - Task { @MainActor in let granted = await self.ensureAuthorized() guard granted else { - logger.debug("not authorized; skipping post", metadata: ["prefix": idPrefix]) + self.logger.debug( + "not authorized; 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.sound = deliverySettings.sound == .systemDefault ? .default : nil content.badge = badge let request = UNNotificationRequest( @@ -42,12 +137,19 @@ final class AppNotifications { content: content, trigger: nil) - logger.info("posting", metadata: ["prefix": idPrefix]) + self.logger.info("posting", metadata: self.metadata(event: event, idPrefix: idPrefix, provider: provider)) do { - try await center.add(request) + try await self.requestPoster(request) + self.playSoundIfNeeded( + event: event, + idPrefix: idPrefix, + provider: provider, + settings: deliverySettings, + notificationVolume: notificationVolume) } catch { - let errorText = String(describing: error) - logger.error("failed to post", metadata: ["prefix": idPrefix, "error": errorText]) + var metadata = self.metadata(event: event, idPrefix: idPrefix, provider: provider) + metadata["error"] = "\(error)" + self.logger.error("failed to post", metadata: metadata) } } } @@ -68,7 +170,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,23 +179,120 @@ 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 { // Swift Testing doesn't always set XCTest env vars, and removing XCTest imports from // the test target can make NSClassFromString("XCTestCase") return nil. If we're not @@ -106,4 +305,73 @@ 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 { + await Task.detached(priority: .utility) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/shortcuts") + + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + + let inputURL: URL? + 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 + process.arguments = ["run", name, "--input-path", url.path] + } catch { + return ShortcutRunResult( + succeeded: false, + output: "Failed to prepare shortcut input: \(error)") + } + } else { + inputURL = nil + process.arguments = ["run", name] + } + defer { + if let inputURL { + try? FileManager.default.removeItem(at: inputURL) + } + } + + do { + try process.run() + process.waitUntilExit() + } catch { + return ShortcutRunResult(succeeded: false, output: "\(error)") + } + + let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: outputData + errorData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + return ShortcutRunResult(succeeded: process.terminationStatus == 0, output: output) + }.value + } } diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index cb7fb5be4..5a73da2f8 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -300,7 +300,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 14a541695..15951037c 100644 --- a/Sources/CodexBar/PreferencesView.swift +++ b/Sources/CodexBar/PreferencesView.swift @@ -5,6 +5,7 @@ enum PreferencesTab: String, Hashable { case general case providers case display + case notifications case advanced case about case debug @@ -14,7 +15,12 @@ enum PreferencesTab: String, Hashable { static let windowHeight: CGFloat = 580 var preferredWidth: CGFloat { - self == .providers ? PreferencesTab.providersWidth : PreferencesTab.defaultWidth + switch self { + case .providers: + PreferencesTab.providersWidth + default: + PreferencesTab.defaultWidth + } } var preferredHeight: CGFloat { @@ -71,6 +77,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 84aebdd9f..b6aba40da 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 5ac7f16f6..5a9d58dc9 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 68ba707aa..7a6a8fc70 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -215,6 +215,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 { @@ -262,6 +275,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, @@ -273,6 +290,8 @@ extension SettingsStore { debugLoadingPatternRaw: debugLoadingPatternRaw, debugKeepCLISessionsAlive: debugKeepCLISessionsAlive, statusChecksEnabled: statusChecksEnabled, + notificationsEnabled: notificationsEnabled, + notificationVolume: notificationVolume, sessionQuotaNotificationsEnabled: sessionQuotaNotificationsEnabled, usageBarsShowUsed: usageBarsShowUsed, resetTimesShowAbsolute: resetTimesShowAbsolute, @@ -297,7 +316,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 69e676032..e51c9bbbb 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 @@ -35,4 +37,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 b470e049c..142e8e19b 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -376,7 +376,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 d2af04b21..5fbaf1b02 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 @@ -219,7 +223,7 @@ final class UsageStore { registry: ProviderRegistry = .shared, historicalUsageHistoryStore: HistoricalUsageHistoryStore = HistoricalUsageHistoryStore(), planUtilizationHistoryStore: PlanUtilizationHistoryStore = .defaultAppSupport(), - sessionQuotaNotifier: any SessionQuotaNotifying = SessionQuotaNotifier(), + sessionQuotaNotifier: (any SessionQuotaNotifying)? = nil, startupBehavior: StartupBehavior = .automatic) { self.codexFetcher = fetcher @@ -230,7 +234,7 @@ final class UsageStore { self.registry = registry 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) @@ -641,7 +645,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) { @@ -655,7 +664,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) @@ -687,6 +696,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/AppNotificationsTests.swift b/Tests/CodexBarTests/AppNotificationsTests.swift new file mode 100644 index 000000000..bc3a00ff4 --- /dev/null +++ b/Tests/CodexBarTests/AppNotificationsTests.swift @@ -0,0 +1,325 @@ +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)] = [] + + func record(request: UNNotificationRequest) { + self.lock.withLock { + self.requests.append(request) + } + } + + func record(hook: URL) { + self.lock.withLock { + self.hooks.append(hook) + } + } + + func record(openedURL: URL) { + self.lock.withLock { + self.openedURLs.append(openedURL) + } + } + + func record(shortcut: String, provider: String?) { + self.lock.withLock { + self.shortcuts.append(ShortcutRun(name: shortcut, provider: provider)) + } + } + + func record(sound: NotificationSoundOption, volume: Double) { + self.lock.withLock { + self.sounds.append((sound: sound, volume: volume)) + } + } + + 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 + } + } + } + + @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 `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 13c31e625..73abf079a 100644 --- a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift @@ -3,6 +3,7 @@ import Testing import WebKit @testable import CodexBarCore +@Suite(.serialized) struct OpenAIDashboardNavigationDelegateTests { final class DelegateBox: @unchecked Sendable { var delegate: NavigationDelegate? @@ -47,16 +48,15 @@ struct OpenAIDashboardNavigationDelegateTests { @MainActor @Test - func `commit completes navigation successfully after grace period`() async { + func `commit completes navigation successfully after grace period`() { let webView = WKWebView() var result: Result? let box = DelegateBox() box.delegate = NavigationDelegate { result = $0 } + NavigationDelegate.testPostCommitSuccessDelayOverride = 0 + defer { NavigationDelegate.testPostCommitSuccessDelayOverride = nil } box.delegate?.webView(webView, didCommit: nil) - #expect(result == nil) - - try? await Task.sleep(nanoseconds: UInt64((NavigationDelegate.postCommitSuccessDelay + 0.1) * 1_000_000_000)) box.delegate = nil switch result { 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 53e67413c..3117fea0f 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -449,22 +449,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..6173721f6 100644 --- a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift +++ b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift @@ -77,4 +77,46 @@ struct UsageStoreSessionQuotaTransitionTests { #expect(notifier.posts.isEmpty) } + + @Test + func `split session quota toggles only notify for enabled transition`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreSessionQuotaTransitionTests-split-events"), + 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) + + 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) + } } From 2f8d0bf5ef2a528ba7a31497032ce6992466594a Mon Sep 17 00:00:00 2001 From: kibermaks Date: Tue, 28 Apr 2026 06:57:46 +0100 Subject: [PATCH 2/2] Fix notification external action ordering --- Sources/CodexBar/AppNotifications.swift | 154 +++++++++--------- .../CodexBarTests/AppNotificationsTests.swift | 31 ++++ 2 files changed, 107 insertions(+), 78 deletions(-) diff --git a/Sources/CodexBar/AppNotifications.swift b/Sources/CodexBar/AppNotifications.swift index 840cff7c5..ae52abee4 100644 --- a/Sources/CodexBar/AppNotifications.swift +++ b/Sources/CodexBar/AppNotifications.swift @@ -23,6 +23,9 @@ final class AppNotifications { private let allowsPostingWhenRunningUnderTests: Bool private let logger = CodexBarLog.logger(LogCategories.notifications) private var authorizationTask: Task? + private nonisolated static var shortcutRunTimeout: TimeInterval { + 60 + } init( authorizationStatusProvider: @escaping @Sendable () async -> UNAuthorizationStatus? = { @@ -104,13 +107,7 @@ final class AppNotifications { guard self.canPostInCurrentEnvironment else { return nil } return Task { @MainActor in - let deliverySettings = settings ?? .localDefault - await self.deliverExternalActions( - event: event, - idPrefix: idPrefix, - provider: provider, - notificationsEnabled: notificationsEnabled, - settings: deliverySettings) + let deliverySettings = (settings ?? .localDefault).normalized guard notificationsEnabled, deliverySettings.enabled else { self.logger.debug( "disabled; skipping notification", @@ -119,38 +116,46 @@ final class AppNotifications { } let granted = await self.ensureAuthorized() - guard granted else { + 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) + + 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)) - return } - 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) - - 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) - } + await self.deliverExternalActions( + event: event, + idPrefix: idPrefix, + provider: provider, + notificationsEnabled: notificationsEnabled, + settings: deliverySettings) } } @@ -324,54 +329,47 @@ final class AppNotifications { } private nonisolated static func runShortcutCommand(name: String, provider: String?) async -> ShortcutRunResult { - await Task.detached(priority: .utility) { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/shortcuts") - - let outputPipe = Pipe() - let errorPipe = Pipe() - process.standardOutput = outputPipe - process.standardError = errorPipe - - let inputURL: URL? - 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 - process.arguments = ["run", name, "--input-path", url.path] - } catch { - return ShortcutRunResult( - succeeded: false, - output: "Failed to prepare shortcut input: \(error)") - } - } else { - inputURL = nil - process.arguments = ["run", name] - } - defer { - if let inputURL { - try? FileManager.default.removeItem(at: inputURL) - } - } - + let inputURL: URL? + let arguments: [String] + if let provider = self.normalizedProvider(provider) { do { - try process.run() - process.waitUntilExit() + 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: "\(error)") + 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) } + } - let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() - let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() - let output = String(data: outputData + errorData, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + do { + let result = try await SubprocessRunner.run( + binary: "/usr/bin/shortcuts", + arguments: arguments, + environment: ProcessInfo.processInfo.environment, + timeout: self.shortcutRunTimeout, + label: "notification-shortcut") - return ShortcutRunResult(succeeded: process.terminationStatus == 0, output: output) - }.value + 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/Tests/CodexBarTests/AppNotificationsTests.swift b/Tests/CodexBarTests/AppNotificationsTests.swift index bc3a00ff4..123b2c364 100644 --- a/Tests/CodexBarTests/AppNotificationsTests.swift +++ b/Tests/CodexBarTests/AppNotificationsTests.swift @@ -17,34 +17,40 @@ struct AppNotificationsTests { 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") } } @@ -77,6 +83,12 @@ struct AppNotificationsTests { self.sounds } } + + func deliveryEventsSnapshot() -> [String] { + self.lock.withLock { + self.deliveryEvents + } + } } @Test @@ -152,6 +164,25 @@ struct AppNotificationsTests { #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()