diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 10e1c47b..b70ed1d5 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -7156,7 +7156,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { - completionHandler([.banner, .sound, .list]) + var options: UNNotificationPresentationOptions = [.banner, .list] + if !NotificationSoundSettings.isSilent() { + options.insert(.sound) + } + completionHandler(options) } private func handleNotificationResponse(_ response: UNNotificationResponse) { diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 5a34be3c..8985b6ce 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -28,6 +28,87 @@ extension UNUserNotificationCenter { } } +enum NotificationSoundSettings { + static let key = "notificationSound" + static let defaultValue = "default" + static let customCommandKey = "notificationCustomCommand" + static let defaultCustomCommand = "" + + static let systemSounds: [(label: String, value: String)] = [ + ("Default", "default"), + ("Basso", "Basso"), + ("Blow", "Blow"), + ("Bottle", "Bottle"), + ("Frog", "Frog"), + ("Funk", "Funk"), + ("Glass", "Glass"), + ("Hero", "Hero"), + ("Morse", "Morse"), + ("Ping", "Ping"), + ("Pop", "Pop"), + ("Purr", "Purr"), + ("Sosumi", "Sosumi"), + ("Submarine", "Submarine"), + ("Tink", "Tink"), + ("None", "none"), + ] + + static func sound(defaults: UserDefaults = .standard) -> UNNotificationSound? { + let value = defaults.string(forKey: key) ?? defaultValue + switch value { + case "default": + return .default + case "none": + return nil + default: + return UNNotificationSound(named: UNNotificationSoundName(rawValue: value)) + } + } + + static func isSilent(defaults: UserDefaults = .standard) -> Bool { + return (defaults.string(forKey: key) ?? defaultValue) == "none" + } + + static func previewSound(value: String) { + switch value { + case "default": + NSSound.beep() + case "none": + break + default: + NSSound(named: NSSound.Name(value))?.play() + } + } + + private static let customCommandQueue = DispatchQueue( + label: "com.cmuxterm.notification-custom-command", + qos: .utility + ) + + static func runCustomCommand(title: String, subtitle: String, body: String, defaults: UserDefaults = .standard) { + let command = (defaults.string(forKey: customCommandKey) ?? defaultCustomCommand) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !command.isEmpty else { return } + customCommandQueue.async { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", command] + var env = ProcessInfo.processInfo.environment + env["CMUX_NOTIFICATION_TITLE"] = title + env["CMUX_NOTIFICATION_SUBTITLE"] = subtitle + env["CMUX_NOTIFICATION_BODY"] = body + process.environment = env + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + } catch { + NSLog("Notification command failed to launch: \(error)") + } + } + } +} + enum NotificationBadgeSettings { static let dockBadgeEnabledKey = "notificationDockBadgeEnabled" static let defaultDockBadgeEnabled = true @@ -379,7 +460,7 @@ final class TerminalNotificationStore: ObservableObject { content.title = notification.title.isEmpty ? appName : notification.title content.subtitle = notification.subtitle content.body = notification.body - content.sound = UNNotificationSound.default + content.sound = NotificationSoundSettings.sound() content.categoryIdentifier = Self.categoryIdentifier content.userInfo = [ "tabId": notification.tabId.uuidString, @@ -398,6 +479,12 @@ final class TerminalNotificationStore: ObservableObject { self.center.add(request) { error in if let error { NSLog("Failed to schedule notification: \(error)") + } else { + NotificationSoundSettings.runCustomCommand( + title: content.title, + subtitle: content.subtitle, + body: content.body + ) } } } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 3471ca26..80556abb 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2734,6 +2734,8 @@ struct SettingsView: View { @AppStorage(BrowserLinkOpenSettings.browserExternalOpenPatternsKey) private var browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText + @AppStorage(NotificationSoundSettings.key) private var notificationSound = NotificationSoundSettings.defaultValue + @AppStorage(NotificationSoundSettings.customCommandKey) private var notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand @AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled @AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) @@ -2942,6 +2944,42 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + "Notification Sound", + subtitle: "Sound played when a notification arrives." + ) { + HStack(spacing: 6) { + Picker("", selection: $notificationSound) { + ForEach(NotificationSoundSettings.systemSounds, id: \.value) { sound in + Text(sound.label).tag(sound.value) + } + } + .labelsHidden() + Button { + NotificationSoundSettings.previewSound(value: notificationSound) + } label: { + Image(systemName: "play.fill") + .font(.system(size: 9)) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(notificationSound == "none") + } + } + + SettingsCardDivider() + + SettingsCardRow( + "Notification Command", + subtitle: "Run a shell command when a notification arrives. $CMUX_NOTIFICATION_TITLE, $CMUX_NOTIFICATION_SUBTITLE, $CMUX_NOTIFICATION_BODY are set." + ) { + TextField("say \"done\"", text: $notificationCustomCommand) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + } + + SettingsCardDivider() + SettingsCardRow( "Send anonymous telemetry", subtitle: sendAnonymousTelemetry != telemetryValueAtLaunch @@ -3649,6 +3687,8 @@ struct SettingsView: View { browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText + notificationSound = NotificationSoundSettings.defaultValue + notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus diff --git a/web/app/docs/notifications/page.tsx b/web/app/docs/notifications/page.tsx index b7738f2c..032af4b3 100644 --- a/web/app/docs/notifications/page.tsx +++ b/web/app/docs/notifications/page.tsx @@ -49,6 +49,48 @@ export default function NotificationsPage() { directly to the workspace with the most recent unread notification.

+

Custom command

+

+ Run a shell command every time a notification is scheduled. Set it in{" "} + Settings → App → Notification Command. The command + runs via /bin/sh -c with these environment variables: +

+ + + + + + + + + + + + + + + + + + + + + +
VariableDescription
CMUX_NOTIFICATION_TITLENotification title (workspace name or app name)
CMUX_NOTIFICATION_SUBTITLENotification subtitle
CMUX_NOTIFICATION_BODYNotification body text
+ {`# Text-to-speech +say "$CMUX_NOTIFICATION_TITLE" + +# Custom sound file +afplay /path/to/sound.aiff + +# Log to file +echo "$CMUX_NOTIFICATION_TITLE: $CMUX_NOTIFICATION_BODY" >> ~/notifications.log`} +

+ The command runs independently of the system sound picker. Set the + picker to "None" to use only the custom command, or keep both for a + system sound plus a custom action. +

+

Sending notifications

CLI