Fix custom notification sound staging reliability (#919)
This commit is contained in:
parent
39a0da2b7e
commit
26bef7316e
5 changed files with 834 additions and 33 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue