From 80bbfdf20670143f69dbea9174e68ebad816956b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:18:07 -0800 Subject: [PATCH] Add custom file support for notification sounds (#869) * Add custom-file notification sound option * Add notification permission controls and test action * Allow notification enable retries from settings * Add notification permission flow debug logging --- Sources/AppDelegate.swift | 6 +- Sources/TerminalNotificationStore.swift | 411 ++++++++++++++++-- Sources/cmuxApp.swift | 183 +++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 183 ++++++++ 4 files changed, 739 insertions(+), 44 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a6f996fb..ab3f5579 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1791,7 +1791,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent PostHogAnalytics.shared.trackHourlyActive(reason: "didBecomeActive") } - guard let tabManager, let notificationStore else { return } + guard let notificationStore else { return } + notificationStore.handleApplicationDidBecomeActive() + guard let tabManager else { return } guard let tabId = tabManager.selectedTabId else { return } let surfaceId = tabManager.focusedSurfaceId(for: tabId) guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return } @@ -7619,7 +7621,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { var options: UNNotificationPresentationOptions = [.banner, .list] - if !NotificationSoundSettings.isSilent() { + if notification.request.content.sound != nil { options.insert(.sound) } completionHandler(options) diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 06d8a97d..4d5ba1b6 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -1,6 +1,7 @@ import AppKit import Foundation import UserNotifications +import Bonsplit // UNUserNotificationCenter.removeDeliveredNotifications(withIdentifiers:) and // removePendingNotificationRequests(withIdentifiers:) perform synchronous XPC to @@ -31,6 +32,10 @@ extension UNUserNotificationCenter { enum NotificationSoundSettings { static let key = "notificationSound" static let defaultValue = "default" + static let customFileValue = "custom_file" + static let customFilePathKey = "notificationSoundCustomFilePath" + static let defaultCustomFilePath = "" + private static let stagedCustomSoundBaseName = "cmux-custom-notification-sound" static let customCommandKey = "notificationCustomCommand" static let defaultCustomCommand = "" @@ -50,6 +55,7 @@ enum NotificationSoundSettings { ("Sosumi", "Sosumi"), ("Submarine", "Submarine"), ("Tink", "Tink"), + ("Custom File...", customFileValue), ("None", "none"), ] @@ -60,26 +66,157 @@ enum NotificationSoundSettings { return .default case "none": return nil + case customFileValue: + guard let customSoundName = stagedCustomSoundName(defaults: defaults) else { + return nil + } + return UNNotificationSound(named: UNNotificationSoundName(rawValue: customSoundName)) default: return UNNotificationSound(named: UNNotificationSoundName(rawValue: value)) } } + static func usesSystemSound(defaults: UserDefaults = .standard) -> Bool { + let value = defaults.string(forKey: key) ?? defaultValue + switch value { + case "none": + return false + case customFileValue: + return customFileURL(defaults: defaults) != nil + default: + return true + } + } + static func isSilent(defaults: UserDefaults = .standard) -> Bool { return (defaults.string(forKey: key) ?? defaultValue) == "none" } - static func previewSound(value: String) { + static func isCustomFileSelected(defaults: UserDefaults = .standard) -> Bool { + (defaults.string(forKey: key) ?? defaultValue) == customFileValue + } + + static func stagedCustomSoundName(defaults: UserDefaults = .standard) -> String? { + guard let sourceURL = customFileURL(defaults: defaults) else { return nil } + let sourceExtension = sourceURL.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines) + guard !sourceExtension.isEmpty else { + NSLog("Notification custom sound requires a file extension: \(sourceURL.path)") + return nil + } + + let destinationDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Sounds", isDirectory: true) + let destinationFileName = "\(stagedCustomSoundBaseName).\(sourceExtension.lowercased())" + let destinationURL = destinationDirectory.appendingPathComponent(destinationFileName, isDirectory: false) + let fileManager = FileManager.default + + do { + try fileManager.createDirectory(at: destinationDirectory, withIntermediateDirectories: true) + try copyStagedSoundIfNeeded(from: sourceURL, to: destinationURL, fileManager: fileManager) + try cleanupStaleStagedSoundFiles( + in: destinationDirectory, + keeping: destinationFileName, + preservingSourceURL: sourceURL, + fileManager: fileManager + ) + return destinationFileName + } catch { + NSLog("Failed to stage custom notification sound: \(error)") + return nil + } + } + + static func customFileURL(defaults: UserDefaults = .standard) -> URL? { + guard let path = normalizedCustomFilePath(defaults.string(forKey: customFilePathKey) ?? defaultCustomFilePath) else { + return nil + } + return URL(fileURLWithPath: (path as NSString).expandingTildeInPath) + } + + static func playCustomFileSound(defaults: UserDefaults = .standard) { + guard let url = customFileURL(defaults: defaults) else { return } + playSoundFile(at: url) + } + + static func playCustomFileSound(path: String) { + guard let normalizedPath = normalizedCustomFilePath(path) else { return } + let url = URL(fileURLWithPath: (normalizedPath as NSString).expandingTildeInPath) + playSoundFile(at: url) + } + + static func previewSound(value: String, defaults: UserDefaults = .standard) { switch value { case "default": NSSound.beep() case "none": break + case customFileValue: + playCustomFileSound(defaults: defaults) default: NSSound(named: NSSound.Name(value))?.play() } } + private static func normalizedCustomFilePath(_ rawPath: String) -> String? { + let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + } + + private static func playSoundFile(at url: URL) { + DispatchQueue.main.async { + guard let sound = NSSound(contentsOf: url, byReference: false) else { + NSLog("Notification custom sound failed to load from path: \(url.path)") + return + } + sound.play() + } + } + + private static func cleanupStaleStagedSoundFiles( + in directoryURL: URL, + keeping fileName: String, + preservingSourceURL: URL, + fileManager: FileManager + ) throws { + let prefix = "\(stagedCustomSoundBaseName)." + let normalizedSource = preservingSourceURL.standardizedFileURL + for fileNameCandidate in try fileManager.contentsOfDirectory(atPath: directoryURL.path) { + guard fileNameCandidate.hasPrefix(prefix), fileNameCandidate != fileName else { continue } + let staleURL = directoryURL.appendingPathComponent(fileNameCandidate, isDirectory: false) + if staleURL.standardizedFileURL == normalizedSource { + continue + } + try? fileManager.removeItem(at: staleURL) + } + } + + private static func copyStagedSoundIfNeeded( + from sourceURL: URL, + to destinationURL: URL, + fileManager: FileManager + ) throws { + let normalizedSource = sourceURL.standardizedFileURL + let normalizedDestination = destinationURL.standardizedFileURL + guard normalizedSource != normalizedDestination else { return } + + if fileManager.fileExists(atPath: normalizedDestination.path) { + let sourceAttributes = try fileManager.attributesOfItem(atPath: normalizedSource.path) + let destinationAttributes = try fileManager.attributesOfItem(atPath: normalizedDestination.path) + let sourceSize = sourceAttributes[.size] as? NSNumber + let destinationSize = destinationAttributes[.size] as? NSNumber + let sourceDate = sourceAttributes[.modificationDate] as? Date + let destinationDate = destinationAttributes[.modificationDate] as? Date + if sourceSize == destinationSize && sourceDate == destinationDate { + return + } + try fileManager.removeItem(at: normalizedDestination) + } + + try fileManager.copyItem(at: normalizedSource, to: normalizedDestination) + } + private static let customCommandQueue = DispatchQueue( label: "com.cmuxterm.notification-custom-command", qos: .utility @@ -165,6 +302,39 @@ enum AppFocusState { } } +enum NotificationAuthorizationState: Equatable { + case unknown + case notDetermined + case authorized + case denied + case provisional + case ephemeral + + var statusLabel: String { + switch self { + case .unknown, .notDetermined: + return "Not Requested" + case .authorized: + return "Allowed" + case .denied: + return "Denied" + case .provisional: + return "Deliver Quietly" + case .ephemeral: + return "Temporary" + } + } + + var allowsDelivery: Bool { + switch self { + case .authorized, .provisional, .ephemeral: + return true + case .unknown, .notDetermined, .denied: + return false + } + } +} + struct TerminalNotification: Identifiable, Hashable { let id: UUID let tabId: UUID @@ -195,6 +365,11 @@ final class TerminalNotificationStore: ObservableObject { static let categoryIdentifier = "com.cmuxterm.app.userNotification" static let actionShowIdentifier = "com.cmuxterm.app.userNotification.show" + private enum AuthorizationRequestOrigin: String { + case notificationDelivery = "notification_delivery" + case settingsButton = "settings_button" + case settingsTest = "settings_test" + } @Published private(set) var notifications: [TerminalNotification] = [] { didSet { @@ -202,9 +377,11 @@ final class TerminalNotificationStore: ObservableObject { refreshDockBadge() } } + @Published private(set) var authorizationState: NotificationAuthorizationState = .unknown private let center = UNUserNotificationCenter.current() - private var hasRequestedAuthorization = false + private var hasRequestedAutomaticAuthorization = false + private var hasDeferredAuthorizationRequest = false private var hasPromptedForSettings = false private var userDefaultsObserver: NSObjectProtocol? private let settingsPromptWindowRetryDelay: TimeInterval = 0.5 @@ -237,6 +414,7 @@ final class TerminalNotificationStore: ObservableObject { self?.refreshDockBadge() } refreshDockBadge() + refreshAuthorizationStatus() } deinit { @@ -268,6 +446,98 @@ final class TerminalNotificationStore: ObservableObject { indexes.unreadCount } + private func logAuthorization(_ message: String) { +#if DEBUG + dlog("notification.auth \(message)") +#endif + NSLog("notification.auth %@", message) + } + + private static func authorizationStatusLabel(_ status: UNAuthorizationStatus) -> String { + switch status { + case .notDetermined: + return "notDetermined" + case .denied: + return "denied" + case .authorized: + return "authorized" + case .provisional: + return "provisional" + case .ephemeral: + return "ephemeral" + @unknown default: + return "unknown(\(status.rawValue))" + } + } + + func refreshAuthorizationStatus() { + center.getNotificationSettings { [weak self] settings in + DispatchQueue.main.async { + guard let self else { return } + self.authorizationState = Self.authorizationState(from: settings.authorizationStatus) + self.logAuthorization( + "refresh status=\(Self.authorizationStatusLabel(settings.authorizationStatus)) mapped=\(self.authorizationState.statusLabel)" + ) + } + } + } + + func requestAuthorizationFromSettings() { + logAuthorization("settings request tapped state=\(authorizationState.statusLabel)") + ensureAuthorization(origin: .settingsButton) { _ in } + } + + func openNotificationSettings() { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") else { + return + } + logAuthorization("open settings url=\(url.absoluteString)") + notificationSettingsURLOpener(url) + } + + func sendSettingsTestNotification() { + logAuthorization("settings test tapped state=\(authorizationState.statusLabel)") + ensureAuthorization(origin: .settingsTest) { [weak self] authorized in + guard let self, authorized else { return } + + let content = UNMutableNotificationContent() + content.title = "cmux test notification" + content.body = "Desktop notifications are enabled." + content.sound = NotificationSoundSettings.sound() + content.categoryIdentifier = Self.categoryIdentifier + + let request = UNNotificationRequest( + identifier: "cmux.settings.test.\(UUID().uuidString)", + content: content, + trigger: nil + ) + + self.center.add(request) { error in + if let error { + NSLog("Failed to schedule test notification: \(error)") + self.logAuthorization("settings test schedule failed error=\(error.localizedDescription)") + } else { + self.logAuthorization("settings test schedule succeeded") + NotificationSoundSettings.runCustomCommand( + title: content.title, + subtitle: content.subtitle, + body: content.body + ) + } + } + } + } + + func handleApplicationDidBecomeActive() { + logAuthorization("app became active deferred=\(hasDeferredAuthorizationRequest)") + if hasDeferredAuthorizationRequest { + hasDeferredAuthorizationRequest = false + ensureAuthorization(origin: .settingsButton) { _ in } + return + } + refreshAuthorizationStatus() + } + func unreadCount(forTabId tabId: UUID) -> Int { indexes.unreadCountByTabId[tabId] ?? 0 } @@ -450,7 +720,7 @@ final class TerminalNotificationStore: ObservableObject { } private func scheduleUserNotification(_ notification: TerminalNotification) { - ensureAuthorization { [weak self] authorized in + ensureAuthorization(origin: .notificationDelivery) { [weak self] authorized in guard let self, authorized else { return } let content = UNMutableNotificationContent() @@ -490,41 +760,90 @@ final class TerminalNotificationStore: ObservableObject { } } - private func ensureAuthorization(_ completion: @escaping (Bool) -> Void) { + private func ensureAuthorization( + origin: AuthorizationRequestOrigin, + _ completion: @escaping (Bool) -> Void + ) { + logAuthorization("ensure start origin=\(origin.rawValue)") center.getNotificationSettings { [weak self] settings in - guard let self else { - completion(false) - return - } + DispatchQueue.main.async { + guard let self else { + completion(false) + return + } - switch settings.authorizationStatus { - case .authorized, .provisional, .ephemeral: - completion(true) - case .denied: - self.promptToEnableNotifications() - completion(false) - case .notDetermined: - self.requestAuthorizationIfNeeded(completion) - @unknown default: - completion(false) + self.authorizationState = Self.authorizationState(from: settings.authorizationStatus) + self.logAuthorization( + "ensure status origin=\(origin.rawValue) status=\(Self.authorizationStatusLabel(settings.authorizationStatus)) mapped=\(self.authorizationState.statusLabel) appActive=\(AppFocusState.isAppActive())" + ) + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + completion(true) + case .denied: + self.logAuthorization("ensure denied origin=\(origin.rawValue) prompting_settings") + self.promptToEnableNotifications() + completion(false) + case .notDetermined: + if Self.shouldDeferAutomaticAuthorizationRequest( + origin: origin, + status: settings.authorizationStatus, + isAppActive: AppFocusState.isAppActive() + ) { + self.logAuthorization("ensure deferred origin=\(origin.rawValue)") + self.hasDeferredAuthorizationRequest = true + completion(false) + } else { + self.requestAuthorizationIfNeeded(origin: origin, completion) + } + @unknown default: + self.logAuthorization("ensure unknown status origin=\(origin.rawValue)") + completion(false) + } } } } - private func requestAuthorizationIfNeeded(_ completion: @escaping (Bool) -> Void) { - guard !hasRequestedAuthorization else { + private func requestAuthorizationIfNeeded( + origin: AuthorizationRequestOrigin, + _ completion: @escaping (Bool) -> Void + ) { + let isAutomaticRequest = origin == .notificationDelivery + guard Self.shouldRequestAuthorization( + isAutomaticRequest: isAutomaticRequest, + hasRequestedAutomaticAuthorization: hasRequestedAutomaticAuthorization + ) else { + logAuthorization( + "request blocked origin=\(origin.rawValue) automatic=\(isAutomaticRequest) hasRequestedAutomatic=\(hasRequestedAutomaticAuthorization)" + ) completion(false) return } - hasRequestedAuthorization = true - center.requestAuthorization(options: [.alert, .sound]) { granted, _ in - completion(granted) + if isAutomaticRequest { + hasRequestedAutomaticAuthorization = true + } + hasDeferredAuthorizationRequest = false + logAuthorization( + "request starting origin=\(origin.rawValue) automatic=\(isAutomaticRequest) hasRequestedAutomatic=\(hasRequestedAutomaticAuthorization)" + ) + center.requestAuthorization(options: [.alert, .sound]) { granted, error in + DispatchQueue.main.async { + if granted { + self.authorizationState = .authorized + } else { + self.refreshAuthorizationStatus() + } + self.logAuthorization( + "request callback origin=\(origin.rawValue) granted=\(granted) error=\(error?.localizedDescription ?? "nil") mapped=\(self.authorizationState.statusLabel)" + ) + completion(granted) + } } } private func promptToEnableNotifications() { DispatchQueue.main.async { [weak self] in guard let self, !self.hasPromptedForSettings else { return } + self.logAuthorization("prompt settings shown") self.hasPromptedForSettings = true self.presentNotificationSettingsPrompt(attempt: 0) } @@ -550,14 +869,54 @@ final class TerminalNotificationStore: ObservableObject { alert.addButton(withTitle: String(localized: "dialog.enableNotifications.openSettings", defaultValue: "Open Settings")) alert.addButton(withTitle: String(localized: "dialog.enableNotifications.notNow", defaultValue: "Not Now")) alert.beginSheetModal(for: window) { [weak self] response in - guard response == .alertFirstButtonReturn, - let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") else { + guard response == .alertFirstButtonReturn else { return } - self?.notificationSettingsURLOpener(url) + self?.openNotificationSettings() } } + static func authorizationState(from status: UNAuthorizationStatus) -> NotificationAuthorizationState { + switch status { + case .authorized: + return .authorized + case .denied: + return .denied + case .notDetermined: + return .notDetermined + case .provisional: + return .provisional + case .ephemeral: + return .ephemeral + @unknown default: + return .unknown + } + } + + static func shouldDeferAutomaticAuthorizationRequest( + status: UNAuthorizationStatus, + isAppActive: Bool + ) -> Bool { + status == .notDetermined && !isAppActive + } + + static func shouldRequestAuthorization( + isAutomaticRequest: Bool, + hasRequestedAutomaticAuthorization: Bool + ) -> Bool { + guard isAutomaticRequest else { return true } + return !hasRequestedAutomaticAuthorization + } + + private static func shouldDeferAutomaticAuthorizationRequest( + origin: AuthorizationRequestOrigin, + status: UNAuthorizationStatus, + isAppActive: Bool + ) -> Bool { + guard origin == .notificationDelivery else { return false } + return shouldDeferAutomaticAuthorizationRequest(status: status, isAppActive: isAppActive) + } + private static func buildIndexes(for notifications: [TerminalNotification]) -> NotificationIndexes { var indexes = NotificationIndexes() for notification in notifications { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 5d3d6500..8b36f4bc 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2,6 +2,7 @@ import AppKit import SwiftUI import Darwin import Bonsplit +import UniformTypeIdentifiers @main struct cmuxApp: App { @@ -2786,6 +2787,8 @@ struct SettingsView: View { private var browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText @AppStorage(NotificationSoundSettings.key) private var notificationSound = NotificationSoundSettings.defaultValue + @AppStorage(NotificationSoundSettings.customFilePathKey) + private var notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath @AppStorage(NotificationSoundSettings.customCommandKey) private var notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand @AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled @AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit @@ -2806,6 +2809,7 @@ struct SettingsView: View { @AppStorage("sidebarShowLog") private var sidebarShowLog = true @AppStorage("sidebarShowProgress") private var sidebarShowProgress = true @AppStorage("sidebarShowStatusPills") private var sidebarShowMetadata = true + @ObservedObject private var notificationStore = TerminalNotificationStore.shared @State private var shortcutResetToken = UUID() @State private var topBlurOpacity: Double = 0 @State private var topBlurBaselineOffset: CGFloat? @@ -2894,12 +2898,109 @@ struct SettingsView: View { browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist } + private var hasCustomNotificationSoundFilePath: Bool { + !notificationSoundCustomFilePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var notificationSoundCustomFileDisplayName: String { + guard hasCustomNotificationSoundFilePath else { + return "No file selected" + } + return URL(fileURLWithPath: notificationSoundCustomFilePath).lastPathComponent + } + + private var canPreviewNotificationSound: Bool { + switch notificationSound { + case "none": + return false + case NotificationSoundSettings.customFileValue: + return hasCustomNotificationSoundFilePath + default: + return true + } + } + + private var notificationPermissionStatusText: String { + notificationStore.authorizationState.statusLabel + } + + private var notificationPermissionStatusColor: Color { + switch notificationStore.authorizationState { + case .authorized, .provisional, .ephemeral: + return .green + case .denied: + return .red + case .unknown, .notDetermined: + return .secondary + } + } + + private var notificationPermissionSubtitle: String { + switch notificationStore.authorizationState { + case .unknown, .notDetermined: + return "Desktop notifications are not enabled yet." + case .authorized: + return "Desktop notifications are enabled." + case .denied: + return "Desktop notifications are disabled in System Settings." + case .provisional: + return "Desktop notifications are enabled with quiet delivery." + case .ephemeral: + return "Desktop notifications are temporarily enabled." + } + } + + private var notificationPermissionActionTitle: String { + switch notificationStore.authorizationState { + case .unknown, .notDetermined: + return "Enable" + case .authorized, .denied, .provisional, .ephemeral: + return "Open Settings" + } + } + private func blurOpacity(forContentOffset offset: CGFloat) -> Double { guard let baseline = topBlurBaselineOffset else { return 0 } let reveal = (baseline - offset) / 24 return Double(min(max(reveal, 0), 1)) } + private func previewNotificationSound() { + if notificationSound == NotificationSoundSettings.customFileValue { + NotificationSoundSettings.playCustomFileSound(path: notificationSoundCustomFilePath) + return + } + NotificationSoundSettings.previewSound(value: notificationSound) + } + + private func chooseNotificationSoundFile() { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.allowedContentTypes = [.audio] + panel.title = "Choose Notification Sound" + panel.prompt = "Choose" + guard panel.runModal() == .OK, let url = panel.url else { return } + notificationSoundCustomFilePath = url.path + notificationSound = NotificationSoundSettings.customFileValue + previewNotificationSound() + } + + private func handleNotificationPermissionAction() { + let state = notificationStore.authorizationState.statusLabel +#if DEBUG + dlog("notification.ui enableTapped state=\(state)") +#endif + NSLog("notification.ui enableTapped state=%@", state) + switch notificationStore.authorizationState { + case .unknown, .notDetermined: + notificationStore.requestAuthorizationFromSettings() + case .authorized, .denied, .provisional, .ephemeral: + notificationStore.openNotificationSettings() + } + } + private func saveSocketPassword() { let trimmed = socketPasswordDraft.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { @@ -3017,25 +3118,73 @@ struct SettingsView: View { SettingsCardDivider() - SettingsPickerRow( - "Notification Sound", - subtitle: "Sound played when a notification arrives.", - controlWidth: pickerColumnWidth, - selection: $notificationSound + SettingsCardRow( + "Desktop Notifications", + subtitle: notificationPermissionSubtitle ) { - ForEach(NotificationSoundSettings.systemSounds, id: \.value) { sound in - Text(sound.label).tag(sound.value) + HStack(spacing: 6) { + Text(notificationPermissionStatusText) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(notificationPermissionStatusColor) + .frame(width: 98, alignment: .trailing) + + Button(notificationPermissionActionTitle) { + handleNotificationPermissionAction() + } + .controlSize(.small) + + Button("Send Test") { + notificationStore.sendSettingsTestNotification() + } + .controlSize(.small) } - } extraTrailing: { - Button { - NotificationSoundSettings.previewSound(value: notificationSound) - } label: { - Image(systemName: "play.fill") - .font(.system(size: 9)) + } + + SettingsCardDivider() + + SettingsCardRow( + "Notification Sound", + subtitle: "Sound played when a notification arrives." + ) { + VStack(alignment: .trailing, spacing: 6) { + HStack(spacing: 6) { + Picker("", selection: $notificationSound) { + ForEach(NotificationSoundSettings.systemSounds, id: \.value) { sound in + Text(sound.label).tag(sound.value) + } + } + .labelsHidden() + Button { + previewNotificationSound() + } label: { + Image(systemName: "play.fill") + .font(.system(size: 9)) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!canPreviewNotificationSound) + } + + if notificationSound == NotificationSoundSettings.customFileValue { + HStack(spacing: 6) { + Text(notificationSoundCustomFileDisplayName) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .frame(width: 170, alignment: .trailing) + Button("Choose...") { + chooseNotificationSoundFile() + } + .controlSize(.small) + Button("Clear") { + notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath + } + .controlSize(.small) + .disabled(!hasCustomNotificationSoundFilePath) + } + } } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(notificationSound == "none") } SettingsCardDivider() @@ -3681,6 +3830,7 @@ struct SettingsView: View { .toggleStyle(.switch) .onAppear { BrowserHistoryStore.shared.loadIfNeeded() + notificationStore.refreshAuthorizationStatus() browserThemeMode = BrowserThemeSettings.mode(defaults: .standard).rawValue browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist @@ -3771,6 +3921,7 @@ struct SettingsView: View { browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText notificationSound = NotificationSoundSettings.defaultValue + notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 7da75be5..400ab90f 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -5,6 +5,7 @@ import WebKit import SwiftUI import ObjectiveC.runtime import Bonsplit +import UserNotifications #if canImport(cmux_DEV) @testable import cmux_DEV @@ -6587,6 +6588,188 @@ final class NotificationDockBadgeTests: XCTestCase { XCTAssertTrue(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults)) } + func testNotificationSoundUsesSystemSoundForDefaultAndNamedSounds() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + XCTAssertTrue(NotificationSoundSettings.usesSystemSound(defaults: defaults)) + + defaults.set("Ping", forKey: NotificationSoundSettings.key) + XCTAssertTrue(NotificationSoundSettings.usesSystemSound(defaults: defaults)) + XCTAssertNotNil(NotificationSoundSettings.sound(defaults: defaults)) + } + + func testNotificationSoundDisablesSystemSoundForNoneAndCustomFile() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + defaults.set("none", forKey: NotificationSoundSettings.key) + XCTAssertFalse(NotificationSoundSettings.usesSystemSound(defaults: defaults)) + XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults)) + + defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) + XCTAssertFalse(NotificationSoundSettings.usesSystemSound(defaults: defaults)) + XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults)) + } + + func testNotificationCustomFileURLExpandsTildePath() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + let rawPath = "~/Library/Sounds/my-custom.wav" + defaults.set(rawPath, forKey: NotificationSoundSettings.customFilePathKey) + let expectedPath = (rawPath as NSString).expandingTildeInPath + XCTAssertEqual(NotificationSoundSettings.customFileURL(defaults: defaults)?.path, expectedPath) + } + + func testNotificationCustomFileSelectionMustBeExplicit() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + defaults.set("~/Library/Sounds/my-custom.wav", forKey: NotificationSoundSettings.customFilePathKey) + + defaults.set("none", forKey: NotificationSoundSettings.key) + XCTAssertFalse(NotificationSoundSettings.isCustomFileSelected(defaults: defaults)) + + defaults.set("Ping", forKey: NotificationSoundSettings.key) + XCTAssertFalse(NotificationSoundSettings.isCustomFileSelected(defaults: defaults)) + + defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) + XCTAssertTrue(NotificationSoundSettings.isCustomFileSelected(defaults: defaults)) + } + + func testNotificationCustomStagingPreservesSourceFileWithCmuxPrefix() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + let fileManager = FileManager.default + let soundsDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Sounds", isDirectory: true) + do { + try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true) + } catch { + XCTFail("Failed to create sounds directory: \(error)") + return + } + + let sourceURL = soundsDirectory.appendingPathComponent( + "cmux-custom-notification-sound.source-\(UUID().uuidString).custtest", + isDirectory: false + ) + let stagedURL = soundsDirectory.appendingPathComponent( + "cmux-custom-notification-sound.custtest", + isDirectory: false + ) + defer { + try? fileManager.removeItem(at: sourceURL) + try? fileManager.removeItem(at: stagedURL) + } + + do { + try Data("test".utf8).write(to: sourceURL, options: .atomic) + } catch { + XCTFail("Failed to write source custom sound file: \(error)") + return + } + + defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) + defaults.set(sourceURL.path, forKey: NotificationSoundSettings.customFilePathKey) + + _ = NotificationSoundSettings.sound(defaults: defaults) + + XCTAssertTrue(fileManager.fileExists(atPath: sourceURL.path)) + XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.path)) + } + + func testNotificationAuthorizationStateMappingCoversKnownUNAuthorizationStatuses() { + XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .notDetermined), .notDetermined) + XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .denied), .denied) + XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .authorized), .authorized) + XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .provisional), .provisional) + } + + func testNotificationAuthorizationStateDeliveryCapability() { + XCTAssertFalse(NotificationAuthorizationState.unknown.allowsDelivery) + XCTAssertFalse(NotificationAuthorizationState.notDetermined.allowsDelivery) + XCTAssertFalse(NotificationAuthorizationState.denied.allowsDelivery) + XCTAssertTrue(NotificationAuthorizationState.authorized.allowsDelivery) + XCTAssertTrue(NotificationAuthorizationState.provisional.allowsDelivery) + XCTAssertTrue(NotificationAuthorizationState.ephemeral.allowsDelivery) + } + + func testNotificationAuthorizationDefersFirstPromptWhileAppIsInactive() { + XCTAssertTrue( + TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest( + status: .notDetermined, + isAppActive: false + ) + ) + XCTAssertFalse( + TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest( + status: .notDetermined, + isAppActive: true + ) + ) + XCTAssertFalse( + TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest( + status: .authorized, + isAppActive: false + ) + ) + } + + func testNotificationAuthorizationRequestGatingAllowsSettingsRetry() { + XCTAssertTrue( + TerminalNotificationStore.shouldRequestAuthorization( + isAutomaticRequest: false, + hasRequestedAutomaticAuthorization: true + ) + ) + XCTAssertTrue( + TerminalNotificationStore.shouldRequestAuthorization( + isAutomaticRequest: true, + hasRequestedAutomaticAuthorization: false + ) + ) + XCTAssertFalse( + TerminalNotificationStore.shouldRequestAuthorization( + isAutomaticRequest: true, + hasRequestedAutomaticAuthorization: true + ) + ) + } + func testNotificationSettingsPromptUsesSheetAndNeverRunsModal() { let store = TerminalNotificationStore.shared let alertSpy = NotificationSettingsAlertSpy()