Fix custom notification sound staging reliability (#919)

This commit is contained in:
Lawrence Chen 2026-03-04 19:19:07 -08:00 committed by GitHub
parent 39a0da2b7e
commit 26bef7316e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 834 additions and 33 deletions

View file

@ -21,17 +21,19 @@ When reporting a tagged reload result in chat, use the format for your agent typ
**Claude Code** (markdown link with correct derived-data path, cmd+clickable):
```markdown
=======================================================
[cmux DEV <tag-name>.app](file:///tmp/cmux-<tag-name>/Build/Products/Debug/cmux%20DEV%20<tag-name>.app)
[cmux DEV <tag-name>.app](file:///Users/lawrencechen/Library/Developer/Xcode/DerivedData/cmux-<tag-name>/Build/Products/Debug/cmux%20DEV%20<tag-name>.app)
=======================================================
```
**Codex** (plain text format):
```
=======================================================
[<tag-name>: file:///tmp/cmux-<tag-name>.app](file:///tmp/cmux-<tag-name>.app)
[<tag-name>: file:///Users/lawrencechen/Library/Developer/Xcode/DerivedData/cmux-<tag-name>/Build/Products/Debug/cmux%20DEV%20<tag-name>.app](file:///Users/lawrencechen/Library/Developer/Xcode/DerivedData/cmux-<tag-name>/Build/Products/Debug/cmux%20DEV%20<tag-name>.app)
=======================================================
```
Never use `/tmp/cmux-<tag>/...` app links in chat output. If the expected DerivedData path is missing, resolve the real `.app` path and report that `file://` URL.
After making code changes, always run the build:
```bash

View file

@ -43959,6 +43959,244 @@
}
}
},
"settings.notifications.sound.custom.choose.button": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Choose..."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "選択..."
}
}
}
},
"settings.notifications.sound.custom.choose.prompt": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Choose"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "選択"
}
}
}
},
"settings.notifications.sound.custom.choose.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Choose Notification Sound"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "通知サウンドを選択"
}
}
}
},
"settings.notifications.sound.custom.clear.button": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Clear"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "クリア"
}
}
}
},
"settings.notifications.sound.custom.error.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Custom Notification Sound Error"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "カスタム通知サウンドのエラー"
}
}
}
},
"settings.notifications.sound.custom.file.none": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "No file selected"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ファイル未選択"
}
}
}
},
"settings.notifications.sound.custom.status.empty": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Choose a custom audio file first."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "先にカスタム音声ファイルを選択してください。"
}
}
}
},
"settings.notifications.sound.custom.status.missingExtensionPrefix": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "File needs an extension: "
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "拡張子が必要です: "
}
}
}
},
"settings.notifications.sound.custom.status.missingFilePrefix": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "File not found: "
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ファイルが見つかりません: "
}
}
}
},
"settings.notifications.sound.custom.status.prepareFailed": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Could not prepare this file for notifications. Try WAV, AIFF, or CAF."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "通知用にこのファイルを準備できませんでした。WAV、AIFF、またはCAFを試してください。"
}
}
}
},
"settings.notifications.sound.custom.status.ready": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Ready for notifications."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "通知用の準備ができました。"
}
}
}
},
"settings.notifications.sound.custom.status.readyConverted": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Prepared for notifications (converted to CAF)."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "通知用に準備しましたCAFに変換。"
}
}
}
},
"settings.notifications.sound.subtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Sound played when a notification arrives."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "通知を受信したときに再生するサウンドです。"
}
}
}
},
"settings.notifications.sound.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Notification Sound"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "通知サウンド"
}
}
}
},
"settings.automation.claudeCode": {
"extractionState": "manual",
"localizations": {

View file

@ -36,6 +36,45 @@ enum NotificationSoundSettings {
static let customFilePathKey = "notificationSoundCustomFilePath"
static let defaultCustomFilePath = ""
private static let stagedCustomSoundBaseName = "cmux-custom-notification-sound"
private static let customSoundPreparationQueue = DispatchQueue(
label: "com.cmuxterm.notification-sound-preparation",
qos: .utility
)
private static let pendingCustomSoundPreparationLock = NSLock()
private static var pendingCustomSoundPreparationPaths: Set<String> = []
private static let notificationSoundSupportedExtensions: Set<String> = [
"aif",
"aiff",
"caf",
"wav",
]
private struct CustomSoundSourceMetadata: Codable, Equatable {
let sourcePath: String
let sourceSize: UInt64
let sourceModificationTime: Double
let sourceFileIdentifier: UInt64?
}
enum CustomSoundPreparationIssue: Error {
case emptyPath
case missingFile(path: String)
case missingFileExtension(path: String)
case stagingFailed(path: String, details: String)
var logMessage: String {
switch self {
case .emptyPath:
return "Notification custom sound path is empty"
case .missingFile(let path):
return "Notification custom sound file does not exist: \(path)"
case .missingFileExtension(let path):
return "Notification custom sound requires a file extension: \(path)"
case .stagingFailed(let path, let details):
return "Failed to stage custom notification sound from \(path): \(details)"
}
}
}
static let customCommandKey = "notificationCustomCommand"
static let defaultCustomCommand = ""
@ -97,33 +136,109 @@ enum NotificationSoundSettings {
}
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)")
let rawPath = defaults.string(forKey: customFilePathKey) ?? defaultCustomFilePath
guard let normalizedPath = normalizedCustomFilePath(rawPath) else {
NSLog("Notification custom sound unavailable: \(CustomSoundPreparationIssue.emptyPath.logMessage)")
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 sourceURL = URL(fileURLWithPath: (normalizedPath as NSString).expandingTildeInPath)
let sourceExtension = sourceURL.pathExtension
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
guard !sourceExtension.isEmpty else {
NSLog("Notification custom sound unavailable: \(CustomSoundPreparationIssue.missingFileExtension(path: sourceURL.path).logMessage)")
return nil
}
let destinationExtension = stagedCustomSoundFileExtension(forSourceExtension: sourceExtension)
let stagedFileName = stagedCustomSoundFileName(
forSourceURL: sourceURL,
destinationExtension: destinationExtension
)
let stagedURL = stagedSoundDirectoryURL().appendingPathComponent(stagedFileName, isDirectory: false)
let fileManager = FileManager.default
guard fileManager.fileExists(atPath: sourceURL.path) else {
NSLog("Notification custom sound unavailable: \(CustomSoundPreparationIssue.missingFile(path: sourceURL.path).logMessage)")
return nil
}
if fileManager.fileExists(atPath: stagedURL.path) {
if let sourceMetadata = currentSourceMetadata(for: sourceURL, fileManager: fileManager),
let stagedMetadata = loadStagedSourceMetadata(for: stagedURL),
stagedMetadata == sourceMetadata {
return stagedFileName
}
}
if destinationExtension == sourceExtension {
switch prepareCustomFileForNotifications(path: normalizedPath) {
case .success(let preparedName):
return preparedName
case .failure(let issue):
NSLog("Notification custom sound unavailable: \(issue.logMessage)")
return nil
}
}
queueCustomSoundPreparation(path: normalizedPath)
NSLog("Notification custom sound not ready yet, staging in background: \(sourceURL.path)")
return nil
}
static func prepareCustomFileForNotifications(path: String) -> Result<String, CustomSoundPreparationIssue> {
guard let normalizedPath = normalizedCustomFilePath(path) else {
return .failure(.emptyPath)
}
let sourceURL = URL(fileURLWithPath: (normalizedPath as NSString).expandingTildeInPath)
return prepareCustomSound(from: sourceURL)
}
private static func prepareCustomSound(from sourceURL: URL) -> Result<String, CustomSoundPreparationIssue> {
let sourcePath = sourceURL.path
let fileManager = FileManager.default
guard fileManager.fileExists(atPath: sourcePath) else {
return .failure(.missingFile(path: sourcePath))
}
let sourceExtension = sourceURL.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines)
guard !sourceExtension.isEmpty else {
return .failure(.missingFileExtension(path: sourcePath))
}
let destinationExtension = stagedCustomSoundFileExtension(forSourceExtension: sourceExtension)
let destinationDirectory = stagedSoundDirectoryURL()
let destinationFileName = stagedCustomSoundFileName(
forSourceURL: sourceURL,
destinationExtension: destinationExtension
)
let destinationURL = destinationDirectory.appendingPathComponent(destinationFileName, isDirectory: false)
let sourceMetadata = currentSourceMetadata(for: sourceURL, fileManager: fileManager)
do {
try fileManager.createDirectory(at: destinationDirectory, withIntermediateDirectories: true)
try copyStagedSoundIfNeeded(from: sourceURL, to: destinationURL, fileManager: fileManager)
if fileManager.fileExists(atPath: destinationURL.path) {
let stagedMetadata = loadStagedSourceMetadata(for: destinationURL)
if stagedMetadata != sourceMetadata {
try? fileManager.removeItem(at: destinationURL)
}
}
if destinationExtension == sourceExtension.lowercased() {
try copyStagedSoundIfNeeded(from: sourceURL, to: destinationURL, fileManager: fileManager)
} else {
try transcodeStagedSoundIfNeeded(from: sourceURL, to: destinationURL, fileManager: fileManager)
}
if let sourceMetadata {
try saveStagedSourceMetadata(sourceMetadata, for: destinationURL)
}
try cleanupStaleStagedSoundFiles(
in: destinationDirectory,
keeping: destinationFileName,
preservingSourceURL: sourceURL,
fileManager: fileManager
)
return destinationFileName
return .success(destinationFileName)
} catch {
NSLog("Failed to stage custom notification sound: \(error)")
return nil
return .failure(.stagingFailed(path: sourcePath, details: error.localizedDescription))
}
}
@ -158,12 +273,58 @@ enum NotificationSoundSettings {
}
}
static func stagedCustomSoundFileExtension(forSourceExtension sourceExtension: String) -> String {
let normalized = sourceExtension
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
guard !normalized.isEmpty else { return "caf" }
if notificationSoundSupportedExtensions.contains(normalized) {
return normalized
}
return "caf"
}
static func stagedCustomSoundFileName(forSourceURL sourceURL: URL, destinationExtension: String) -> String {
let normalizedExtension = destinationExtension
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
let ext = normalizedExtension.isEmpty ? "caf" : normalizedExtension
let signature = stagedCustomSoundSourceSignature(for: sourceURL)
return "\(stagedCustomSoundBaseName)-\(signature).\(ext)"
}
private static func normalizedCustomFilePath(_ rawPath: String) -> String? {
let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return trimmed
}
private static func stagedSoundDirectoryURL() -> URL {
URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
.appendingPathComponent("Library", isDirectory: true)
.appendingPathComponent("Sounds", isDirectory: true)
}
private static func queueCustomSoundPreparation(path: String) {
let expandedPath = (path as NSString).expandingTildeInPath
pendingCustomSoundPreparationLock.lock()
if pendingCustomSoundPreparationPaths.contains(expandedPath) {
pendingCustomSoundPreparationLock.unlock()
return
}
pendingCustomSoundPreparationPaths.insert(expandedPath)
pendingCustomSoundPreparationLock.unlock()
customSoundPreparationQueue.async {
defer {
pendingCustomSoundPreparationLock.lock()
pendingCustomSoundPreparationPaths.remove(expandedPath)
pendingCustomSoundPreparationLock.unlock()
}
_ = prepareCustomFileForNotifications(path: expandedPath)
}
}
private static func playSoundFile(at url: URL) {
DispatchQueue.main.async {
guard let sound = NSSound(contentsOf: url, byReference: false) else {
@ -180,15 +341,21 @@ enum NotificationSoundSettings {
preservingSourceURL: URL,
fileManager: FileManager
) throws {
let prefix = "\(stagedCustomSoundBaseName)."
let legacyPrefix = "\(stagedCustomSoundBaseName)."
let hashedPrefix = "\(stagedCustomSoundBaseName)-"
let normalizedSource = preservingSourceURL.standardizedFileURL
let keptStagedURL = directoryURL.appendingPathComponent(fileName, isDirectory: false)
let keptMetadataFileName = stagedSourceMetadataURL(for: keptStagedURL).lastPathComponent
for fileNameCandidate in try fileManager.contentsOfDirectory(atPath: directoryURL.path) {
guard fileNameCandidate.hasPrefix(prefix), fileNameCandidate != fileName else { continue }
let isManagedName = fileNameCandidate.hasPrefix(legacyPrefix) || fileNameCandidate.hasPrefix(hashedPrefix)
let isKeptManagedFile = fileNameCandidate == fileName || fileNameCandidate == keptMetadataFileName
guard isManagedName, !isKeptManagedFile else { continue }
let staleURL = directoryURL.appendingPathComponent(fileNameCandidate, isDirectory: false)
if staleURL.standardizedFileURL == normalizedSource {
continue
}
try? fileManager.removeItem(at: staleURL)
try? fileManager.removeItem(at: stagedSourceMetadataURL(for: staleURL))
}
}
@ -217,6 +384,108 @@ enum NotificationSoundSettings {
try fileManager.copyItem(at: normalizedSource, to: normalizedDestination)
}
private static func transcodeStagedSoundIfNeeded(
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 sourceDate = sourceAttributes[.modificationDate] as? Date
let destinationDate = destinationAttributes[.modificationDate] as? Date
if let sourceDate, let destinationDate, destinationDate >= sourceDate {
return
}
try fileManager.removeItem(at: normalizedDestination)
}
let outputPipe = Pipe()
let errorPipe = Pipe()
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/afconvert")
process.arguments = [
"-f", "caff",
"-d", "LEI16",
normalizedSource.path,
normalizedDestination.path,
]
process.standardOutput = outputPipe
process.standardError = errorPipe
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
let errorOutput = String(data: errorData, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if fileManager.fileExists(atPath: normalizedDestination.path) {
try? fileManager.removeItem(at: normalizedDestination)
}
let description: String
if let errorOutput, !errorOutput.isEmpty {
description = errorOutput
} else {
description = "afconvert failed with exit code \(process.terminationStatus)"
}
throw NSError(
domain: "NotificationSoundSettings",
code: Int(process.terminationStatus),
userInfo: [
NSLocalizedDescriptionKey: description,
]
)
}
}
private static func stagedCustomSoundSourceSignature(for sourceURL: URL) -> String {
let normalizedPath = sourceURL.standardizedFileURL.path
var hash: UInt64 = 0xcbf29ce484222325
for byte in normalizedPath.utf8 {
hash ^= UInt64(byte)
hash &*= 0x100000001b3
}
return String(format: "%016llx", hash)
}
private static func stagedSourceMetadataURL(for stagedURL: URL) -> URL {
stagedURL.appendingPathExtension("source-metadata")
}
private static func currentSourceMetadata(for sourceURL: URL, fileManager: FileManager) -> CustomSoundSourceMetadata? {
guard let attributes = try? fileManager.attributesOfItem(atPath: sourceURL.path) else {
return nil
}
guard let sourceSizeNumber = attributes[.size] as? NSNumber else {
return nil
}
let sourceDate = (attributes[.modificationDate] as? Date) ?? .distantPast
let fileIdentifier = (attributes[.systemFileNumber] as? NSNumber)?.uint64Value
return CustomSoundSourceMetadata(
sourcePath: sourceURL.standardizedFileURL.path,
sourceSize: sourceSizeNumber.uint64Value,
sourceModificationTime: sourceDate.timeIntervalSinceReferenceDate,
sourceFileIdentifier: fileIdentifier
)
}
private static func loadStagedSourceMetadata(for stagedURL: URL) -> CustomSoundSourceMetadata? {
let metadataURL = stagedSourceMetadataURL(for: stagedURL)
guard let data = try? Data(contentsOf: metadataURL) else {
return nil
}
return try? JSONDecoder().decode(CustomSoundSourceMetadata.self, from: data)
}
private static func saveStagedSourceMetadata(_ metadata: CustomSoundSourceMetadata, for stagedURL: URL) throws {
let metadataURL = stagedSourceMetadataURL(for: stagedURL)
let data = try JSONEncoder().encode(metadata)
try data.write(to: metadataURL, options: .atomic)
}
private static let customCommandQueue = DispatchQueue(
label: "com.cmuxterm.notification-custom-command",
qos: .utility

View file

@ -2853,6 +2853,10 @@ struct SettingsView: View {
@State private var socketPasswordDraft = ""
@State private var socketPasswordStatusMessage: String?
@State private var socketPasswordStatusIsError = false
@State private var notificationCustomSoundStatusMessage: String?
@State private var notificationCustomSoundStatusIsError = false
@State private var showNotificationCustomSoundErrorAlert = false
@State private var notificationCustomSoundErrorAlertMessage = ""
@State private var telemetryValueAtLaunch = TelemetrySettings.enabledForCurrentLaunch
@State private var showLanguageRestartAlert = false
@State private var isResettingSettings = false
@ -2935,7 +2939,10 @@ struct SettingsView: View {
private var notificationSoundCustomFileDisplayName: String {
guard hasCustomNotificationSoundFilePath else {
return "No file selected"
return String(
localized: "settings.notifications.sound.custom.file.none",
defaultValue: "No file selected"
)
}
return URL(fileURLWithPath: notificationSoundCustomFilePath).lastPathComponent
}
@ -3004,18 +3011,113 @@ struct SettingsView: View {
NotificationSoundSettings.previewSound(value: notificationSound)
}
private func notificationCustomSoundIssueMessage(_ issue: NotificationSoundSettings.CustomSoundPreparationIssue) -> String {
switch issue {
case .emptyPath:
return String(
localized: "settings.notifications.sound.custom.status.empty",
defaultValue: "Choose a custom audio file first."
)
case .missingFile(let path):
let fileName = URL(fileURLWithPath: path).lastPathComponent
return String(
localized: "settings.notifications.sound.custom.status.missingFilePrefix",
defaultValue: "File not found: "
) + fileName
case .missingFileExtension(let path):
let fileName = URL(fileURLWithPath: path).lastPathComponent
return String(
localized: "settings.notifications.sound.custom.status.missingExtensionPrefix",
defaultValue: "File needs an extension: "
) + fileName
case .stagingFailed(_, let details):
let prefix = String(
localized: "settings.notifications.sound.custom.status.prepareFailed",
defaultValue: "Could not prepare this file for notifications. Try WAV, AIFF, or CAF."
)
return "\(prefix) (\(details))"
}
}
private func notificationCustomSoundReadyStatusMessage(for path: String) -> String {
let sourceExtension = URL(fileURLWithPath: path).pathExtension
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
let stagedExtension = NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: sourceExtension)
if !sourceExtension.isEmpty, stagedExtension != sourceExtension {
return String(
localized: "settings.notifications.sound.custom.status.readyConverted",
defaultValue: "Prepared for notifications (converted to CAF)."
)
}
return String(
localized: "settings.notifications.sound.custom.status.ready",
defaultValue: "Ready for notifications."
)
}
private func refreshNotificationCustomSoundStatus(showAlertOnFailure: Bool = false) {
guard notificationSound == NotificationSoundSettings.customFileValue else {
notificationCustomSoundStatusMessage = nil
notificationCustomSoundStatusIsError = false
return
}
let pathSnapshot = notificationSoundCustomFilePath
DispatchQueue.global(qos: .userInitiated).async {
let result = NotificationSoundSettings.prepareCustomFileForNotifications(path: pathSnapshot)
DispatchQueue.main.async {
guard notificationSound == NotificationSoundSettings.customFileValue else {
notificationCustomSoundStatusMessage = nil
notificationCustomSoundStatusIsError = false
return
}
guard notificationSoundCustomFilePath == pathSnapshot else { return }
switch result {
case .success:
notificationCustomSoundStatusMessage = notificationCustomSoundReadyStatusMessage(for: pathSnapshot)
notificationCustomSoundStatusIsError = false
case .failure(let issue):
let message = notificationCustomSoundIssueMessage(issue)
notificationCustomSoundStatusMessage = message
notificationCustomSoundStatusIsError = true
if showAlertOnFailure {
notificationCustomSoundErrorAlertMessage = message
showNotificationCustomSoundErrorAlert = true
}
}
}
}
}
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"
panel.title = String(
localized: "settings.notifications.sound.custom.choose.title",
defaultValue: "Choose Notification Sound"
)
panel.prompt = String(
localized: "settings.notifications.sound.custom.choose.prompt",
defaultValue: "Choose"
)
guard panel.runModal() == .OK, let url = panel.url else { return }
notificationSoundCustomFilePath = url.path
notificationSound = NotificationSoundSettings.customFileValue
previewNotificationSound()
let selectedPath = url.path
switch NotificationSoundSettings.prepareCustomFileForNotifications(path: selectedPath) {
case .success:
notificationSoundCustomFilePath = selectedPath
notificationSound = NotificationSoundSettings.customFileValue
notificationCustomSoundStatusMessage = notificationCustomSoundReadyStatusMessage(for: selectedPath)
notificationCustomSoundStatusIsError = false
previewNotificationSound()
case .failure(let issue):
let message = notificationCustomSoundIssueMessage(issue)
notificationCustomSoundErrorAlertMessage = message
showNotificationCustomSoundErrorAlert = true
refreshNotificationCustomSoundStatus()
}
}
private func handleNotificationPermissionAction() {
@ -3178,8 +3280,8 @@ struct SettingsView: View {
SettingsCardDivider()
SettingsCardRow(
"Notification Sound",
subtitle: "Sound played when a notification arrives."
String(localized: "settings.notifications.sound.title", defaultValue: "Notification Sound"),
subtitle: String(localized: "settings.notifications.sound.subtitle", defaultValue: "Sound played when a notification arrives.")
) {
VStack(alignment: .trailing, spacing: 6) {
HStack(spacing: 6) {
@ -3208,16 +3310,35 @@ struct SettingsView: View {
.lineLimit(1)
.truncationMode(.middle)
.frame(width: 170, alignment: .trailing)
Button("Choose...") {
Button(
String(
localized: "settings.notifications.sound.custom.choose.button",
defaultValue: "Choose..."
)
) {
chooseNotificationSoundFile()
}
.controlSize(.small)
Button("Clear") {
Button(
String(
localized: "settings.notifications.sound.custom.clear.button",
defaultValue: "Clear"
)
) {
notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath
refreshNotificationCustomSoundStatus()
}
.controlSize(.small)
.disabled(!hasCustomNotificationSoundFilePath)
}
if let notificationCustomSoundStatusMessage {
Text(notificationCustomSoundStatusMessage)
.font(.system(size: 11))
.foregroundStyle(notificationCustomSoundStatusIsError ? Color.red : Color.secondary)
.lineLimit(2)
.multilineTextAlignment(.trailing)
.frame(width: 260, alignment: .trailing)
}
}
}
}
@ -3870,6 +3991,13 @@ struct SettingsView: View {
browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count
browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist
reloadWorkspaceTabColorSettings()
refreshNotificationCustomSoundStatus()
}
.onChange(of: notificationSound) { _, _ in
refreshNotificationCustomSoundStatus()
}
.onChange(of: notificationSoundCustomFilePath) { _, _ in
refreshNotificationCustomSoundStatus()
}
.onChange(of: browserInsecureHTTPAllowlist) { oldValue, newValue in
// Keep draft in sync with external changes unless the user has local unsaved edits.
@ -3920,6 +4048,17 @@ struct SettingsView: View {
}
Button(String(localized: "settings.app.language.restartDialog.later", defaultValue: "Later"), role: .cancel) {}
}
.alert(
String(
localized: "settings.notifications.sound.custom.error.title",
defaultValue: "Custom Notification Sound Error"
),
isPresented: $showNotificationCustomSoundErrorAlert
) {
Button(String(localized: "common.ok", defaultValue: "OK"), role: .cancel) {}
} message: {
Text(notificationCustomSoundErrorAlertMessage)
}
}
private func relaunchApp() {
@ -3960,6 +4099,10 @@ struct SettingsView: View {
browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
notificationSound = NotificationSoundSettings.defaultValue
notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath
notificationCustomSoundStatusMessage = nil
notificationCustomSoundStatusIsError = false
showNotificationCustomSoundErrorAlert = false
notificationCustomSoundErrorAlertMessage = ""
notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit

View file

@ -6745,16 +6745,11 @@ final class NotificationDockBadgeTests: XCTestCase {
}
let sourceURL = soundsDirectory.appendingPathComponent(
"cmux-custom-notification-sound.source-\(UUID().uuidString).custtest",
isDirectory: false
)
let stagedURL = soundsDirectory.appendingPathComponent(
"cmux-custom-notification-sound.custtest",
"cmux-custom-notification-sound.source-\(UUID().uuidString).wav",
isDirectory: false
)
defer {
try? fileManager.removeItem(at: sourceURL)
try? fileManager.removeItem(at: stagedURL)
}
do {
@ -6769,8 +6764,162 @@ final class NotificationDockBadgeTests: XCTestCase {
_ = NotificationSoundSettings.sound(defaults: defaults)
guard let stagedName = NotificationSoundSettings.stagedCustomSoundName(defaults: defaults) else {
XCTFail("Expected staged custom sound name")
return
}
let stagedURL = soundsDirectory.appendingPathComponent(stagedName, isDirectory: false)
defer {
try? fileManager.removeItem(at: stagedURL)
}
XCTAssertTrue(fileManager.fileExists(atPath: sourceURL.path))
XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.path))
XCTAssertTrue(stagedName.hasPrefix("cmux-custom-notification-sound-"))
XCTAssertTrue(stagedName.hasSuffix(".wav"))
}
func testNotificationCustomUnsupportedExtensionsStageAsCaf() {
XCTAssertEqual(
NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "mp3"),
"caf"
)
XCTAssertEqual(
NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "M4A"),
"caf"
)
XCTAssertEqual(
NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "wav"),
"wav"
)
XCTAssertEqual(
NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "AIFF"),
"aiff"
)
let sourceA = URL(fileURLWithPath: "/tmp/custom-a.mp3")
let sourceB = URL(fileURLWithPath: "/tmp/custom-b.mp3")
let stagedA = NotificationSoundSettings.stagedCustomSoundFileName(
forSourceURL: sourceA,
destinationExtension: "caf"
)
let stagedB = NotificationSoundSettings.stagedCustomSoundFileName(
forSourceURL: sourceB,
destinationExtension: "caf"
)
XCTAssertNotEqual(stagedA, stagedB)
XCTAssertTrue(stagedA.hasPrefix("cmux-custom-notification-sound-"))
XCTAssertTrue(stagedA.hasSuffix(".caf"))
}
func testNotificationCustomPreparationKeepsActiveSourceMetadataSidecar() {
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.metadata-\(UUID().uuidString).wav",
isDirectory: false
)
do {
try Data("test".utf8).write(to: sourceURL, options: .atomic)
} catch {
XCTFail("Failed to write source custom sound file: \(error)")
return
}
defer {
try? fileManager.removeItem(at: sourceURL)
}
defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
defaults.set(sourceURL.path, forKey: NotificationSoundSettings.customFilePathKey)
let prepareResult = NotificationSoundSettings.prepareCustomFileForNotifications(path: sourceURL.path)
let stagedName: String
switch prepareResult {
case .success(let name):
stagedName = name
case .failure(let issue):
XCTFail("Expected custom sound preparation success, got \(issue)")
return
}
let stagedURL = soundsDirectory.appendingPathComponent(stagedName, isDirectory: false)
let metadataURL = stagedURL.appendingPathExtension("source-metadata")
defer {
try? fileManager.removeItem(at: stagedURL)
try? fileManager.removeItem(at: metadataURL)
}
XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.path))
XCTAssertTrue(fileManager.fileExists(atPath: metadataURL.path))
}
func testNotificationCustomSoundReturnsNilWhenPreparationFails() {
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 invalidSourceURL = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-invalid-sound-\(UUID().uuidString).mp3", isDirectory: false)
defer {
try? FileManager.default.removeItem(at: invalidSourceURL)
let stagedURL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
.appendingPathComponent("Library", isDirectory: true)
.appendingPathComponent("Sounds", isDirectory: true)
.appendingPathComponent("cmux-custom-notification-sound.caf", isDirectory: false)
try? FileManager.default.removeItem(at: stagedURL)
}
do {
try Data("not-audio".utf8).write(to: invalidSourceURL, options: .atomic)
} catch {
XCTFail("Failed to write invalid custom sound source: \(error)")
return
}
defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
defaults.set(invalidSourceURL.path, forKey: NotificationSoundSettings.customFilePathKey)
XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults))
}
func testNotificationCustomPreparationReportsMissingFile() {
let missingPath = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-missing-\(UUID().uuidString).wav", isDirectory: false)
.path
let result = NotificationSoundSettings.prepareCustomFileForNotifications(path: missingPath)
switch result {
case .success:
XCTFail("Expected missing file failure")
case .failure(let issue):
guard case .missingFile = issue else {
XCTFail("Expected missingFile issue, got \(issue)")
return
}
}
}
func testNotificationAuthorizationStateMappingCoversKnownUNAuthorizationStatuses() {