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
This commit is contained in:
Lawrence Chen 2026-03-04 17:18:07 -08:00 committed by GitHub
parent 28977c8e3b
commit 80bbfdf206
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 739 additions and 44 deletions

View file

@ -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)

View file

@ -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 {

View file

@ -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

View file

@ -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()