Add customizable notification sound (#839)

* Add customizable notification sound setting

Adds a "Notification Sound" picker in Settings > App that lets users
choose from macOS system sounds (Default, Basso, Blow, Glass, etc.)
or silence notifications entirely with "None".

Closes https://github.com/manaflow-ai/cmux/issues/608

* Add custom notification command with env vars and sound preview

Users can set a shell command in Settings > App > Notification Command
that runs on every notification. CMUX_NOTIFICATION_TITLE,
CMUX_NOTIFICATION_SUBTITLE, and CMUX_NOTIFICATION_BODY env vars are
set. Also adds a play button to preview system sounds and docs.
This commit is contained in:
Lawrence Chen 2026-03-04 00:12:05 -08:00 committed by GitHub
parent 044a3dbb64
commit a5de92e9d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 175 additions and 2 deletions

View file

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

View file

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

View file

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

View file

@ -49,6 +49,48 @@ export default function NotificationsPage() {
directly to the workspace with the most recent unread notification.
</p>
<h2>Custom command</h2>
<p>
Run a shell command every time a notification is scheduled. Set it in{" "}
<strong>Settings App Notification Command</strong>. The command
runs via <code>/bin/sh -c</code> with these environment variables:
</p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>CMUX_NOTIFICATION_TITLE</code></td>
<td>Notification title (workspace name or app name)</td>
</tr>
<tr>
<td><code>CMUX_NOTIFICATION_SUBTITLE</code></td>
<td>Notification subtitle</td>
</tr>
<tr>
<td><code>CMUX_NOTIFICATION_BODY</code></td>
<td>Notification body text</td>
</tr>
</tbody>
</table>
<CodeBlock title="Examples" lang="bash">{`# 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`}</CodeBlock>
<p>
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.
</p>
<h2>Sending notifications</h2>
<h3>CLI</h3>