diff --git a/SupacodeSettingsFeature/Reducer/SettingsFeature+CustomNotificationSound.swift b/SupacodeSettingsFeature/Reducer/SettingsFeature+CustomNotificationSound.swift new file mode 100644 index 000000000..aba5b9c35 --- /dev/null +++ b/SupacodeSettingsFeature/Reducer/SettingsFeature+CustomNotificationSound.swift @@ -0,0 +1,133 @@ +import ComposableArchitecture +import Foundation +import SupacodeSettingsShared + +private nonisolated let customNotificationSoundLogger = SupaLogger("Settings") + +extension SettingsFeature { + /// Custom-sound file lifecycle kept separate from the general settings switch. + static var customNotificationSoundReducer: some Reducer { + @Dependency(AnalyticsClient.self) var analyticsClient + @Dependency(CustomNotificationSoundClient.self) var customNotificationSoundClient + @Dependency(NotificationSoundClient.self) var notificationSoundClient + + return Reduce { state, action in + switch action { + case .customNotificationSoundSelected(let url): + guard !state.isManagingCustomNotificationSound else { return .none } + state.isManagingCustomNotificationSound = true + return .run { send in + do { + let sound = try await customNotificationSoundClient.importSound(url) + await send(.customNotificationSoundImported(.success(sound))) + } catch { + await send(.customNotificationSoundImported(.failure(error))) + } + } + + case .customNotificationSoundImportFailed(let message): + state.alert = AlertState { + TextState("Unable to Import Sound") + } actions: { + ButtonState(role: .cancel, action: .dismiss) { + TextState("OK") + } + } message: { + TextState(message) + } + return .none + + case .customNotificationSoundImported(.success(let customSound)): + state.isManagingCustomNotificationSound = false + let replacedSound = state.customNotificationSound + state.customNotificationSound = customSound + state.notificationSound = .custom + let configuration = state.notificationSoundConfiguration + var effects: [Effect] = [ + Self.persist(state.globalSettings, analyticsClient: analyticsClient), + .run { _ in await notificationSoundClient.play(configuration) }, + ] + if let replacedSound, replacedSound != customSound { + effects.append( + .run { _ in + do { + try await customNotificationSoundClient.removeSound(replacedSound) + } catch { + customNotificationSoundLogger.warning( + "Could not remove the previous custom notification sound: \(error.localizedDescription)" + ) + } + } + ) + } + return .merge(effects) + + case .customNotificationSoundImported(.failure(let error)): + state.isManagingCustomNotificationSound = false + return .send(.customNotificationSoundImportFailed(error.localizedDescription)) + + case .removeCustomNotificationSoundTapped: + guard !state.isManagingCustomNotificationSound else { return .none } + guard let customSound = state.customNotificationSound else { return .none } + state.alert = AlertState { + TextState("Remove \"\(customSound.displayName)\"?") + } actions: { + ButtonState(role: .destructive, action: .confirmRemoveCustomNotificationSound) { + TextState("Remove") + } + ButtonState(role: .cancel, action: .dismiss) { + TextState("Cancel") + } + } message: { + TextState("Supacode's managed copy of this sound will be deleted.") + } + return .none + + case .alert(.presented(.confirmRemoveCustomNotificationSound)): + state.alert = nil + guard let customSound = state.customNotificationSound else { return .none } + state.isManagingCustomNotificationSound = true + var settingsAfterRemoval = state.globalSettings + settingsAfterRemoval.customNotificationSound = nil + if settingsAfterRemoval.notificationSound == .custom { + settingsAfterRemoval.notificationSound = GlobalSettings.default.notificationSound + } + return .merge( + Self.persist(settingsAfterRemoval, analyticsClient: analyticsClient), + .run { send in + do { + try await customNotificationSoundClient.removeSound(customSound) + await send(.customNotificationSoundRemoved(.success(()))) + } catch { + await send(.customNotificationSoundRemoved(.failure(error))) + } + } + ) + + case .customNotificationSoundRemoved(.success): + state.isManagingCustomNotificationSound = false + state.customNotificationSound = nil + if state.notificationSound == .custom { + state.notificationSound = GlobalSettings.default.notificationSound + } + return Self.persist(state.globalSettings, analyticsClient: analyticsClient) + + case .customNotificationSoundRemoved(.failure(let error)): + state.isManagingCustomNotificationSound = false + state.alert = AlertState { + TextState("Unable to Remove Sound") + } actions: { + ButtonState(role: .cancel, action: .dismiss) { + TextState("OK") + } + } message: { + TextState(error.localizedDescription) + } + return Self.persist(state.globalSettings, analyticsClient: analyticsClient) + + default: + return .none + } + } + } +} diff --git a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift index 82ff008c3..3eb1c5046 100644 --- a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift @@ -54,6 +54,7 @@ public struct SettingsFeature { public var updatesAutomaticallyDownloadUpdates: Bool public var inAppNotificationsEnabled: Bool public var notificationSound: NotificationSound + public var customNotificationSound: CustomNotificationSound? public var systemNotificationsEnabled: Bool public var muteNotificationsForActiveSurface: Bool public var moveNotifiedWorktreeToTop: Bool @@ -83,6 +84,7 @@ public struct SettingsFeature { public var remoteSessionPersistenceEnabled: Bool public var appVisibility: AppVisibility public var terminalHibernationEnabled: Bool + public var isManagingCustomNotificationSound = false public var chromeTextSize: ChromeTextSize public var automaticRepositoryRefreshEnabled: Bool public var cliInstallState = CLIInstallState.checking @@ -128,6 +130,13 @@ public struct SettingsFeature { SkillAgent.allCasesByDisplayName.filter { (agentIntegrationStates[$0] ?? .checking).isInstallSheetCandidate } } + public var notificationSoundConfiguration: NotificationSoundConfiguration { + NotificationSoundConfiguration( + sound: notificationSound, + customSound: customNotificationSound + ) + } + public init(settings: GlobalSettings = .default) { @Dependency(\.openActionAvailability) var openActionAvailability installedOpenActions = openActionAvailability.installedActions() @@ -138,6 +147,7 @@ public struct SettingsFeature { updatesAutomaticallyDownloadUpdates = settings.updatesAutomaticallyDownloadUpdates inAppNotificationsEnabled = settings.inAppNotificationsEnabled notificationSound = settings.notificationSound + customNotificationSound = settings.customNotificationSound systemNotificationsEnabled = settings.systemNotificationsEnabled muteNotificationsForActiveSurface = settings.muteNotificationsForActiveSurface moveNotifiedWorktreeToTop = settings.moveNotifiedWorktreeToTop @@ -181,6 +191,7 @@ public struct SettingsFeature { updatesAutomaticallyDownloadUpdates: updatesAutomaticallyDownloadUpdates, inAppNotificationsEnabled: inAppNotificationsEnabled, notificationSound: notificationSound, + customNotificationSound: customNotificationSound, systemNotificationsEnabled: systemNotificationsEnabled, muteNotificationsForActiveSurface: muteNotificationsForActiveSurface, moveNotifiedWorktreeToTop: moveNotifiedWorktreeToTop, @@ -224,6 +235,11 @@ public struct SettingsFeature { case repositoriesChanged([SettingsRepositorySummary]) case setSelection(SettingsSection?) case setSystemNotificationsEnabled(Bool) + case customNotificationSoundSelected(URL) + case customNotificationSoundImportFailed(String) + case customNotificationSoundImported(Result) + case removeCustomNotificationSoundTapped + case customNotificationSoundRemoved(Result) case setAppVisibility(AppVisibility) case setAutomatedActionPolicy(AutomatedActionPolicy) case showNotificationPermissionAlert(errorMessage: String?) @@ -262,6 +278,7 @@ public struct SettingsFeature { public enum Alert: Equatable { case dismiss case openSystemNotificationSettings + case confirmRemoveCustomNotificationSound case confirmAutoDeleteDaysChange(AutoDeletePeriod) case confirmRemoveGlobalScript(ScriptDefinition.ID) } @@ -283,6 +300,7 @@ public struct SettingsFeature { public var body: some Reducer { BindingReducer() + Self.customNotificationSoundReducer Reduce { state, action in switch action { case .task: @@ -331,7 +349,7 @@ public struct SettingsFeature { } else { var updatedSettings = settings updatedSettings.defaultWorktreeBaseDirectoryPath = normalizedWorktreeBaseDirPath - normalizedSettings = persistGlobalSettings(updatedSettings) + normalizedSettings = Self.persistGlobalSettings(updatedSettings) } state.appearanceMode = normalizedSettings.appearanceMode state.defaultEditorID = normalizedSettings.defaultEditorID @@ -340,6 +358,7 @@ public struct SettingsFeature { state.updatesAutomaticallyDownloadUpdates = normalizedSettings.updatesAutomaticallyDownloadUpdates state.inAppNotificationsEnabled = normalizedSettings.inAppNotificationsEnabled state.notificationSound = normalizedSettings.notificationSound + state.customNotificationSound = normalizedSettings.customNotificationSound state.systemNotificationsEnabled = normalizedSettings.systemNotificationsEnabled state.muteNotificationsForActiveSurface = normalizedSettings.muteNotificationsForActiveSurface state.moveNotifiedWorktreeToTop = normalizedSettings.moveNotifiedWorktreeToTop @@ -376,15 +395,17 @@ public struct SettingsFeature { 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 + let configuration = state.notificationSoundConfiguration + let shouldPreview = + state.notificationSound != .never + && (!state.systemNotificationsEnabled + || state.notificationSound.usesSelectedSoundForSystemNotifications) state.syncGlobalDefaults(from: state.globalSettings) return .merge( persist(state), - shouldPreview ? .run { _ in await notificationSoundClient.play(sound) } : .none + shouldPreview + ? .run { _ in await notificationSoundClient.play(configuration) } + : .none ) case .binding: @@ -786,6 +807,13 @@ public struct SettingsFeature { case .repositorySettings: return .none + case .customNotificationSoundSelected, + .customNotificationSoundImportFailed, + .customNotificationSoundImported, + .removeCustomNotificationSoundTapped, + .customNotificationSoundRemoved: + return .none + case .delegate: return .none } @@ -796,7 +824,14 @@ public struct SettingsFeature { } private func persist(_ state: State) -> Effect { - let settings = persistGlobalSettings(state.globalSettings) + Self.persist(state.globalSettings, analyticsClient: analyticsClient) + } + + static func persist( + _ settings: GlobalSettings, + analyticsClient: AnalyticsClient + ) -> Effect { + let settings = persistGlobalSettings(settings) if settings.analyticsEnabled { analyticsClient.capture("settings_changed", nil) } @@ -804,7 +839,7 @@ public struct SettingsFeature { } @discardableResult - private func persistGlobalSettings(_ settings: GlobalSettings) -> GlobalSettings { + private static func persistGlobalSettings(_ settings: GlobalSettings) -> GlobalSettings { @Shared(.settingsFile) var settingsFile $settingsFile.withLock { $0.global = settings diff --git a/SupacodeSettingsFeature/Views/NotificationsSettingsView.swift b/SupacodeSettingsFeature/Views/NotificationsSettingsView.swift index 9c88df680..b33d3603f 100644 --- a/SupacodeSettingsFeature/Views/NotificationsSettingsView.swift +++ b/SupacodeSettingsFeature/Views/NotificationsSettingsView.swift @@ -1,9 +1,18 @@ import ComposableArchitecture +import Foundation import SupacodeSettingsShared import SwiftUI +import UniformTypeIdentifiers public struct NotificationsSettingsView: View { + private static let supportedSoundTypes: [UTType] = [ + .aiff, + .wav, + UTType(filenameExtension: "caf"), + ].compactMap { $0 } + @Bindable var store: StoreOf + @State private var isChoosingCustomSound = false public init(store: StoreOf) { self.store = store @@ -25,19 +34,45 @@ public struct NotificationsSettingsView: View { } Divider() Text(NotificationSound.supacodeClassic.displayName).tag(NotificationSound.supacodeClassic) + if let customSound = store.customNotificationSound { + Divider() + Text(customSound.displayName).tag(NotificationSound.custom) + } } label: { Text("Play notification sound") Text( - "Ignored when system notifications are enabled, as they play sounds" - + " according to your settings." + "For system notifications, macOS sounds use the default banner sound. " + + "Custom and Supacode Classic apply directly when System Settings allows sounds." ) } - .disabled(store.systemNotificationsEnabled) + VStack(alignment: .leading) { + HStack { + Button( + store.isManagingCustomNotificationSound ? "Working..." : "Choose Custom Sound..." + ) { + isChoosingCustomSound = true + } + .disabled(store.isManagingCustomNotificationSound) + .help("Import an AIFF, WAV, or CAF notification sound") + if store.customNotificationSound != nil { + Button("Remove Custom Sound", role: .destructive) { + store.send(.removeCustomNotificationSoundTapped) + } + .disabled(store.isManagingCustomNotificationSound) + .help("Delete Supacode's managed copy of the custom notification sound") + } + } + Text("AIFF, WAV, or CAF under 30 seconds (Linear PCM, IMA4, µLaw, or aLaw).") + .font(.footnote) + .foregroundStyle(.secondary) + } Toggle( isOn: $store.muteNotificationsForActiveSurface ) { Text("Mute notifications for active surface") - Text("Skip the notification and sound when the terminal that sent it is focused and visible.") + Text( + "Skip the notification and sound when the terminal that sent it is focused and visible." + ) } .disabled(!store.hasActiveNotificationChannel) } @@ -72,6 +107,19 @@ public struct NotificationsSettingsView: View { .padding(.leading, -8) .padding(.trailing, -6) .navigationTitle("Notifications") + .fileImporter( + isPresented: $isChoosingCustomSound, + allowedContentTypes: Self.supportedSoundTypes + ) { result in + switch result { + case .success(let url): + store.send(.customNotificationSoundSelected(url)) + case .failure(let error) where (error as? CocoaError)?.code == .userCancelled: + break + case .failure(let error): + store.send(.customNotificationSoundImportFailed(error.localizedDescription)) + } + } } } diff --git a/SupacodeSettingsShared/Clients/Notifications/CustomNotificationSoundClient.swift b/SupacodeSettingsShared/Clients/Notifications/CustomNotificationSoundClient.swift new file mode 100644 index 000000000..fd5c0f261 --- /dev/null +++ b/SupacodeSettingsShared/Clients/Notifications/CustomNotificationSoundClient.swift @@ -0,0 +1,179 @@ +import AVFAudio +import AudioToolbox +import ComposableArchitecture +import Foundation + +public enum CustomNotificationSoundImportError: Error, Equatable, LocalizedError, Sendable { + case unsupportedFileType + case unsupportedEncoding + case unreadable + case empty + case tooLong + + public var errorDescription: String? { + switch self { + case .unsupportedFileType: + "Choose an AIFF, WAV, or CAF audio file." + case .unsupportedEncoding: + "The sound must use Linear PCM, IMA4, µLaw, or aLaw encoding." + case .unreadable: + "Supacode could not read this audio file." + case .empty: + "The selected audio file is empty." + case .tooLong: + "Notification sounds must be shorter than 30 seconds." + } + } +} + +nonisolated enum ManagedNotificationSoundStorage { + static let fileNamePrefix = "supacode-custom-notification-" + static let supportedExtensions: Set = ["aif", "aiff", "caf", "wav"] + static let supportedFormatIDs: Set = [ + kAudioFormatLinearPCM, + kAudioFormatAppleIMA4, + kAudioFormatULaw, + kAudioFormatALaw, + ] + + static var defaultSoundsDirectory: URL { + FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0] + .appending(path: "Sounds", directoryHint: .isDirectory) + } + + static func fileURL( + for sound: CustomNotificationSound, + soundsDirectory: URL = defaultSoundsDirectory + ) -> URL? { + guard sound.fileName.hasPrefix(fileNamePrefix), + sound.fileName == URL(filePath: sound.fileName).lastPathComponent, + supportedExtensions.contains(URL(filePath: sound.fileName).pathExtension.lowercased()) + else { + return nil + } + return soundsDirectory.appending(path: sound.fileName, directoryHint: .notDirectory) + } +} + +public nonisolated struct CustomNotificationSoundClient: Sendable { + public var importSound: @Sendable (_ sourceURL: URL) async throws -> CustomNotificationSound + public var removeSound: @Sendable (_ sound: CustomNotificationSound) async throws -> Void + + public init( + importSound: + @escaping @Sendable (_ sourceURL: URL) async throws -> CustomNotificationSound, + removeSound: @escaping @Sendable (_ sound: CustomNotificationSound) async throws -> Void + ) { + self.importSound = importSound + self.removeSound = removeSound + } + + static func fileSystem( + soundsDirectory: URL, + makeUUID: @escaping @Sendable () -> UUID = { UUID() }, + validate: @escaping @Sendable (URL) throws -> Void = { try Self.validate($0) }, + removeInvalidCopy: @escaping @Sendable (URL) throws -> Void = { + try FileManager.default.removeItem(at: $0) + } + ) -> Self { + Self( + importSound: { sourceURL in + let fileManager = FileManager.default + let accessed = sourceURL.startAccessingSecurityScopedResource() + defer { + if accessed { + sourceURL.stopAccessingSecurityScopedResource() + } + } + + let fileExtension = sourceURL.pathExtension.lowercased() + guard ManagedNotificationSoundStorage.supportedExtensions.contains(fileExtension) else { + throw CustomNotificationSoundImportError.unsupportedFileType + } + try validate(sourceURL) + + try fileManager.createDirectory( + at: soundsDirectory, + withIntermediateDirectories: true + ) + let fileName = + "\(ManagedNotificationSoundStorage.fileNamePrefix)\(makeUUID().uuidString.lowercased()).\(fileExtension)" + let destinationURL = soundsDirectory.appending(path: fileName, directoryHint: .notDirectory) + try fileManager.copyItem(at: sourceURL, to: destinationURL) + do { + try validate(destinationURL) + } catch { + do { + try removeInvalidCopy(destinationURL) + } catch let cleanupError { + SupaLogger("Notifications").warning( + "Could not remove an invalid custom sound copy: \(cleanupError.localizedDescription)" + ) + } + throw error + } + + let baseName = sourceURL.deletingPathExtension().lastPathComponent + let displayName = baseName.isEmpty ? "Custom Sound" : baseName + return CustomNotificationSound(displayName: displayName, fileName: fileName) + }, + removeSound: { sound in + let fileManager = FileManager.default + guard + let url = ManagedNotificationSoundStorage.fileURL( + for: sound, + soundsDirectory: soundsDirectory + ) + else { + throw CustomNotificationSoundImportError.unreadable + } + guard fileManager.fileExists(atPath: url.path) else { return } + try fileManager.removeItem(at: url) + } + ) + } + + private static func validate(_ url: URL) throws { + let readableFile: AVAudioFile + do { + readableFile = try AVAudioFile(forReading: url) + } catch { + throw CustomNotificationSoundImportError.unreadable + } + guard + ManagedNotificationSoundStorage.supportedFormatIDs.contains( + readableFile.fileFormat.streamDescription.pointee.mFormatID + ) + else { + throw CustomNotificationSoundImportError.unsupportedEncoding + } + guard readableFile.length > 0, readableFile.processingFormat.sampleRate > 0 else { + throw CustomNotificationSoundImportError.empty + } + let duration = Double(readableFile.length) / readableFile.processingFormat.sampleRate + guard duration < 30 else { + throw CustomNotificationSoundImportError.tooLong + } + } + +} + +extension CustomNotificationSoundClient: DependencyKey { + public static let liveValue = fileSystem( + soundsDirectory: ManagedNotificationSoundStorage.defaultSoundsDirectory + ) + + public static let testValue = CustomNotificationSoundClient( + importSound: { _ in + throw CustomNotificationSoundImportError.unreadable + }, + removeSound: { _ in } + ) +} + +extension DependencyValues { + public var customNotificationSoundClient: CustomNotificationSoundClient { + get { self[CustomNotificationSoundClient.self] } + set { self[CustomNotificationSoundClient.self] = newValue } + } +} diff --git a/SupacodeSettingsShared/Clients/Notifications/NotificationSoundClient.swift b/SupacodeSettingsShared/Clients/Notifications/NotificationSoundClient.swift index ff1e0804e..9534e3115 100644 --- a/SupacodeSettingsShared/Clients/Notifications/NotificationSoundClient.swift +++ b/SupacodeSettingsShared/Clients/Notifications/NotificationSoundClient.swift @@ -2,58 +2,103 @@ import AppKit import ComposableArchitecture import Foundation -/// 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 - } - - private static func make(_ sound: NotificationSound) -> NSSound? { - // `.never` (and any future sourceless case) plays nothing. - guard let source = sound.source else { return nil } +enum NotificationSoundResolver { + static func make( + _ configuration: NotificationSoundConfiguration, + soundsDirectory: URL = ManagedNotificationSoundStorage.defaultSoundsDirectory, + bundledSoundURL: (String, String) -> URL? = { + Bundle.main.url(forResource: $0, withExtension: $1) + }, + makeSound: (URL, Bool) -> NSSound? = { + NSSound(contentsOf: $0, byReference: $1) + } + ) -> NSSound? { + guard let source = configuration.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 { + guard let url = bundledSoundURL(resource, fileExtension) else { SupaLogger("Notifications").warning( - "Bundled \(resource).\(fileExtension) is missing; in-app sound will not play.") + "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.") + guard let made = makeSound(url, true) else { + SupaLogger("Notifications").warning( + "Bundled \(resource).\(fileExtension) could not be loaded as an NSSound." + ) return nil } return made + case .custom(let fileName): + let customSound = CustomNotificationSound(displayName: fileName, fileName: fileName) + guard + let url = ManagedNotificationSoundStorage.fileURL( + for: customSound, + soundsDirectory: soundsDirectory + ), + FileManager.default.fileExists(atPath: url.path) + else { + return nil + } + return makeSound(url, false) } } } +/// 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 +enum NotificationSoundCache { + static var sounds: [NotificationSound: NSSound] = [:] + + static func resolve( + _ configuration: NotificationSoundConfiguration, + make: (NotificationSoundConfiguration) -> NSSound? = { + NotificationSoundResolver.make($0) + } + ) -> NSSound? { + if configuration.sound == .custom { + guard let custom = make(configuration) else { + SupaLogger("Notifications").warning( + "Custom notification sound is unavailable; playing the default in-app sound." + ) + return resolve( + NotificationSoundConfiguration(sound: .hero, customSound: nil), + make: make + ) + } + return custom + } + if let cached = sounds[configuration.sound] { return cached } + guard let made = make(configuration) else { return nil } + sounds[configuration.sound] = made + return made + } +} + public nonisolated struct NotificationSoundClient: Sendable { - public var play: @MainActor @Sendable (_ sound: NotificationSound) -> Void + public var play: @MainActor @Sendable (_ configuration: NotificationSoundConfiguration) -> Void - public init(play: @escaping @MainActor @Sendable (_ sound: NotificationSound) -> Void) { + public init( + play: @escaping @MainActor @Sendable (_ configuration: NotificationSoundConfiguration) -> Void + ) { self.play = play } } -extension NotificationSoundClient: DependencyKey { - public static let liveValue = NotificationSoundClient( - play: { sound in - // `.never` resolves to no `NSSound`, so nothing plays. - _ = NotificationSoundCache.resolve(sound)?.play() +extension NotificationSoundClient { + public static let live = NotificationSoundClient( + play: { configuration in + _ = NotificationSoundCache.resolve(configuration)?.play() } ) +} + +extension NotificationSoundClient: DependencyKey { + public static let liveValue = live public static let testValue = NotificationSoundClient( play: { _ in } diff --git a/SupacodeSettingsShared/Clients/Notifications/SystemNotificationClient.swift b/SupacodeSettingsShared/Clients/Notifications/SystemNotificationClient.swift index 3adb23f43..4b2f9fde2 100644 --- a/SupacodeSettingsShared/Clients/Notifications/SystemNotificationClient.swift +++ b/SupacodeSettingsShared/Clients/Notifications/SystemNotificationClient.swift @@ -9,6 +9,40 @@ private nonisolated let deeplinkUserInfoKey = "supacode.deeplink" private nonisolated let systemNotificationLogger = SupaLogger("SystemNotifications") +enum SystemNotificationSound: Equatable, Sendable { + case none + case `default` + case named(String) +} + +nonisolated enum SystemNotificationSoundResolver { + static func resolve( + _ configuration: NotificationSoundConfiguration, + soundsDirectory: URL = ManagedNotificationSoundStorage.defaultSoundsDirectory, + fileManager: FileManager = .default + ) -> SystemNotificationSound { + switch configuration.sound { + case .never: + return .none + case .custom: + guard let customSound = configuration.customSound, + let url = ManagedNotificationSoundStorage.fileURL( + for: customSound, + soundsDirectory: soundsDirectory + ), + fileManager.fileExists(atPath: url.path) + else { + return .default + } + return .named(customSound.fileName) + default: + return configuration.sound.usesSelectedSoundForSystemNotifications + ? .named("notification.wav") + : .default + } + } +} + @MainActor private final class ForegroundSystemNotificationDelegate: NSObject, UNUserNotificationCenterDelegate { var onDeeplinkTap: ((URL) -> Void)? @@ -79,13 +113,25 @@ public nonisolated struct SystemNotificationClient: Sendable { public var authorizationStatus: @MainActor @Sendable () async -> AuthorizationStatus public var requestAuthorization: @MainActor @Sendable () async -> AuthorizationRequestResult - public var send: @MainActor @Sendable (_ title: String, _ body: String, _ deeplinkURL: URL?) async -> Void + public var send: + @MainActor @Sendable ( + _ title: String, + _ body: String, + _ deeplinkURL: URL?, + _ sound: NotificationSoundConfiguration + ) async -> Void public var openSettings: @MainActor @Sendable () async -> Void public init( authorizationStatus: @escaping @MainActor @Sendable () async -> AuthorizationStatus, requestAuthorization: @escaping @MainActor @Sendable () async -> AuthorizationRequestResult, - send: @escaping @MainActor @Sendable (_ title: String, _ body: String, _ deeplinkURL: URL?) async -> Void, + send: + @escaping @MainActor @Sendable ( + _ title: String, + _ body: String, + _ deeplinkURL: URL?, + _ sound: NotificationSoundConfiguration + ) async -> Void, openSettings: @escaping @MainActor @Sendable () async -> Void ) { self.authorizationStatus = authorizationStatus @@ -95,8 +141,8 @@ public nonisolated struct SystemNotificationClient: Sendable { } } -extension SystemNotificationClient: DependencyKey { - public static let liveValue = SystemNotificationClient( +extension SystemNotificationClient { + public static let live = SystemNotificationClient( authorizationStatus: { let center = configuredNotificationCenter() let settings = await center.notificationSettings() @@ -125,12 +171,22 @@ extension SystemNotificationClient: DependencyKey { ) } }, - send: { title, body, deeplinkURL in + send: { title, body, deeplinkURL, soundConfiguration in let center = configuredNotificationCenter() let content = UNMutableNotificationContent() content.title = title content.body = body - content.sound = .default + switch SystemNotificationSoundResolver.resolve(soundConfiguration) { + case .none: + content.sound = nil + case .default: + content.sound = .default + case .named(let fileName): + content.sound = UNNotificationSound( + named: UNNotificationSoundName(rawValue: fileName) + ) + } + if let deeplinkURL { content.userInfo = [deeplinkUserInfoKey: deeplinkURL.absoluteString] } @@ -139,7 +195,13 @@ extension SystemNotificationClient: DependencyKey { content: content, trigger: nil ) - try? await center.add(request) + do { + try await center.add(request) + } catch { + systemNotificationLogger.error( + "Failed to deliver system notification: \(error.localizedDescription)" + ) + } }, openSettings: { guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") else { @@ -148,11 +210,15 @@ extension SystemNotificationClient: DependencyKey { _ = NSWorkspace.shared.open(url) } ) +} + +extension SystemNotificationClient: DependencyKey { + public static let liveValue = live public static let testValue = SystemNotificationClient( authorizationStatus: { .notDetermined }, requestAuthorization: { AuthorizationRequestResult(granted: false, errorMessage: nil) }, - send: { _, _, _ in }, + send: { _, _, _, _ in }, openSettings: {} ) } diff --git a/SupacodeSettingsShared/Models/GlobalSettings.swift b/SupacodeSettingsShared/Models/GlobalSettings.swift index 47c97dc6d..11f5bfd75 100644 --- a/SupacodeSettingsShared/Models/GlobalSettings.swift +++ b/SupacodeSettingsShared/Models/GlobalSettings.swift @@ -65,6 +65,7 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { public var updatesAutomaticallyDownloadUpdates: Bool public var inAppNotificationsEnabled: Bool public var notificationSound: NotificationSound + public var customNotificationSound: CustomNotificationSound? public var systemNotificationsEnabled: Bool public var muteNotificationsForActiveSurface: Bool public var moveNotifiedWorktreeToTop: Bool @@ -123,6 +124,7 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { updatesAutomaticallyDownloadUpdates: false, inAppNotificationsEnabled: true, notificationSound: .hero, + customNotificationSound: nil, systemNotificationsEnabled: false, muteNotificationsForActiveSurface: true, moveNotifiedWorktreeToTop: false, @@ -162,6 +164,7 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { updatesAutomaticallyDownloadUpdates: Bool, inAppNotificationsEnabled: Bool, notificationSound: NotificationSound = .hero, + customNotificationSound: CustomNotificationSound? = nil, systemNotificationsEnabled: Bool = false, muteNotificationsForActiveSurface: Bool = true, moveNotifiedWorktreeToTop: Bool, @@ -201,6 +204,7 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.updatesAutomaticallyDownloadUpdates = updatesAutomaticallyDownloadUpdates self.inAppNotificationsEnabled = inAppNotificationsEnabled self.notificationSound = notificationSound + self.customNotificationSound = customNotificationSound self.systemNotificationsEnabled = systemNotificationsEnabled self.muteNotificationsForActiveSurface = muteNotificationsForActiveSurface self.moveNotifiedWorktreeToTop = moveNotifiedWorktreeToTop @@ -271,6 +275,12 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { } else { notificationSound = Self.default.notificationSound } + customNotificationSound = + (try? container.decodeIfPresent(CustomNotificationSound.self, forKey: .customNotificationSound)) + ?? nil + if notificationSound == .custom && customNotificationSound == nil { + notificationSound = Self.default.notificationSound + } systemNotificationsEnabled = try container.decodeIfPresent(Bool.self, forKey: .systemNotificationsEnabled) ?? Self.default.systemNotificationsEnabled diff --git a/SupacodeSettingsShared/Models/NotificationSound.swift b/SupacodeSettingsShared/Models/NotificationSound.swift index b749ccc32..c72a2f6e5 100644 --- a/SupacodeSettingsShared/Models/NotificationSound.swift +++ b/SupacodeSettingsShared/Models/NotificationSound.swift @@ -1,10 +1,21 @@ 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. -public enum NotificationSound: String, CaseIterable, Identifiable, Codable, Sendable { - /// No sound plays in-app. +public nonisolated struct CustomNotificationSound: Codable, Equatable, Hashable, Sendable { + public let displayName: String + public let fileName: String + + public init(displayName: String, fileName: String) { + self.displayName = displayName + self.fileName = fileName + } +} + +/// User-selectable notification sound. The String raw value is the persisted +/// contract, so renaming a case orphans selections. +public nonisolated enum NotificationSound: String, CaseIterable, Identifiable, Codable, Hashable, + Sendable +{ + /// No sound plays. case never // `/System/Library/Sounds`. case basso @@ -23,59 +34,40 @@ public enum NotificationSound: String, CaseIterable, Identifiable, Codable, Send case tink /// The bundled Supacode chime. case supacodeClassic + /// A user-provided sound copied into `Library/Sounds`. + case custom - /// 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) + case custom(fileName: String) } - var source: Source? { + func source(customSound: CustomNotificationSound?) -> 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") + case .custom: + guard let customSound else { return nil } + return .custom(fileName: customSound.fileName) + default: + return .system(name: rawValue.capitalized) } } - /// The `/System/Library/Sounds` cases (every case whose source is `.system`). - public static let systemCases: [NotificationSound] = allCases.filter { - if case .system? = $0.source { return true } + public static let systemCases = allCases.filter { + if case .system = $0.source(customSound: nil) { + return true + } return false } + public var usesSelectedSoundForSystemNotifications: Bool { + self == .supacodeClassic || self == .custom + } + public var id: String { rawValue } @@ -86,9 +78,24 @@ public enum NotificationSound: String, CaseIterable, Identifiable, Codable, Send return "Never" case .supacodeClassic: return "Supacode Classic" + case .custom: + return "Custom" default: - if case .system(let name)? = source { return name } return rawValue.capitalized } } } + +public nonisolated struct NotificationSoundConfiguration: Equatable, Hashable, Sendable { + public let sound: NotificationSound + public let customSound: CustomNotificationSound? + + public init(sound: NotificationSound, customSound: CustomNotificationSound?) { + self.sound = sound + self.customSound = customSound + } + + var source: NotificationSound.Source? { + sound.source(customSound: customSound) + } +} diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 10b391a0f..129e41aea 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -1703,19 +1703,22 @@ struct AppFeature { .notificationReceived(let worktreeID, let surfaceID, let title, let body, let isViewed)): var effects: [Effect] = [] let isMuted = isViewed && state.settings.muteNotificationsForActiveSurface + let soundConfiguration = state.settings.notificationSoundConfiguration if state.settings.systemNotificationsEnabled && !isMuted { let deeplinkURL = surfaceDeeplinkURL(worktreeID: worktreeID, surfaceID: surfaceID) effects.append( .run { _ in - await systemNotificationClient.send(title, body, deeplinkURL) + await systemNotificationClient.send(title, body, deeplinkURL, soundConfiguration) } ) } - if state.settings.notificationSound != .never && !state.settings.systemNotificationsEnabled && !isMuted { - let sound = state.settings.notificationSound + if state.settings.notificationSound != .never, + !state.settings.systemNotificationsEnabled, + !isMuted + { effects.append( .run { _ in - await notificationSoundClient.play(sound) + await notificationSoundClient.play(soundConfiguration) } ) } diff --git a/supacodeTests/AppFeatureSystemNotificationTests.swift b/supacodeTests/AppFeatureSystemNotificationTests.swift index 9c419e50b..e76d75960 100644 --- a/supacodeTests/AppFeatureSystemNotificationTests.swift +++ b/supacodeTests/AppFeatureSystemNotificationTests.swift @@ -128,7 +128,7 @@ struct AppFeatureSystemNotificationTests { ) { AppFeature() } withDependencies: { - $0.systemNotificationClient.send = { title, body, _ in + $0.systemNotificationClient.send = { title, body, _, _ in sends.withValue { $0.append((title, body)) } } $0.terminalClient.tabID = { _, _ in nil } @@ -153,6 +153,95 @@ struct AppFeatureSystemNotificationTests { #expect(sends.value.first?.1 == "Build succeeded") } + @Test(.dependencies) + func systemNotificationReceivesCustomSoundConfiguration() async { + let custom = CustomNotificationSound( + displayName: "My Bell", + fileName: "supacode-custom-notification-bell.wav" + ) + var globalSettings = GlobalSettings.default + globalSettings.systemNotificationsEnabled = true + globalSettings.notificationSound = .custom + globalSettings.customNotificationSound = custom + let received = LockIsolated<[NotificationSoundConfiguration]>([]) + let store = TestStore( + initialState: AppFeature.State( + settings: SettingsFeature.State(settings: globalSettings) + ) + ) { + AppFeature() + } withDependencies: { + $0.systemNotificationClient.send = { _, _, _, sound in + received.withValue { $0.append(sound) } + } + $0.terminalClient.tabID = { _, _ in nil } + } + store.exhaustivity = .off + + await store.send( + .terminalEvent( + .notificationReceived( + worktreeID: "/tmp/repo/wt-1", + surfaceID: UUID(), + title: "Done", + body: "Build succeeded", + isViewed: false + ) + ) + ) + await store.finish() + + #expect( + received.value == [ + NotificationSoundConfiguration(sound: .custom, customSound: custom) + ] + ) + } + + @Test(.dependencies) + func inAppNotificationReceivesCustomSoundConfiguration() async { + let custom = CustomNotificationSound( + displayName: "My Bell", + fileName: "supacode-custom-notification-bell.wav" + ) + var globalSettings = GlobalSettings.default + globalSettings.systemNotificationsEnabled = false + globalSettings.notificationSound = .custom + globalSettings.customNotificationSound = custom + let received = LockIsolated<[NotificationSoundConfiguration]>([]) + let store = TestStore( + initialState: AppFeature.State( + settings: SettingsFeature.State(settings: globalSettings) + ) + ) { + AppFeature() + } withDependencies: { + $0.notificationSoundClient.play = { sound in + received.withValue { $0.append(sound) } + } + } + store.exhaustivity = .off + + await store.send( + .terminalEvent( + .notificationReceived( + worktreeID: "/tmp/repo/wt-1", + surfaceID: UUID(), + title: "Done", + body: "Build succeeded", + isViewed: false + ) + ) + ) + await store.finish() + + #expect( + received.value == [ + NotificationSoundConfiguration(sound: .custom, customSound: custom) + ] + ) + } + @Test(.dependencies) func notificationReceivedSkipsSystemNotificationWhenSurfaceIsViewed() async { var globalSettings = GlobalSettings.default globalSettings.systemNotificationsEnabled = true @@ -164,7 +253,7 @@ struct AppFeatureSystemNotificationTests { ) { AppFeature() } withDependencies: { - $0.systemNotificationClient.send = { _, _, _ in + $0.systemNotificationClient.send = { _, _, _, _ in sends.withValue { $0 += 1 } } $0.terminalClient.tabID = { _, _ in nil } @@ -201,7 +290,7 @@ struct AppFeatureSystemNotificationTests { ) { AppFeature() } withDependencies: { - $0.systemNotificationClient.send = { _, _, _ in + $0.systemNotificationClient.send = { _, _, _, _ in sends.withValue { $0 += 1 } } $0.terminalClient.tabID = { _, _ in nil } @@ -308,7 +397,7 @@ struct AppFeatureSystemNotificationTests { ) { AppFeature() } withDependencies: { - $0.systemNotificationClient.send = { _, _, _ in + $0.systemNotificationClient.send = { _, _, _, _ in sends.withValue { $0 += 1 } } $0.notificationSoundClient.play = { _ in @@ -349,7 +438,7 @@ struct AppFeatureSystemNotificationTests { $0.notificationSoundClient.play = { _ in plays.withValue { $0 += 1 } } - $0.systemNotificationClient.send = { _, _, _ in } + $0.systemNotificationClient.send = { _, _, _, _ in } $0.terminalClient.tabID = { _, _ in nil } } store.exhaustivity = .off @@ -374,7 +463,7 @@ struct AppFeatureSystemNotificationTests { var globalSettings = GlobalSettings.default globalSettings.systemNotificationsEnabled = false globalSettings.notificationSound = .funk - let plays = LockIsolated<[NotificationSound]>([]) + let plays = LockIsolated<[NotificationSoundConfiguration]>([]) let sends = LockIsolated(0) let store = TestStore( initialState: AppFeature.State( @@ -386,7 +475,7 @@ struct AppFeatureSystemNotificationTests { $0.notificationSoundClient.play = { sound in plays.withValue { $0.append(sound) } } - $0.systemNotificationClient.send = { _, _, _ in + $0.systemNotificationClient.send = { _, _, _, _ in sends.withValue { $0 += 1 } } } @@ -405,7 +494,11 @@ struct AppFeatureSystemNotificationTests { ) await store.finish() - #expect(plays.value == [.funk]) + #expect( + plays.value == [ + NotificationSoundConfiguration(sound: .funk, customSound: nil) + ] + ) #expect(sends.value == 0) } @@ -413,7 +506,7 @@ struct AppFeatureSystemNotificationTests { var globalSettings = GlobalSettings.default globalSettings.systemNotificationsEnabled = false globalSettings.notificationSound = .never - let plays = LockIsolated<[NotificationSound]>([]) + let plays = LockIsolated<[NotificationSoundConfiguration]>([]) let sends = LockIsolated(0) let store = TestStore( initialState: AppFeature.State( @@ -425,7 +518,7 @@ struct AppFeatureSystemNotificationTests { $0.notificationSoundClient.play = { sound in plays.withValue { $0.append(sound) } } - $0.systemNotificationClient.send = { _, _, _ in + $0.systemNotificationClient.send = { _, _, _, _ in sends.withValue { $0 += 1 } } } diff --git a/supacodeTests/CustomNotificationSoundTests.swift b/supacodeTests/CustomNotificationSoundTests.swift new file mode 100644 index 000000000..6a5226419 --- /dev/null +++ b/supacodeTests/CustomNotificationSoundTests.swift @@ -0,0 +1,365 @@ +import AVFAudio +import AppKit +import AudioToolbox +import Dependencies +import Foundation +import Testing + +@testable import SupacodeSettingsShared + +struct CustomNotificationSoundTests { + @Test func importErrorsHaveActionableMessages() { + #expect( + CustomNotificationSoundImportError.unsupportedFileType.errorDescription + == "Choose an AIFF, WAV, or CAF audio file." + ) + #expect( + CustomNotificationSoundImportError.unsupportedEncoding.errorDescription + == "The sound must use Linear PCM, IMA4, µLaw, or aLaw encoding." + ) + #expect( + CustomNotificationSoundImportError.unreadable.errorDescription + == "Supacode could not read this audio file." + ) + #expect( + CustomNotificationSoundImportError.empty.errorDescription + == "The selected audio file is empty." + ) + #expect( + CustomNotificationSoundImportError.tooLong.errorDescription + == "Notification sounds must be shorter than 30 seconds." + ) + } + + @Test func importsValidLinearPCMSound() async throws { + let root = try SoundTestFixture.makeDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appending(path: "My Bell.wav") + try SoundTestFixture.writeLinearPCM(to: source, duration: 1) + let soundsDirectory = root.appending(path: "Library/Sounds", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: soundsDirectory, withIntermediateDirectories: true) + + let client = CustomNotificationSoundClient.fileSystem( + soundsDirectory: soundsDirectory, + makeUUID: { UUID(uuidString: "00000000-0000-0000-0000-000000000001")! } + ) + let imported = try await client.importSound(source) + let importedURL = try #require( + ManagedNotificationSoundStorage.fileURL( + for: imported, + soundsDirectory: soundsDirectory + ) + ) + + #expect(imported.displayName == "My Bell") + #expect( + imported.fileName == "supacode-custom-notification-00000000-0000-0000-0000-000000000001.wav") + #expect(FileManager.default.fileExists(atPath: importedURL.path)) + #expect(try Data(contentsOf: importedURL) == Data(contentsOf: source)) + #expect(try AVAudioFile(forReading: importedURL).length > 0) + + let defaultUUIDClient = CustomNotificationSoundClient.fileSystem( + soundsDirectory: root.appending(path: "Default UUID Sounds", directoryHint: .isDirectory) + ) + let defaultUUIDImport = try await defaultUUIDClient.importSound(source) + #expect( + defaultUUIDImport.fileName.hasPrefix(ManagedNotificationSoundStorage.fileNamePrefix) + ) + } + + @Test func rejectsUnsupportedFileExtension() async throws { + let root = try SoundTestFixture.makeDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appending(path: "sound.mp3") + try Data("not audio".utf8).write(to: source) + let client = CustomNotificationSoundClient.fileSystem( + soundsDirectory: root.appending(path: "Sounds", directoryHint: .isDirectory) + ) + + await #expect(throws: CustomNotificationSoundImportError.unsupportedFileType) { + try await client.importSound(source) + } + } + + @Test func rejectsUnreadableAndUnsupportedEncodedFiles() async throws { + let root = try SoundTestFixture.makeDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appending(path: "broken.wav") + try Data("not audio".utf8).write(to: source) + let client = CustomNotificationSoundClient.fileSystem( + soundsDirectory: root.appending(path: "Sounds", directoryHint: .isDirectory) + ) + + await #expect(throws: CustomNotificationSoundImportError.unreadable) { + try await client.importSound(source) + } + + let unsupported = root.appending(path: "compressed.caf") + try SoundTestFixture.writeAAC(to: unsupported, duration: 1) + await #expect(throws: CustomNotificationSoundImportError.unsupportedEncoding) { + try await client.importSound(unsupported) + } + } + + @Test func removesCopiedFileWhenPostCopyValidationFails() async throws { + let root = try SoundTestFixture.makeDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appending(path: "sound.wav") + try SoundTestFixture.writeLinearPCM(to: source, duration: 1) + let soundsDirectory = root.appending(path: "Sounds", directoryHint: .isDirectory) + let validationCount = LockIsolated(0) + let client = CustomNotificationSoundClient.fileSystem( + soundsDirectory: soundsDirectory, + validate: { _ in + let count = validationCount.withValue { + $0 += 1 + return $0 + } + if count == 2 { + throw CustomNotificationSoundImportError.unreadable + } + } + ) + + await #expect(throws: CustomNotificationSoundImportError.unreadable) { + try await client.importSound(source) + } + #expect( + (try FileManager.default.contentsOfDirectory(atPath: soundsDirectory.path)).isEmpty + ) + } + + @Test func preservesValidationErrorWhenInvalidCopyCleanupFails() async throws { + let root = try SoundTestFixture.makeDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appending(path: "sound.wav") + try SoundTestFixture.writeLinearPCM(to: source, duration: 1) + let validationCount = LockIsolated(0) + let client = CustomNotificationSoundClient.fileSystem( + soundsDirectory: root.appending(path: "Sounds", directoryHint: .isDirectory), + validate: { _ in + if validationCount.withValue({ value in + value += 1 + return value + }) == 2 { + throw CustomNotificationSoundImportError.unreadable + } + }, + removeInvalidCopy: { _ in + throw CocoaError(.fileWriteNoPermission) + } + ) + + await #expect(throws: CustomNotificationSoundImportError.unreadable) { + try await client.importSound(source) + } + } + + @Test func rejectsEmptyAndThirtySecondSounds() async throws { + let root = try SoundTestFixture.makeDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let soundsDirectory = root.appending(path: "Sounds", directoryHint: .isDirectory) + let client = CustomNotificationSoundClient.fileSystem(soundsDirectory: soundsDirectory) + + let empty = root.appending(path: "empty.wav") + try SoundTestFixture.writeLinearPCM(to: empty, duration: 0) + await #expect(throws: CustomNotificationSoundImportError.empty) { + try await client.importSound(empty) + } + + let tooLong = root.appending(path: "long.wav") + try SoundTestFixture.writeLinearPCM(to: tooLong, duration: 30) + await #expect(throws: CustomNotificationSoundImportError.tooLong) { + try await client.importSound(tooLong) + } + } + + @Test func removesManagedSoundAndIgnoresAlreadyMissingFile() async throws { + let root = try SoundTestFixture.makeDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let soundsDirectory = root.appending(path: "Sounds", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: soundsDirectory, withIntermediateDirectories: true) + let sound = CustomNotificationSound( + displayName: "Bell", + fileName: "supacode-custom-notification-bell.caf" + ) + let soundURL = try #require( + ManagedNotificationSoundStorage.fileURL( + for: sound, + soundsDirectory: soundsDirectory + ) + ) + try Data("sound".utf8).write(to: soundURL) + let client = CustomNotificationSoundClient.fileSystem(soundsDirectory: soundsDirectory) + + try await client.removeSound(sound) + #expect(!FileManager.default.fileExists(atPath: soundURL.path)) + try await client.removeSound(sound) + + await #expect(throws: CustomNotificationSoundImportError.unreadable) { + try await client.removeSound( + CustomNotificationSound(displayName: "Unsafe", fileName: "../unsafe.wav") + ) + } + } + + @Test func systemNotificationSoundResolutionCoversEveryFallback() throws { + let root = try SoundTestFixture.makeDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let custom = CustomNotificationSound( + displayName: "Bell", + fileName: "supacode-custom-notification-bell.wav" + ) + let customURL = try #require( + ManagedNotificationSoundStorage.fileURL(for: custom, soundsDirectory: root) + ) + try Data("sound".utf8).write(to: customURL) + + #expect( + SystemNotificationSoundResolver.resolve( + NotificationSoundConfiguration(sound: .custom, customSound: custom), + soundsDirectory: root + ) == .named(custom.fileName) + ) + try FileManager.default.removeItem(at: customURL) + #expect( + SystemNotificationSoundResolver.resolve( + NotificationSoundConfiguration(sound: .custom, customSound: custom), + soundsDirectory: root + ) == .default + ) + #expect( + SystemNotificationSoundResolver.resolve( + NotificationSoundConfiguration(sound: .never, customSound: nil), + soundsDirectory: root + ) == .none + ) + #expect( + SystemNotificationSoundResolver.resolve( + NotificationSoundConfiguration(sound: .supacodeClassic, customSound: nil), + soundsDirectory: root + ) == .named("notification.wav") + ) + #expect( + SystemNotificationSoundResolver.resolve( + NotificationSoundConfiguration(sound: .hero, customSound: nil), + soundsDirectory: root + ) == .default + ) + } + + @Test func inAppResolverLoadsManagedSoundAndRejectsMissingOrCorruptFiles() throws { + let root = try SoundTestFixture.makeDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let custom = CustomNotificationSound( + displayName: "Bell", + fileName: "supacode-custom-notification-bell.wav" + ) + let customURL = try #require( + ManagedNotificationSoundStorage.fileURL(for: custom, soundsDirectory: root) + ) + try SoundTestFixture.writeLinearPCM(to: customURL, duration: 1) + let configuration = NotificationSoundConfiguration(sound: .custom, customSound: custom) + + let resolved = try #require( + NotificationSoundResolver.make(configuration, soundsDirectory: root) + ) + #expect(resolved.duration > 0) + + try Data("corrupt".utf8).write(to: customURL) + #expect(NotificationSoundResolver.make(configuration, soundsDirectory: root) == nil) + try FileManager.default.removeItem(at: customURL) + #expect(NotificationSoundResolver.make(configuration, soundsDirectory: root) == nil) + } + + @Test func rejectsUnsafeManagedFileNames() { + let unsafe = CustomNotificationSound( + displayName: "Unsafe", + fileName: "../supacode-custom-notification-unsafe.wav" + ) + #expect(ManagedNotificationSoundStorage.fileURL(for: unsafe) == nil) + #expect( + ManagedNotificationSoundStorage.fileURL( + for: CustomNotificationSound(displayName: "Foreign", fileName: "foreign.wav") + ) == nil + ) + #expect( + ManagedNotificationSoundStorage.fileURL( + for: CustomNotificationSound( + displayName: "MP3", + fileName: "supacode-custom-notification-sound.mp3" + ) + ) == nil + ) + #expect( + ManagedNotificationSoundStorage.defaultSoundsDirectory.lastPathComponent == "Sounds" + ) + } + + @Test func dependencyTestValueFailsImportAndIgnoresRemoval() async throws { + await #expect(throws: CustomNotificationSoundImportError.unreadable) { + try await CustomNotificationSoundClient.testValue.importSound( + URL(filePath: "/tmp/sound.wav") + ) + } + try await CustomNotificationSoundClient.testValue.removeSound( + CustomNotificationSound( + displayName: "Bell", + fileName: "supacode-custom-notification-bell.wav" + ) + ) + } +} + +private enum SoundTestFixture { + static func makeDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appending(path: "supacode-sound-tests-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + static func writeLinearPCM(to url: URL, duration: TimeInterval) throws { + let sampleRate = 8_000.0 + let settings: [String: Any] = [ + AVFormatIDKey: kAudioFormatLinearPCM, + AVSampleRateKey: sampleRate, + AVNumberOfChannelsKey: 1, + AVLinearPCMBitDepthKey: 16, + AVLinearPCMIsFloatKey: false, + AVLinearPCMIsBigEndianKey: false, + ] + let file = try AVAudioFile(forWriting: url, settings: settings) + let frameCount = AVAudioFrameCount(sampleRate * duration) + guard frameCount > 0 else { return } + let buffer = try #require( + AVAudioPCMBuffer( + pcmFormat: file.processingFormat, + frameCapacity: frameCount + ) + ) + buffer.frameLength = frameCount + try file.write(from: buffer) + } + + static func writeAAC(to url: URL, duration: TimeInterval) throws { + let sampleRate = 44_100.0 + let file = try AVAudioFile( + forWriting: url, + settings: [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: sampleRate, + AVNumberOfChannelsKey: 1, + ] + ) + let frameCount = AVAudioFrameCount(sampleRate * duration) + let buffer = try #require( + AVAudioPCMBuffer( + pcmFormat: file.processingFormat, + frameCapacity: frameCount + ) + ) + buffer.frameLength = frameCount + try file.write(from: buffer) + } +} diff --git a/supacodeTests/NotificationSoundTests.swift b/supacodeTests/NotificationSoundTests.swift index f4a199e76..dccc80ffa 100644 --- a/supacodeTests/NotificationSoundTests.swift +++ b/supacodeTests/NotificationSoundTests.swift @@ -1,38 +1,183 @@ +import AppKit import Foundation import Testing @testable import SupacodeSettingsShared struct NotificationSoundTests { + @MainActor + @Test func cacheCoversBuiltInCustomAndFallbackPathsWithoutPlayingAudio() throws { + NotificationSoundCache.sounds.removeAll() + defer { NotificationSoundCache.sounds.removeAll() } + let madeSound = try #require(NSSound(named: "Funk")) + let builtIn = NotificationSoundConfiguration(sound: .funk, customSound: nil) + var builtInMakeCount = 0 + + #expect( + NotificationSoundCache.resolve(builtIn) { _ in + builtInMakeCount += 1 + return madeSound + } === madeSound + ) + #expect( + NotificationSoundCache.resolve(builtIn) { _ in + builtInMakeCount += 1 + return madeSound + } === madeSound + ) + #expect(builtInMakeCount == 1) + + let builtInWithCustomMetadata = NotificationSoundConfiguration( + sound: .funk, + customSound: CustomNotificationSound( + displayName: "Unused", + fileName: "supacode-custom-notification-unused.wav" + ) + ) + #expect( + NotificationSoundCache.resolve(builtInWithCustomMetadata) { _ in + builtInMakeCount += 1 + return madeSound + } === madeSound + ) + #expect(builtInMakeCount == 1) + + let custom = NotificationSoundConfiguration( + sound: .custom, + customSound: CustomNotificationSound( + displayName: "Bell", + fileName: "supacode-custom-notification-bell.wav" + ) + ) + var customMakeCount = 0 + #expect( + NotificationSoundCache.resolve(custom) { _ in + customMakeCount += 1 + return madeSound + } === madeSound + ) + #expect( + NotificationSoundCache.resolve(custom) { _ in + customMakeCount += 1 + return madeSound + } === madeSound + ) + #expect(customMakeCount == 2) + + var fallbackConfigurations: [NotificationSoundConfiguration] = [] + #expect( + NotificationSoundCache.resolve(custom) { configuration in + fallbackConfigurations.append(configuration) + return configuration.sound == .hero ? madeSound : nil + } === madeSound + ) + #expect( + fallbackConfigurations == [ + custom, + NotificationSoundConfiguration(sound: .hero, customSound: nil), + ] + ) + + NotificationSoundClient.live.play( + NotificationSoundConfiguration(sound: .never, customSound: nil) + ) + NotificationSoundClient.testValue.play( + NotificationSoundConfiguration(sound: .never, customSound: nil) + ) + } + @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")) + #expect(NotificationSound.never.source(customSound: nil) == nil) + #expect(NotificationSound.funk.source(customSound: nil) == .system(name: "Funk")) + #expect(NotificationSound.tink.source(customSound: nil) == .system(name: "Tink")) + #expect( + NotificationSound.supacodeClassic.source(customSound: nil) + == .bundled(resource: "notification", withExtension: "wav") + ) + let custom = CustomNotificationSound( + displayName: "Bell", fileName: "supacode-custom-notification-id.wav") + #expect( + NotificationSound.custom.source(customSound: custom) == .custom(fileName: custom.fileName)) + #expect(NotificationSound.custom.source(customSound: nil) == nil) + } + + @MainActor + @Test func bundledSoundResolvesFromAppResources() { + #expect( + NotificationSoundResolver.make( + NotificationSoundConfiguration(sound: .supacodeClassic, customSound: nil) + ) != nil + ) + } + + @MainActor + @Test func bundledSoundResolutionHandlesMissingAndUnreadableResources() throws { + let configuration = NotificationSoundConfiguration( + sound: .supacodeClassic, + customSound: nil + ) + #expect( + NotificationSoundResolver.make( + configuration, + bundledSoundURL: { _, _ in nil } + ) == nil + ) + + let resourceURL = URL(filePath: "/tmp/unreadable-notification.wav") + var requestedResource: (String, String)? + var requestedSound: (URL, Bool)? + #expect( + NotificationSoundResolver.make( + configuration, + bundledSoundURL: { resource, fileExtension in + requestedResource = (resource, fileExtension) + return resourceURL + }, + makeSound: { url, byReference in + requestedSound = (url, byReference) + return nil + } + ) == nil + ) + #expect(requestedResource?.0 == "notification") + #expect(requestedResource?.1 == "wav") + #expect(requestedSound?.0 == resourceURL) + #expect(requestedSound?.1 == true) } @Test func displayNamesAreUnambiguous() { #expect(NotificationSound.never.displayName == "Never") #expect(NotificationSound.supacodeClassic.displayName == "Supacode Classic") + #expect(NotificationSound.custom.displayName == "Custom") #expect(NotificationSound.funk.displayName == "Funk") } @Test func pickerGroupsCoverEveryCaseWithoutOverlap() { - let grouped = [NotificationSound.never] + NotificationSound.systemCases + [.supacodeClassic] + let grouped = + [NotificationSound.never] + NotificationSound.systemCases + [.supacodeClassic, .custom] #expect(Set(grouped) == Set(NotificationSound.allCases)) #expect(grouped.count == NotificationSound.allCases.count) } + @Test func systemNotificationDeliveryPolicyMatchesSupportedSounds() { + #expect(NotificationSound.supacodeClassic.usesSelectedSoundForSystemNotifications) + #expect(NotificationSound.custom.usesSelectedSoundForSystemNotifications) + #expect(!NotificationSound.hero.usesSelectedSoundForSystemNotifications) + #expect(!NotificationSound.never.usesSelectedSoundForSystemNotifications) + } + // 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(NotificationSound.custom.rawValue == "custom") #expect( Set(NotificationSound.allCases.map(\.rawValue)) == [ "never", "basso", "blow", "bottle", "frog", "funk", "glass", "hero", "morse", "ping", "pop", "purr", "sosumi", "submarine", "tink", "supacodeClassic", + "custom", ] ) // The persisted JSON string must still decode to the case. diff --git a/supacodeTests/SettingsFeatureTests.swift b/supacodeTests/SettingsFeatureTests.swift index ee7fabd0e..d7df1751d 100644 --- a/supacodeTests/SettingsFeatureTests.swift +++ b/supacodeTests/SettingsFeatureTests.swift @@ -273,12 +273,12 @@ struct SettingsFeatureTests { @Shared(.settingsFile) var settingsFile $settingsFile.withLock { $0.global = .default } - let played = LockIsolated<[NotificationSound]>([]) + let played = LockIsolated<[NotificationSoundConfiguration]>([]) let store = TestStore(initialState: SettingsFeature.State()) { SettingsFeature() } withDependencies: { - $0[NotificationSoundClient.self].play = { sound in - played.withValue { $0.append(sound) } + $0[NotificationSoundClient.self].play = { configuration in + played.withValue { $0.append(configuration) } } } @@ -288,21 +288,25 @@ struct SettingsFeatureTests { await store.receive(\.delegate.settingsChanged) // Picking a sound auditions it through the in-app player. - #expect(played.value == [.glass]) + #expect( + played.value == [ + NotificationSoundConfiguration(sound: .glass, customSound: nil) + ] + ) } - @Test(.dependencies) func doesNotPreviewWhenSystemNotificationsEnabled() async { + @Test(.dependencies) func doesNotPreviewSystemSoundWhenSystemNotificationsEnabled() async { var settings = GlobalSettings.default settings.systemNotificationsEnabled = true @Shared(.settingsFile) var settingsFile $settingsFile.withLock { $0.global = settings } - let played = LockIsolated<[NotificationSound]>([]) + let played = LockIsolated<[NotificationSoundConfiguration]>([]) let store = TestStore(initialState: SettingsFeature.State(settings: settings)) { SettingsFeature() } withDependencies: { - $0[NotificationSoundClient.self].play = { sound in - played.withValue { $0.append(sound) } + $0[NotificationSoundClient.self].play = { configuration in + played.withValue { $0.append(configuration) } } } @@ -311,7 +315,6 @@ struct SettingsFeatureTests { } await store.receive(\.delegate.settingsChanged) - // With system notifications on the in-app path is unused, so no preview. #expect(played.value.isEmpty) } @@ -319,7 +322,7 @@ struct SettingsFeatureTests { @Shared(.settingsFile) var settingsFile $settingsFile.withLock { $0.global = .default } - let played = LockIsolated<[NotificationSound]>([]) + let played = LockIsolated<[NotificationSoundConfiguration]>([]) let store = TestStore(initialState: SettingsFeature.State()) { SettingsFeature() } withDependencies: { @@ -337,6 +340,304 @@ struct SettingsFeatureTests { #expect(played.value.isEmpty) } + @Test(.dependencies) func importingCustomSoundPersistsSelectsAndPreviewsIt() async { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = .default } + let imported = CustomNotificationSound( + displayName: "My Bell", + fileName: "supacode-custom-notification-bell.wav" + ) + let played = LockIsolated<[NotificationSoundConfiguration]>([]) + let store = TestStore(initialState: SettingsFeature.State()) { + SettingsFeature() + } withDependencies: { + $0.customNotificationSoundClient.importSound = { _ in imported } + $0.notificationSoundClient.play = { configuration in + played.withValue { $0.append(configuration) } + } + } + + await store.send( + .customNotificationSoundSelected(URL(filePath: "/tmp/My Bell.wav")) + ) { + $0.isManagingCustomNotificationSound = true + } + await store.receive(\.customNotificationSoundImported) { + $0.isManagingCustomNotificationSound = false + $0.customNotificationSound = imported + $0.notificationSound = .custom + } + await store.receive(\.delegate.settingsChanged) + + #expect(settingsFile.global.notificationSound == .custom) + #expect(settingsFile.global.customNotificationSound == imported) + #expect( + played.value == [ + NotificationSoundConfiguration(sound: .custom, customSound: imported) + ] + ) + } + + @Test(.dependencies) func replacingCustomSoundPersistsBeforeRemovingPreviousFile() async { + let previous = CustomNotificationSound( + displayName: "Previous", + fileName: "supacode-custom-notification-previous.wav" + ) + let imported = CustomNotificationSound( + displayName: "Replacement", + fileName: "supacode-custom-notification-replacement.wav" + ) + var settings = GlobalSettings.default + settings.notificationSound = .custom + settings.customNotificationSound = previous + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = settings } + let removed = LockIsolated<[CustomNotificationSound]>([]) + let store = TestStore(initialState: SettingsFeature.State(settings: settings)) { + SettingsFeature() + } withDependencies: { + $0.customNotificationSoundClient.importSound = { _ in imported } + $0.customNotificationSoundClient.removeSound = { sound in + @Shared(.settingsFile) var persistedSettings + #expect(persistedSettings.global.customNotificationSound == imported) + removed.withValue { $0.append(sound) } + } + $0.notificationSoundClient.play = { _ in } + } + + await store.send( + .customNotificationSoundSelected(URL(filePath: "/tmp/Replacement.wav")) + ) { + $0.isManagingCustomNotificationSound = true + } + await store.receive(\.customNotificationSoundImported) { + $0.isManagingCustomNotificationSound = false + $0.customNotificationSound = imported + } + await store.receive(\.delegate.settingsChanged) + await store.finish() + + #expect(removed.value == [previous]) + #expect(settingsFile.global.customNotificationSound == imported) + } + + @Test(.dependencies) func replacementKeepsNewSoundWhenPreviousFileCleanupFails() async { + let previous = CustomNotificationSound( + displayName: "Previous", + fileName: "supacode-custom-notification-previous.wav" + ) + let imported = CustomNotificationSound( + displayName: "Replacement", + fileName: "supacode-custom-notification-replacement.wav" + ) + var settings = GlobalSettings.default + settings.notificationSound = .custom + settings.customNotificationSound = previous + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = settings } + let store = TestStore(initialState: SettingsFeature.State(settings: settings)) { + SettingsFeature() + } withDependencies: { + $0.customNotificationSoundClient.importSound = { _ in imported } + $0.customNotificationSoundClient.removeSound = { _ in + throw CocoaError(.fileWriteNoPermission) + } + $0.notificationSoundClient.play = { _ in } + } + store.exhaustivity = .off + + await store.send( + .customNotificationSoundSelected(URL(filePath: "/tmp/Replacement.wav")) + ) + await store.receive(\.customNotificationSoundImported) + await store.finish() + + #expect(store.state.customNotificationSound == imported) + #expect(settingsFile.global.customNotificationSound == imported) + } + + @Test(.dependencies) func removingSelectedCustomSoundDeletesItAndRestoresDefault() async { + let custom = CustomNotificationSound( + displayName: "My Bell", + fileName: "supacode-custom-notification-bell.wav" + ) + var settings = GlobalSettings.default + settings.notificationSound = .custom + settings.customNotificationSound = custom + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = settings } + let removed = LockIsolated<[CustomNotificationSound]>([]) + let store = TestStore(initialState: SettingsFeature.State(settings: settings)) { + SettingsFeature() + } withDependencies: { + $0.customNotificationSoundClient.removeSound = { sound in + @Shared(.settingsFile) var persistedSettings + #expect(persistedSettings.global.customNotificationSound == nil) + #expect( + persistedSettings.global.notificationSound + == GlobalSettings.default.notificationSound + ) + removed.withValue { $0.append(sound) } + } + } + store.exhaustivity = .off + + await store.send(.removeCustomNotificationSoundTapped) + await store.send(.alert(.presented(.confirmRemoveCustomNotificationSound))) { + $0.isManagingCustomNotificationSound = true + } + await store.receive(\.customNotificationSoundRemoved) { + $0.isManagingCustomNotificationSound = false + $0.customNotificationSound = nil + $0.notificationSound = GlobalSettings.default.notificationSound + } + await store.finish() + + #expect(removed.value == [custom]) + #expect(settingsFile.global.customNotificationSound == nil) + #expect( + settingsFile.global.notificationSound + == GlobalSettings.default.notificationSound + ) + } + + @Test(.dependencies) func failedCustomSoundImportPreservesCurrentSelection() async { + let current = CustomNotificationSound( + displayName: "Current", + fileName: "supacode-custom-notification-current.wav" + ) + var settings = GlobalSettings.default + settings.notificationSound = .custom + settings.customNotificationSound = current + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = settings } + let store = TestStore(initialState: SettingsFeature.State(settings: settings)) { + SettingsFeature() + } withDependencies: { + $0.customNotificationSoundClient.importSound = { _ in + throw CustomNotificationSoundImportError.unsupportedEncoding + } + } + store.exhaustivity = .off + + await store.send( + .customNotificationSoundSelected(URL(filePath: "/tmp/Unsupported.caf")) + ) + await store.receive(\.customNotificationSoundImported) + await store.receive(\.customNotificationSoundImportFailed) + await store.finish() + + #expect(store.state.notificationSound == .custom) + #expect(store.state.customNotificationSound == current) + #expect(!store.state.isManagingCustomNotificationSound) + #expect(store.state.alert != nil) + #expect(settingsFile.global.customNotificationSound == current) + } + + @Test(.dependencies) func ignoresCustomSoundSelectionWhileImportIsRunning() async { + var state = SettingsFeature.State() + state.isManagingCustomNotificationSound = true + let imports = LockIsolated(0) + let store = TestStore(initialState: state) { + SettingsFeature() + } withDependencies: { + $0.customNotificationSoundClient.importSound = { _ in + imports.withValue { $0 += 1 } + throw CustomNotificationSoundImportError.unreadable + } + } + + await store.send( + .customNotificationSoundSelected(URL(filePath: "/tmp/Second.wav")) + ) + await store.send(.removeCustomNotificationSoundTapped) + + #expect(imports.value == 0) + #expect(store.state.isManagingCustomNotificationSound) + #expect(store.state.alert == nil) + } + + @Test(.dependencies) func failedCustomSoundRemovalPreservesFileMetadata() async { + let custom = CustomNotificationSound( + displayName: "Current", + fileName: "supacode-custom-notification-current.wav" + ) + var settings = GlobalSettings.default + settings.notificationSound = .custom + settings.customNotificationSound = custom + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = settings } + let store = TestStore(initialState: SettingsFeature.State(settings: settings)) { + SettingsFeature() + } withDependencies: { + $0.customNotificationSoundClient.removeSound = { _ in + @Shared(.settingsFile) var persistedSettings + #expect(persistedSettings.global.customNotificationSound == nil) + throw CocoaError(.fileWriteNoPermission) + } + } + store.exhaustivity = .off + + await store.send(.removeCustomNotificationSoundTapped) + await store.send(.alert(.presented(.confirmRemoveCustomNotificationSound))) { + $0.isManagingCustomNotificationSound = true + } + await store.receive(\.customNotificationSoundRemoved) { + $0.isManagingCustomNotificationSound = false + } + await store.finish() + + #expect(store.state.notificationSound == .custom) + #expect(store.state.customNotificationSound == custom) + #expect(store.state.alert != nil) + #expect(settingsFile.global.customNotificationSound == custom) + } + + @Test(.dependencies) func successfulRemovalClearsMetadataAfterInterveningSettingsWrite() async { + let custom = CustomNotificationSound( + displayName: "My Bell", + fileName: "supacode-custom-notification-bell.wav" + ) + var settings = GlobalSettings.default + settings.notificationSound = .custom + settings.customNotificationSound = custom + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = settings } + let (removalStarted, removalStartedContinuation) = AsyncStream.makeStream() + let (removalGate, removalGateContinuation) = AsyncStream.makeStream() + let store = TestStore(initialState: SettingsFeature.State(settings: settings)) { + SettingsFeature() + } withDependencies: { + $0.customNotificationSoundClient.removeSound = { _ in + removalStartedContinuation.yield() + for await _ in removalGate { + break + } + } + } + store.exhaustivity = .off + var removalStartedIterator = removalStarted.makeAsyncIterator() + + await store.send(.removeCustomNotificationSoundTapped) + await store.send(.alert(.presented(.confirmRemoveCustomNotificationSound))) { + $0.isManagingCustomNotificationSound = true + } + _ = await removalStartedIterator.next() + await store.send(.binding(.set(\.muteNotificationsForActiveSurface, true))) { + $0.muteNotificationsForActiveSurface = true + } + removalGateContinuation.yield() + await store.receive(\.customNotificationSoundRemoved) { + $0.isManagingCustomNotificationSound = false + $0.customNotificationSound = nil + $0.notificationSound = GlobalSettings.default.notificationSound + } + await store.finish() + + #expect(settingsFile.global.customNotificationSound == nil) + #expect(settingsFile.global.muteNotificationsForActiveSurface) + } + @Test(.dependencies) func enablingDisabledByDefaultShortcutBindsItsDefault() async { let initialSettings = GlobalSettings.default @Shared(.settingsFile) var settingsFile diff --git a/supacodeTests/SettingsFilePersistenceTests.swift b/supacodeTests/SettingsFilePersistenceTests.swift index e199c7bae..97f848138 100644 --- a/supacodeTests/SettingsFilePersistenceTests.swift +++ b/supacodeTests/SettingsFilePersistenceTests.swift @@ -288,6 +288,63 @@ struct SettingsFilePersistenceTests { #expect(reloaded.global.notificationSound == .submarine) } + @Test(.dependencies) func roundTripsCustomNotificationSound() throws { + let storage = SettingsTestStorage() + let custom = CustomNotificationSound( + displayName: "My Bell", + fileName: "supacode-custom-notification-bell.wav" + ) + + withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + $settings.withLock { + $0.global.notificationSound = .custom + $0.global.customNotificationSound = custom + } + } + + let reloaded: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var reloaded: SettingsFile + return reloaded + } + + #expect(reloaded.global.notificationSound == .custom) + #expect(reloaded.global.customNotificationSound == custom) + } + + @Test(.dependencies) func customSelectionWithoutMetadataFallsBackToDefault() throws { + var global = GlobalSettings.default + global.systemNotificationsEnabled = true + let encoded = try JSONEncoder().encode(global) + var globalDict = try #require( + try JSONSerialization.jsonObject(with: encoded) as? [String: Any] + ) + globalDict["notificationSound"] = "custom" + globalDict["customNotificationSound"] = [ + "displayName": 42, + "fileName": ["invalid"], + ] + 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 + } + + #expect(settings.global.notificationSound == GlobalSettings.default.notificationSound) + #expect(settings.global.customNotificationSound == nil) + #expect(settings.global.systemNotificationsEnabled) + } + @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.