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:
parent
044a3dbb64
commit
a5de92e9d6
4 changed files with 175 additions and 2 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue