diff --git a/CLAUDE.md b/CLAUDE.md index 03591777..18f06112 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 .app](file:///tmp/cmux-/Build/Products/Debug/cmux%20DEV%20.app) +[cmux DEV .app](file:///Users/lawrencechen/Library/Developer/Xcode/DerivedData/cmux-/Build/Products/Debug/cmux%20DEV%20.app) ======================================================= ``` **Codex** (plain text format): ``` ======================================================= -[: file:///tmp/cmux-.app](file:///tmp/cmux-.app) +[: file:///Users/lawrencechen/Library/Developer/Xcode/DerivedData/cmux-/Build/Products/Debug/cmux%20DEV%20.app](file:///Users/lawrencechen/Library/Developer/Xcode/DerivedData/cmux-/Build/Products/Debug/cmux%20DEV%20.app) ======================================================= ``` +Never use `/tmp/cmux-/...` 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 diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 3f711d05..2139e387 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -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": { diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 4d5ba1b6..5bb768cb 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -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 = [] + private static let notificationSoundSupportedExtensions: Set = [ + "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 { + 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 { + 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 diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index fdc91f13..ff392c1f 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -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 diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 5aecfac8..ffedcee4 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -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() {