diff --git a/docs/components/notifications.md b/docs/components/notifications.md index bee00625..f90d3ccf 100644 --- a/docs/components/notifications.md +++ b/docs/components/notifications.md @@ -32,7 +32,8 @@ Custom commands also post a success toast when they exit 0. Whether these become **macOS system banners** depends on `systemNotificationsEnabled`; in-app alerts depend on `inAppNotificationsEnabled`. -A standalone sound (`notificationSoundEnabled`) plays only when system +A standalone sound (`notificationSound` — a picker offering Never, the macOS +system sounds, and the bundled Prowl Classic chime) plays only when system notifications are **disabled** — when banners are on, the banner carries its own sound. @@ -62,7 +63,10 @@ top of its section. **Jump to Latest Unread** (`⌘⌥U`) takes you straight to - `inAppNotificationsEnabled` — in-app alerts / bell indicators. - `systemNotificationsEnabled` — macOS system banners. -- `notificationSoundEnabled` — play a sound. +- `notificationSound` — which sound to play (`never`, a system sound such as + `hero`, or `supacodeClassic` = Prowl Classic, the default). Selecting a sound + previews it. Replaces the legacy `notificationSoundEnabled` toggle (`true` → + `supacodeClassic`, `false` → `never` on migration). - `moveNotifiedWorktreeToTop` — float notified worktree to top. - `commandFinishedNotificationEnabled` + `commandFinishedNotificationThreshold` — long-command notifications and their minimum duration. diff --git a/docs/components/settings.md b/docs/components/settings.md index 01e0c282..b6b1b325 100644 --- a/docs/components/settings.md +++ b/docs/components/settings.md @@ -17,7 +17,7 @@ window is a sidebar of tabs plus a detail pane. | Tab | Controls | |-----|----------| | **General** | Appearance (system/light/dark), default app for opening worktrees, diff tool, confirm-before-quit, default view mode, window chrome tint, toolbar buttons (Run / Open-in-editor), dim unfocused splits, Active Agents panel auto-show & tab titles. | -| **Notifications** | In-app alerts, sound, macOS system notifications, move-notified-to-top, command-finished notification + threshold, Dock badge & bounce. → [notifications](notifications.md) | +| **Notifications** | In-app alerts, notification sound picker (Never / system sounds / Prowl Classic), macOS system notifications, move-notified-to-top, command-finished notification + threshold, Dock badge & bounce. → [notifications](notifications.md) | | **Shortcuts** | Remap app keyboard shortcuts; view defaults; resolve conflicts. → [keyboard-shortcuts](../reference/keyboard-shortcuts.md) | | **Worktree** | Worktree creation/deletion defaults: prompt on create, fetch before create, base directory, copy ignored/untracked files, delete-branch-on-delete, merged-worktree action, archived auto-delete period. | | **Updates** | Update channel (Stable/Tip), auto-check toggle, "Check for Updates Now". → [updates](updates.md) | diff --git a/docs/reference/settings-fields.md b/docs/reference/settings-fields.md index c996d6b8..97163667 100644 --- a/docs/reference/settings-fields.md +++ b/docs/reference/settings-fields.md @@ -30,7 +30,7 @@ JSON is pretty-printed with sorted keys. Legacy `~/.supacode` is migrated to | `updatesAutomaticallyCheckForUpdates` | Bool | `true` | Background update checks. | | `updatesAutomaticallyDownloadUpdates` | Bool | `false` | Auto-download updates. | | `inAppNotificationsEnabled` | Bool | `true` | In-app alerts / bell indicators. | -| `notificationSoundEnabled` | Bool | `true` | Play a notification sound. | +| `notificationSound` | enum (`never` / system sound raw values like `hero` / `supacodeClassic`) | `supacodeClassic` | Sound played for notifications when system banners are off; `never` disables it. Migrates the legacy `notificationSoundEnabled` Bool (`true` → `supacodeClassic`, `false` → `never`); unknown raw values fall back to the default. | | `systemNotificationsEnabled` | Bool | `false` | macOS system banners. | | `moveNotifiedWorktreeToTop` | Bool | `true` | Float a notified worktree to top. | | `commandFinishedNotificationEnabled` | Bool | `true` | Notify when a long command finishes. | diff --git a/supacode/Clients/Notifications/NotificationSoundClient.swift b/supacode/Clients/Notifications/NotificationSoundClient.swift index 7166d5f4..526c01a8 100644 --- a/supacode/Clients/Notifications/NotificationSoundClient.swift +++ b/supacode/Clients/Notifications/NotificationSoundClient.swift @@ -2,26 +2,57 @@ import AppKit import ComposableArchitecture import Foundation -private let appNotificationSound: NSSound? = { - guard let url = Bundle.main.url(forResource: "notification", withExtension: "wav") else { - return nil +/// Caches the resolved `NSSound` for each `NotificationSound` so repeated +/// notifications don't reload the same file off disk. Main-actor isolated +/// because `NSSound` playback is. +@MainActor +private enum NotificationSoundCache { + static var sounds: [NotificationSound: NSSound] = [:] + + static func resolve(_ sound: NotificationSound) -> NSSound? { + if let cached = sounds[sound] { return cached } + guard let made = make(sound) else { return nil } + sounds[sound] = made + return made } - return NSSound(contentsOf: url, byReference: true) -}() -struct NotificationSoundClient { - var play: @MainActor @Sendable () -> Void + private static func make(_ sound: NotificationSound) -> NSSound? { + // `.never` (and any future sourceless case) plays nothing. + guard let source = sound.source else { return nil } + switch source { + case .system(let name): + return NSSound(named: name) + case .bundled(let resource, let fileExtension): + // The bundled chime is a packaging invariant; a missing or unreadable + // file means the sound is silently dead, so leave a trail. + guard let url = Bundle.main.url(forResource: resource, withExtension: fileExtension) else { + SupaLogger("Notifications").warning( + "Bundled \(resource).\(fileExtension) is missing; in-app sound will not play.") + return nil + } + guard let made = NSSound(contentsOf: url, byReference: true) else { + SupaLogger("Notifications").warning("Bundled \(resource).\(fileExtension) could not be loaded as an NSSound.") + return nil + } + return made + } + } +} + +nonisolated struct NotificationSoundClient: Sendable { + var play: @MainActor @Sendable (_ sound: NotificationSound) -> Void } extension NotificationSoundClient: DependencyKey { static let liveValue = NotificationSoundClient( - play: { - _ = appNotificationSound?.play() + play: { sound in + // `.never` resolves to no `NSSound`, so nothing plays. + _ = NotificationSoundCache.resolve(sound)?.play() } ) static let testValue = NotificationSoundClient( - play: {} + play: { _ in } ) } diff --git a/supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift b/supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift index d486ab35..fb1cd390 100644 --- a/supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift +++ b/supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift @@ -174,10 +174,11 @@ extension AppFeature { } ) } - if state.settings.notificationSoundEnabled && !state.settings.systemNotificationsEnabled { + if state.settings.notificationSound != .never && !state.settings.systemNotificationsEnabled { + let sound = state.settings.notificationSound effects.append( .run { _ in - await notificationSoundClient.play() + await notificationSoundClient.play(sound) } ) } diff --git a/supacode/Features/Settings/Models/GlobalSettings.swift b/supacode/Features/Settings/Models/GlobalSettings.swift index eae66dab..ac0d7eb1 100644 --- a/supacode/Features/Settings/Models/GlobalSettings.swift +++ b/supacode/Features/Settings/Models/GlobalSettings.swift @@ -6,7 +6,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { var updatesAutomaticallyCheckForUpdates: Bool var updatesAutomaticallyDownloadUpdates: Bool var inAppNotificationsEnabled: Bool - var notificationSoundEnabled: Bool + var notificationSound: NotificationSound var systemNotificationsEnabled: Bool var moveNotifiedWorktreeToTop: Bool var commandFinishedNotificationEnabled: Bool @@ -51,7 +51,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { updatesAutomaticallyCheckForUpdates: true, updatesAutomaticallyDownloadUpdates: false, inAppNotificationsEnabled: true, - notificationSoundEnabled: true, + notificationSound: .supacodeClassic, systemNotificationsEnabled: false, moveNotifiedWorktreeToTop: true, commandFinishedNotificationEnabled: true, @@ -95,7 +95,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { updatesAutomaticallyCheckForUpdates: Bool, updatesAutomaticallyDownloadUpdates: Bool, inAppNotificationsEnabled: Bool, - notificationSoundEnabled: Bool, + notificationSound: NotificationSound = .supacodeClassic, systemNotificationsEnabled: Bool = false, moveNotifiedWorktreeToTop: Bool, commandFinishedNotificationEnabled: Bool = true, @@ -137,7 +137,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.updatesAutomaticallyCheckForUpdates = updatesAutomaticallyCheckForUpdates self.updatesAutomaticallyDownloadUpdates = updatesAutomaticallyDownloadUpdates self.inAppNotificationsEnabled = inAppNotificationsEnabled - self.notificationSoundEnabled = notificationSoundEnabled + self.notificationSound = notificationSound self.systemNotificationsEnabled = systemNotificationsEnabled self.moveNotifiedWorktreeToTop = moveNotifiedWorktreeToTop self.commandFinishedNotificationEnabled = commandFinishedNotificationEnabled @@ -182,7 +182,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { try container.encode(updatesAutomaticallyCheckForUpdates, forKey: .updatesAutomaticallyCheckForUpdates) try container.encode(updatesAutomaticallyDownloadUpdates, forKey: .updatesAutomaticallyDownloadUpdates) try container.encode(inAppNotificationsEnabled, forKey: .inAppNotificationsEnabled) - try container.encode(notificationSoundEnabled, forKey: .notificationSoundEnabled) + try container.encode(notificationSound, forKey: .notificationSound) try container.encode(systemNotificationsEnabled, forKey: .systemNotificationsEnabled) try container.encode(moveNotifiedWorktreeToTop, forKey: .moveNotifiedWorktreeToTop) try container.encode(commandFinishedNotificationEnabled, forKey: .commandFinishedNotificationEnabled) @@ -228,7 +228,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { case updatesAutomaticallyCheckForUpdates case updatesAutomaticallyDownloadUpdates case inAppNotificationsEnabled - case notificationSoundEnabled + case notificationSound case systemNotificationsEnabled case moveNotifiedWorktreeToTop case commandFinishedNotificationEnabled @@ -264,8 +264,9 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { case shelfSpineTintFollowsRepositoryColor case externalDiffToolID case externalDiffCustomCommand - // Legacy key for migration + // Legacy keys for migration case automaticallyArchiveMergedWorktrees + case notificationSoundEnabled } init(from decoder: any Decoder) throws { @@ -285,9 +286,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { inAppNotificationsEnabled = try container.decodeIfPresent(Bool.self, forKey: .inAppNotificationsEnabled) ?? Self.default.inAppNotificationsEnabled - notificationSoundEnabled = - try container.decodeIfPresent(Bool.self, forKey: .notificationSoundEnabled) - ?? Self.default.notificationSoundEnabled + notificationSound = try Self.decodeNotificationSound(from: container) systemNotificationsEnabled = try container.decodeIfPresent(Bool.self, forKey: .systemNotificationsEnabled) ?? Self.default.systemNotificationsEnabled @@ -415,6 +414,23 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { return (toolID, customCommand) } + /// Folds the removed `notificationSoundEnabled` toggle: off becomes `.never`, + /// on the default sound. `try?` keeps an unrecognized raw value (e.g. from a + /// newer build) from failing the whole decode — it flattens both a decode + /// throw and an absent key to `nil`, so either falls through to the legacy + /// key and then the default. + private static func decodeNotificationSound( + from container: KeyedDecodingContainer + ) throws -> NotificationSound { + if let sound = try? container.decodeIfPresent(NotificationSound.self, forKey: .notificationSound) { + return sound + } + if let legacyEnabled = try container.decodeIfPresent(Bool.self, forKey: .notificationSoundEnabled) { + return legacyEnabled ? Self.default.notificationSound : .never + } + return Self.default.notificationSound + } + private static func decodeMergedWorktreeAction( from container: KeyedDecodingContainer ) throws -> MergedWorktreeAction? { diff --git a/supacode/Features/Settings/Models/NotificationSound.swift b/supacode/Features/Settings/Models/NotificationSound.swift new file mode 100644 index 00000000..27d2212f --- /dev/null +++ b/supacode/Features/Settings/Models/NotificationSound.swift @@ -0,0 +1,95 @@ +import Foundation + +/// User-selectable in-app notification sound; only drives the in-app `NSSound` +/// path (system notifications play the macOS banner's own sound). The String +/// raw value is the persisted contract, so renaming a case orphans selections. +nonisolated enum NotificationSound: String, CaseIterable, Identifiable, Codable, Sendable { + /// No sound plays in-app. + case never + // `/System/Library/Sounds`. + 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 + /// The bundled Prowl chime; the raw value keeps upstream's `supacodeClassic` + /// spelling because it is the persisted contract. + case supacodeClassic + + /// How a choice resolves to an in-app sound, or `nil` when nothing plays. + /// Exactly one kind per case, so invalid combinations are unrepresentable. + /// `NotificationSoundClient` turns it into an `NSSound`, keeping this model + /// free of AppKit. + enum Source: Equatable, Sendable { + case system(name: String) + case bundled(resource: String, withExtension: String) + } + + var source: Source? { + switch self { + case .never: + return nil + case .basso: + return .system(name: "Basso") + case .blow: + return .system(name: "Blow") + case .bottle: + return .system(name: "Bottle") + case .frog: + return .system(name: "Frog") + case .funk: + return .system(name: "Funk") + case .glass: + return .system(name: "Glass") + case .hero: + return .system(name: "Hero") + case .morse: + return .system(name: "Morse") + case .ping: + return .system(name: "Ping") + case .pop: + return .system(name: "Pop") + case .purr: + return .system(name: "Purr") + case .sosumi: + return .system(name: "Sosumi") + case .submarine: + return .system(name: "Submarine") + case .tink: + return .system(name: "Tink") + case .supacodeClassic: + return .bundled(resource: "notification", withExtension: "wav") + } + } + + /// The `/System/Library/Sounds` cases (every case whose source is `.system`). + static let systemCases: [NotificationSound] = allCases.filter { + if case .system? = $0.source { return true } + return false + } + + var id: String { + rawValue + } + + var displayName: String { + switch self { + case .never: + return "Never" + case .supacodeClassic: + return "Prowl Classic" + default: + if case .system(let name)? = source { return name } + return rawValue.capitalized + } + } +} diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index 2e758883..6d09ffe2 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -13,7 +13,7 @@ struct SettingsFeature { var updatesAutomaticallyCheckForUpdates: Bool var updatesAutomaticallyDownloadUpdates: Bool var inAppNotificationsEnabled: Bool - var notificationSoundEnabled: Bool + var notificationSound: NotificationSound var systemNotificationsEnabled: Bool var moveNotifiedWorktreeToTop: Bool var commandFinishedNotificationEnabled: Bool @@ -71,7 +71,7 @@ struct SettingsFeature { updatesAutomaticallyCheckForUpdates = settings.updatesAutomaticallyCheckForUpdates updatesAutomaticallyDownloadUpdates = settings.updatesAutomaticallyDownloadUpdates inAppNotificationsEnabled = settings.inAppNotificationsEnabled - notificationSoundEnabled = settings.notificationSoundEnabled + notificationSound = settings.notificationSound systemNotificationsEnabled = settings.systemNotificationsEnabled moveNotifiedWorktreeToTop = settings.moveNotifiedWorktreeToTop commandFinishedNotificationEnabled = settings.commandFinishedNotificationEnabled @@ -119,7 +119,7 @@ struct SettingsFeature { updatesAutomaticallyCheckForUpdates: updatesAutomaticallyCheckForUpdates, updatesAutomaticallyDownloadUpdates: updatesAutomaticallyDownloadUpdates, inAppNotificationsEnabled: inAppNotificationsEnabled, - notificationSoundEnabled: notificationSoundEnabled, + notificationSound: notificationSound, systemNotificationsEnabled: systemNotificationsEnabled, moveNotifiedWorktreeToTop: moveNotifiedWorktreeToTop, commandFinishedNotificationEnabled: commandFinishedNotificationEnabled, @@ -204,6 +204,7 @@ struct SettingsFeature { @Dependency(AnalyticsClient.self) private var analyticsClient @Dependency(SystemNotificationClient.self) private var systemNotificationClient + @Dependency(NotificationSoundClient.self) private var notificationSoundClient @Dependency(TerminalLayoutPersistenceClient.self) private var terminalLayoutPersistence @Dependency(CLIInstallClient.self) private var cliInstallClient @@ -239,7 +240,7 @@ struct SettingsFeature { state.updatesAutomaticallyCheckForUpdates = normalizedSettings.updatesAutomaticallyCheckForUpdates state.updatesAutomaticallyDownloadUpdates = normalizedSettings.updatesAutomaticallyDownloadUpdates state.inAppNotificationsEnabled = normalizedSettings.inAppNotificationsEnabled - state.notificationSoundEnabled = normalizedSettings.notificationSoundEnabled + state.notificationSound = normalizedSettings.notificationSound state.systemNotificationsEnabled = normalizedSettings.systemNotificationsEnabled state.moveNotifiedWorktreeToTop = normalizedSettings.moveNotifiedWorktreeToTop state.commandFinishedNotificationEnabled = normalizedSettings.commandFinishedNotificationEnabled @@ -277,6 +278,18 @@ struct SettingsFeature { state.syncGlobalDefaults(from: normalizedSettings) return .send(.delegate(.settingsChanged(normalizedSettings))) + case .binding(\.notificationSound): + let sound = state.notificationSound + // Preview the chosen sound, but only on the in-app path: with system + // notifications on, the banner plays the macOS default instead. `.never` + // has nothing to audition. + let shouldPreview = !state.systemNotificationsEnabled && sound != .never + state.syncGlobalDefaults(from: state.globalSettings) + return .merge( + persist(state), + shouldPreview ? .run { _ in await notificationSoundClient.play(sound) } : .none + ) + case .binding: state.commandFinishedNotificationThreshold = min(max(state.commandFinishedNotificationThreshold, 0), 600) state.syncGlobalDefaults(from: state.globalSettings) diff --git a/supacode/Features/Settings/Views/NotificationsSettingsView.swift b/supacode/Features/Settings/Views/NotificationsSettingsView.swift index c7a560f2..91d4c07a 100644 --- a/supacode/Features/Settings/Views/NotificationsSettingsView.swift +++ b/supacode/Features/Settings/Views/NotificationsSettingsView.swift @@ -16,11 +16,23 @@ struct NotificationsSettingsView: View { isOn: $store.inAppNotificationsEnabled ) .help("Show bell icon next to worktree") - Toggle( - "Play notification sound", - isOn: $store.notificationSoundEnabled - ) - .help("Play a sound when a notification is received") + Picker(selection: $store.notificationSound) { + Text(NotificationSound.never.displayName).tag(NotificationSound.never) + Divider() + ForEach(NotificationSound.systemCases) { sound in + NotificationSoundLabel(sound: sound).tag(sound) + } + Divider() + NotificationSoundLabel(sound: .supacodeClassic).tag(NotificationSound.supacodeClassic) + } label: { + Text("Play notification sound") + Text( + "Ignored when system notifications are enabled, as they play sounds" + + " according to your settings." + ) + } + .help("Choose the sound played when a notification is received") + .disabled(store.systemNotificationsEnabled) Toggle( "Move notified worktree to top", isOn: $store.moveNotifiedWorktreeToTop @@ -112,3 +124,15 @@ struct NotificationsSettingsView: View { } } } + +private struct NotificationSoundLabel: View { + let sound: NotificationSound + + var body: some View { + if sound == GlobalSettings.default.notificationSound { + Text("\(sound.displayName) \(Text("Default").foregroundStyle(.secondary))") + } else { + Text(sound.displayName) + } + } +} diff --git a/supacodeTests/AppFeatureSystemNotificationTests.swift b/supacodeTests/AppFeatureSystemNotificationTests.swift index d767332d..b38c7604 100644 --- a/supacodeTests/AppFeatureSystemNotificationTests.swift +++ b/supacodeTests/AppFeatureSystemNotificationTests.swift @@ -171,7 +171,6 @@ struct AppFeatureSystemNotificationTests { @Test(.dependencies) func notificationReceivedSkipsLocalSoundWhenSystemNotificationsEnabled() async { var globalSettings = GlobalSettings.default globalSettings.systemNotificationsEnabled = true - globalSettings.notificationSoundEnabled = true let plays = LockIsolated(0) let store = TestStore( initialState: AppFeature.State( @@ -180,7 +179,7 @@ struct AppFeatureSystemNotificationTests { ) { AppFeature() } withDependencies: { - $0.notificationSoundClient.play = { + $0.notificationSoundClient.play = { _ in plays.withValue { $0 += 1 } } } @@ -204,8 +203,8 @@ struct AppFeatureSystemNotificationTests { @Test(.dependencies) func notificationReceivedPlaysLocalSoundWhenSystemNotificationsDisabled() async { var globalSettings = GlobalSettings.default globalSettings.systemNotificationsEnabled = false - globalSettings.notificationSoundEnabled = true - let plays = LockIsolated(0) + globalSettings.notificationSound = .funk + let plays = LockIsolated<[NotificationSound]>([]) let sends = LockIsolated(0) let store = TestStore( initialState: AppFeature.State( @@ -214,8 +213,47 @@ struct AppFeatureSystemNotificationTests { ) { AppFeature() } withDependencies: { - $0.notificationSoundClient.play = { - plays.withValue { $0 += 1 } + $0.notificationSoundClient.play = { sound in + plays.withValue { $0.append(sound) } + } + $0.systemNotificationClient.send = { _, _, _, _ in + sends.withValue { $0 += 1 } + } + } + store.exhaustivity = .off + + await store.send( + .terminalEvent( + .notificationReceived( + worktreeID: "/tmp/repo/wt-1", + surfaceID: UUID(), + title: "Done", + body: "Build succeeded" + ) + ) + ) + await store.finish() + + // The selected sound reaches the player unchanged. + #expect(plays.value == [.funk]) + #expect(sends.value == 0) + } + + @Test(.dependencies) func notificationReceivedSkipsLocalSoundWhenNever() async { + var globalSettings = GlobalSettings.default + globalSettings.systemNotificationsEnabled = false + globalSettings.notificationSound = .never + let plays = LockIsolated<[NotificationSound]>([]) + let sends = LockIsolated(0) + let store = TestStore( + initialState: AppFeature.State( + settings: SettingsFeature.State(settings: globalSettings) + ) + ) { + AppFeature() + } withDependencies: { + $0.notificationSoundClient.play = { sound in + plays.withValue { $0.append(sound) } } $0.systemNotificationClient.send = { _, _, _, _ in sends.withValue { $0 += 1 } @@ -235,7 +273,7 @@ struct AppFeatureSystemNotificationTests { ) await store.finish() - #expect(plays.value == 1) + #expect(plays.value.isEmpty) #expect(sends.value == 0) } } diff --git a/supacodeTests/NotificationSoundTests.swift b/supacodeTests/NotificationSoundTests.swift new file mode 100644 index 00000000..07208b9f --- /dev/null +++ b/supacodeTests/NotificationSoundTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing + +@testable import supacode + +struct NotificationSoundTests { + @Test func sourceMapsEachCaseToExactlyOneKind() { + #expect(NotificationSound.never.source == nil) + #expect(NotificationSound.funk.source == .system(name: "Funk")) + #expect(NotificationSound.tink.source == .system(name: "Tink")) + #expect(NotificationSound.supacodeClassic.source == .bundled(resource: "notification", withExtension: "wav")) + } + + @Test func displayNamesAreUnambiguous() { + #expect(NotificationSound.never.displayName == "Never") + #expect(NotificationSound.supacodeClassic.displayName == "Prowl Classic") + #expect(NotificationSound.funk.displayName == "Funk") + } + + @Test func pickerGroupsCoverEveryCaseWithoutOverlap() { + let grouped = [NotificationSound.never] + NotificationSound.systemCases + [.supacodeClassic] + #expect(Set(grouped) == Set(NotificationSound.allCases)) + #expect(grouped.count == NotificationSound.allCases.count) + } + + // The raw values are the persisted contract; a rename orphans saved + // selections, so pin the literals here. Change them only as a deliberate edit. + @Test func rawValueContractIsStable() throws { + #expect(NotificationSound.never.rawValue == "never") + #expect(NotificationSound.hero.rawValue == "hero") + #expect(NotificationSound.supacodeClassic.rawValue == "supacodeClassic") + #expect( + Set(NotificationSound.allCases.map(\.rawValue)) == [ + "never", "basso", "blow", "bottle", "frog", "funk", "glass", "hero", + "morse", "ping", "pop", "purr", "sosumi", "submarine", "tink", "supacodeClassic", + ] + ) + // The persisted JSON string must still decode to the case. + #expect(try JSONDecoder().decode(NotificationSound.self, from: Data("\"hero\"".utf8)) == .hero) + } +} diff --git a/supacodeTests/SettingsFeatureTests.swift b/supacodeTests/SettingsFeatureTests.swift index 5eec1552..bd0b8a5a 100644 --- a/supacodeTests/SettingsFeatureTests.swift +++ b/supacodeTests/SettingsFeatureTests.swift @@ -18,7 +18,6 @@ struct SettingsFeatureTests { updatesAutomaticallyCheckForUpdates: false, updatesAutomaticallyDownloadUpdates: true, inAppNotificationsEnabled: false, - notificationSoundEnabled: true, systemNotificationsEnabled: true, moveNotifiedWorktreeToTop: false, analyticsEnabled: false, @@ -44,7 +43,6 @@ struct SettingsFeatureTests { $0.updatesAutomaticallyCheckForUpdates = false $0.updatesAutomaticallyDownloadUpdates = true $0.inAppNotificationsEnabled = false - $0.notificationSoundEnabled = true $0.moveNotifiedWorktreeToTop = false $0.systemNotificationsEnabled = true $0.analyticsEnabled = false @@ -66,7 +64,6 @@ struct SettingsFeatureTests { updatesAutomaticallyCheckForUpdates: false, updatesAutomaticallyDownloadUpdates: false, inAppNotificationsEnabled: false, - notificationSoundEnabled: false, systemNotificationsEnabled: false, moveNotifiedWorktreeToTop: true, analyticsEnabled: true, @@ -94,7 +91,6 @@ struct SettingsFeatureTests { updatesAutomaticallyCheckForUpdates: initialSettings.updatesAutomaticallyCheckForUpdates, updatesAutomaticallyDownloadUpdates: initialSettings.updatesAutomaticallyDownloadUpdates, inAppNotificationsEnabled: initialSettings.inAppNotificationsEnabled, - notificationSoundEnabled: initialSettings.notificationSoundEnabled, systemNotificationsEnabled: initialSettings.systemNotificationsEnabled, moveNotifiedWorktreeToTop: initialSettings.moveNotifiedWorktreeToTop, analyticsEnabled: initialSettings.analyticsEnabled, @@ -126,6 +122,74 @@ struct SettingsFeatureTests { #expect(settingsFile.global.systemNotificationsEnabled == true) } + @Test(.dependencies) func selectingNotificationSoundPlaysPreview() async { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = .default } + + let played = LockIsolated<[NotificationSound]>([]) + let store = TestStore(initialState: SettingsFeature.State()) { + SettingsFeature() + } withDependencies: { + $0[NotificationSoundClient.self].play = { sound in + played.withValue { $0.append(sound) } + } + } + + await store.send(.binding(.set(\.notificationSound, .glass))) { + $0.notificationSound = .glass + } + await store.receive(\.delegate.settingsChanged) + + // Picking a sound auditions it through the in-app player. + #expect(played.value == [.glass]) + } + + @Test(.dependencies) func doesNotPreviewWhenSystemNotificationsEnabled() async { + var settings = GlobalSettings.default + settings.systemNotificationsEnabled = true + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = settings } + + let played = LockIsolated<[NotificationSound]>([]) + let store = TestStore(initialState: SettingsFeature.State(settings: settings)) { + SettingsFeature() + } withDependencies: { + $0[NotificationSoundClient.self].play = { sound in + played.withValue { $0.append(sound) } + } + } + + await store.send(.binding(.set(\.notificationSound, .glass))) { + $0.notificationSound = .glass + } + await store.receive(\.delegate.settingsChanged) + + // With system notifications on the in-app path is unused, so no preview. + #expect(played.value.isEmpty) + } + + @Test(.dependencies) func doesNotPreviewWhenNeverSelected() async { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = .default } + + let played = LockIsolated<[NotificationSound]>([]) + let store = TestStore(initialState: SettingsFeature.State()) { + SettingsFeature() + } withDependencies: { + $0[NotificationSoundClient.self].play = { sound in + played.withValue { $0.append(sound) } + } + } + + await store.send(.binding(.set(\.notificationSound, .never))) { + $0.notificationSound = .never + } + await store.receive(\.delegate.settingsChanged) + + // "Never" has nothing to audition. + #expect(played.value.isEmpty) + } + @Test(.dependencies) func refreshDockBadgeAuthorizationStoresSystemState() async { let store = TestStore(initialState: SettingsFeature.State()) { SettingsFeature() @@ -177,7 +241,6 @@ struct SettingsFeatureTests { updatesAutomaticallyCheckForUpdates: false, updatesAutomaticallyDownloadUpdates: true, inAppNotificationsEnabled: false, - notificationSoundEnabled: false, systemNotificationsEnabled: true, moveNotifiedWorktreeToTop: true, analyticsEnabled: true, @@ -196,7 +259,6 @@ struct SettingsFeatureTests { $0.updatesAutomaticallyCheckForUpdates = false $0.updatesAutomaticallyDownloadUpdates = true $0.inAppNotificationsEnabled = false - $0.notificationSoundEnabled = false $0.moveNotifiedWorktreeToTop = true $0.systemNotificationsEnabled = true $0.analyticsEnabled = true diff --git a/supacodeTests/SettingsFilePersistenceTests.swift b/supacodeTests/SettingsFilePersistenceTests.swift index 5d9a9db7..edccf530 100644 --- a/supacodeTests/SettingsFilePersistenceTests.swift +++ b/supacodeTests/SettingsFilePersistenceTests.swift @@ -155,7 +155,8 @@ struct SettingsFilePersistenceTests { #expect(settings.global.updatesAutomaticallyCheckForUpdates == false) #expect(settings.global.updatesAutomaticallyDownloadUpdates == true) #expect(settings.global.inAppNotificationsEnabled == true) - #expect(settings.global.notificationSoundEnabled == true) + // Missing key (pre-feature file) decodes to the default sound. + #expect(settings.global.notificationSound == .supacodeClassic) #expect(settings.global.systemNotificationsEnabled == false) #expect(settings.global.moveNotifiedWorktreeToTop == true) #expect(settings.global.analyticsEnabled == true) @@ -225,6 +226,106 @@ struct SettingsFilePersistenceTests { #expect(settings.global.mergedWorktreeAction == nil) } + + @Test(.dependencies) func roundTripsExplicitNotificationSound() throws { + let storage = SettingsTestStorage() + + withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + $settings.withLock { $0.global.notificationSound = .submarine } + } + + let reloaded: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var reloaded: SettingsFile + return reloaded + } + + // An explicitly chosen sound must survive a save / reload round-trip. + #expect(reloaded.global.notificationSound == .submarine) + } + + @Test(.dependencies) func migratesLegacyNotificationSoundEnabledFalseToNever() throws { + // A pre-picker file with the sound explicitly muted must stay muted, not + // resurface as the default sound on upgrade. + let legacy = LegacySettingsFileWithSoundToggle( + global: LegacyGlobalSettingsWithSoundToggle( + appearanceMode: .dark, + updatesAutomaticallyCheckForUpdates: true, + updatesAutomaticallyDownloadUpdates: false, + notificationSoundEnabled: false + ), + repositories: [:] + ) + let data = try JSONEncoder().encode(legacy) + let storage = MutableTestStorage(initialData: data) + + let settings: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + return settings + } + + #expect(settings.global.notificationSound == .never) + } + + @Test(.dependencies) func migratesLegacyNotificationSoundEnabledTrueToDefault() throws { + // Symmetric to the `false` case: the sound was on, so it folds to the + // classic chime — what those users were already hearing. + let legacy = LegacySettingsFileWithSoundToggle( + global: LegacyGlobalSettingsWithSoundToggle( + appearanceMode: .dark, + updatesAutomaticallyCheckForUpdates: true, + updatesAutomaticallyDownloadUpdates: false, + notificationSoundEnabled: true + ), + repositories: [:] + ) + let data = try JSONEncoder().encode(legacy) + let storage = MutableTestStorage(initialData: data) + + let settings: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + return settings + } + + #expect(settings.global.notificationSound == .supacodeClassic) + } + + @Test(.dependencies) func decodesUnrecognizedNotificationSoundAsDefaultWithoutResettingSiblings() throws { + // A hand-edited or downgraded file carrying a sound case this build doesn't + // know yet. The `try?` must isolate the fallback to this one field. + var global = GlobalSettings.default + global.appearanceMode = .dark + global.systemNotificationsEnabled = true + global.updatesAutomaticallyDownloadUpdates = true + + let encoded = try JSONEncoder().encode(global) + var globalDict = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + globalDict["notificationSound"] = "futureSoundFromNewerBuild" + let data = try JSONSerialization.data(withJSONObject: ["global": globalDict, "repositories": [:]]) + let storage = MutableTestStorage(initialData: data) + + let settings: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + return settings + } + + // The unknown sound falls back to the default... + #expect(settings.global.notificationSound == .supacodeClassic) + // ...but the rest of the file survives. This is what `try?` buys over `try`. + #expect(settings.global.appearanceMode == .dark) + #expect(settings.global.systemNotificationsEnabled == true) + #expect(settings.global.updatesAutomaticallyDownloadUpdates == true) + } } nonisolated private final class MutableTestStorage: @unchecked Sendable { @@ -271,6 +372,18 @@ private struct LegacyAutoArchiveGlobalSettings: Codable { var automaticallyArchiveMergedWorktrees: Bool } +private struct LegacySettingsFileWithSoundToggle: Codable { + var global: LegacyGlobalSettingsWithSoundToggle + var repositories: [String: RepositorySettings] +} + +private struct LegacyGlobalSettingsWithSoundToggle: Codable { + var appearanceMode: AppearanceMode + var updatesAutomaticallyCheckForUpdates: Bool + var updatesAutomaticallyDownloadUpdates: Bool + var notificationSoundEnabled: Bool +} + private struct LegacySettingsFile: Codable { var global: LegacyGlobalSettings var repositories: [String: RepositorySettings]