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):
|
**Claude Code** (markdown link with correct derived-data path, cmd+clickable):
|
||||||
```markdown
|
```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):
|
**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:
|
After making code changes, always run the build:
|
||||||
|
|
||||||
```bash
|
```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": {
|
"settings.automation.claudeCode": {
|
||||||
"extractionState": "manual",
|
"extractionState": "manual",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,45 @@ enum NotificationSoundSettings {
|
||||||
static let customFilePathKey = "notificationSoundCustomFilePath"
|
static let customFilePathKey = "notificationSoundCustomFilePath"
|
||||||
static let defaultCustomFilePath = ""
|
static let defaultCustomFilePath = ""
|
||||||
private static let stagedCustomSoundBaseName = "cmux-custom-notification-sound"
|
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 customCommandKey = "notificationCustomCommand"
|
||||||
static let defaultCustomCommand = ""
|
static let defaultCustomCommand = ""
|
||||||
|
|
||||||
|
|
@ -97,33 +136,109 @@ enum NotificationSoundSettings {
|
||||||
}
|
}
|
||||||
|
|
||||||
static func stagedCustomSoundName(defaults: UserDefaults = .standard) -> String? {
|
static func stagedCustomSoundName(defaults: UserDefaults = .standard) -> String? {
|
||||||
guard let sourceURL = customFileURL(defaults: defaults) else { return nil }
|
let rawPath = defaults.string(forKey: customFilePathKey) ?? defaultCustomFilePath
|
||||||
let sourceExtension = sourceURL.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines)
|
guard let normalizedPath = normalizedCustomFilePath(rawPath) else {
|
||||||
guard !sourceExtension.isEmpty else {
|
NSLog("Notification custom sound unavailable: \(CustomSoundPreparationIssue.emptyPath.logMessage)")
|
||||||
NSLog("Notification custom sound requires a file extension: \(sourceURL.path)")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let destinationDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
|
let sourceURL = URL(fileURLWithPath: (normalizedPath as NSString).expandingTildeInPath)
|
||||||
.appendingPathComponent("Library", isDirectory: true)
|
let sourceExtension = sourceURL.pathExtension
|
||||||
.appendingPathComponent("Sounds", isDirectory: true)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let destinationFileName = "\(stagedCustomSoundBaseName).\(sourceExtension.lowercased())"
|
.lowercased()
|
||||||
let destinationURL = destinationDirectory.appendingPathComponent(destinationFileName, isDirectory: false)
|
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
|
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 {
|
do {
|
||||||
try fileManager.createDirectory(at: destinationDirectory, withIntermediateDirectories: true)
|
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(
|
try cleanupStaleStagedSoundFiles(
|
||||||
in: destinationDirectory,
|
in: destinationDirectory,
|
||||||
keeping: destinationFileName,
|
keeping: destinationFileName,
|
||||||
preservingSourceURL: sourceURL,
|
preservingSourceURL: sourceURL,
|
||||||
fileManager: fileManager
|
fileManager: fileManager
|
||||||
)
|
)
|
||||||
return destinationFileName
|
return .success(destinationFileName)
|
||||||
} catch {
|
} catch {
|
||||||
NSLog("Failed to stage custom notification sound: \(error)")
|
return .failure(.stagingFailed(path: sourcePath, details: error.localizedDescription))
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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? {
|
private static func normalizedCustomFilePath(_ rawPath: String) -> String? {
|
||||||
let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
return trimmed
|
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) {
|
private static func playSoundFile(at url: URL) {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard let sound = NSSound(contentsOf: url, byReference: false) else {
|
guard let sound = NSSound(contentsOf: url, byReference: false) else {
|
||||||
|
|
@ -180,15 +341,21 @@ enum NotificationSoundSettings {
|
||||||
preservingSourceURL: URL,
|
preservingSourceURL: URL,
|
||||||
fileManager: FileManager
|
fileManager: FileManager
|
||||||
) throws {
|
) throws {
|
||||||
let prefix = "\(stagedCustomSoundBaseName)."
|
let legacyPrefix = "\(stagedCustomSoundBaseName)."
|
||||||
|
let hashedPrefix = "\(stagedCustomSoundBaseName)-"
|
||||||
let normalizedSource = preservingSourceURL.standardizedFileURL
|
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) {
|
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)
|
let staleURL = directoryURL.appendingPathComponent(fileNameCandidate, isDirectory: false)
|
||||||
if staleURL.standardizedFileURL == normalizedSource {
|
if staleURL.standardizedFileURL == normalizedSource {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
try? fileManager.removeItem(at: staleURL)
|
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)
|
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(
|
private static let customCommandQueue = DispatchQueue(
|
||||||
label: "com.cmuxterm.notification-custom-command",
|
label: "com.cmuxterm.notification-custom-command",
|
||||||
qos: .utility
|
qos: .utility
|
||||||
|
|
|
||||||
|
|
@ -2853,6 +2853,10 @@ struct SettingsView: View {
|
||||||
@State private var socketPasswordDraft = ""
|
@State private var socketPasswordDraft = ""
|
||||||
@State private var socketPasswordStatusMessage: String?
|
@State private var socketPasswordStatusMessage: String?
|
||||||
@State private var socketPasswordStatusIsError = false
|
@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 telemetryValueAtLaunch = TelemetrySettings.enabledForCurrentLaunch
|
||||||
@State private var showLanguageRestartAlert = false
|
@State private var showLanguageRestartAlert = false
|
||||||
@State private var isResettingSettings = false
|
@State private var isResettingSettings = false
|
||||||
|
|
@ -2935,7 +2939,10 @@ struct SettingsView: View {
|
||||||
|
|
||||||
private var notificationSoundCustomFileDisplayName: String {
|
private var notificationSoundCustomFileDisplayName: String {
|
||||||
guard hasCustomNotificationSoundFilePath else {
|
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
|
return URL(fileURLWithPath: notificationSoundCustomFilePath).lastPathComponent
|
||||||
}
|
}
|
||||||
|
|
@ -3004,18 +3011,113 @@ struct SettingsView: View {
|
||||||
NotificationSoundSettings.previewSound(value: notificationSound)
|
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() {
|
private func chooseNotificationSoundFile() {
|
||||||
let panel = NSOpenPanel()
|
let panel = NSOpenPanel()
|
||||||
panel.canChooseFiles = true
|
panel.canChooseFiles = true
|
||||||
panel.canChooseDirectories = false
|
panel.canChooseDirectories = false
|
||||||
panel.allowsMultipleSelection = false
|
panel.allowsMultipleSelection = false
|
||||||
panel.allowedContentTypes = [.audio]
|
panel.allowedContentTypes = [.audio]
|
||||||
panel.title = "Choose Notification Sound"
|
panel.title = String(
|
||||||
panel.prompt = "Choose"
|
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 }
|
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||||
notificationSoundCustomFilePath = url.path
|
let selectedPath = url.path
|
||||||
notificationSound = NotificationSoundSettings.customFileValue
|
switch NotificationSoundSettings.prepareCustomFileForNotifications(path: selectedPath) {
|
||||||
previewNotificationSound()
|
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() {
|
private func handleNotificationPermissionAction() {
|
||||||
|
|
@ -3178,8 +3280,8 @@ struct SettingsView: View {
|
||||||
SettingsCardDivider()
|
SettingsCardDivider()
|
||||||
|
|
||||||
SettingsCardRow(
|
SettingsCardRow(
|
||||||
"Notification Sound",
|
String(localized: "settings.notifications.sound.title", defaultValue: "Notification Sound"),
|
||||||
subtitle: "Sound played when a notification arrives."
|
subtitle: String(localized: "settings.notifications.sound.subtitle", defaultValue: "Sound played when a notification arrives.")
|
||||||
) {
|
) {
|
||||||
VStack(alignment: .trailing, spacing: 6) {
|
VStack(alignment: .trailing, spacing: 6) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
|
|
@ -3208,16 +3310,35 @@ struct SettingsView: View {
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.middle)
|
.truncationMode(.middle)
|
||||||
.frame(width: 170, alignment: .trailing)
|
.frame(width: 170, alignment: .trailing)
|
||||||
Button("Choose...") {
|
Button(
|
||||||
|
String(
|
||||||
|
localized: "settings.notifications.sound.custom.choose.button",
|
||||||
|
defaultValue: "Choose..."
|
||||||
|
)
|
||||||
|
) {
|
||||||
chooseNotificationSoundFile()
|
chooseNotificationSoundFile()
|
||||||
}
|
}
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
Button("Clear") {
|
Button(
|
||||||
|
String(
|
||||||
|
localized: "settings.notifications.sound.custom.clear.button",
|
||||||
|
defaultValue: "Clear"
|
||||||
|
)
|
||||||
|
) {
|
||||||
notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath
|
notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath
|
||||||
|
refreshNotificationCustomSoundStatus()
|
||||||
}
|
}
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
.disabled(!hasCustomNotificationSoundFilePath)
|
.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
|
browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count
|
||||||
browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist
|
browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist
|
||||||
reloadWorkspaceTabColorSettings()
|
reloadWorkspaceTabColorSettings()
|
||||||
|
refreshNotificationCustomSoundStatus()
|
||||||
|
}
|
||||||
|
.onChange(of: notificationSound) { _, _ in
|
||||||
|
refreshNotificationCustomSoundStatus()
|
||||||
|
}
|
||||||
|
.onChange(of: notificationSoundCustomFilePath) { _, _ in
|
||||||
|
refreshNotificationCustomSoundStatus()
|
||||||
}
|
}
|
||||||
.onChange(of: browserInsecureHTTPAllowlist) { oldValue, newValue in
|
.onChange(of: browserInsecureHTTPAllowlist) { oldValue, newValue in
|
||||||
// Keep draft in sync with external changes unless the user has local unsaved edits.
|
// 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) {}
|
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() {
|
private func relaunchApp() {
|
||||||
|
|
@ -3960,6 +4099,10 @@ struct SettingsView: View {
|
||||||
browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
|
browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
|
||||||
notificationSound = NotificationSoundSettings.defaultValue
|
notificationSound = NotificationSoundSettings.defaultValue
|
||||||
notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath
|
notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath
|
||||||
|
notificationCustomSoundStatusMessage = nil
|
||||||
|
notificationCustomSoundStatusIsError = false
|
||||||
|
showNotificationCustomSoundErrorAlert = false
|
||||||
|
notificationCustomSoundErrorAlertMessage = ""
|
||||||
notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand
|
notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand
|
||||||
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
||||||
warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
|
warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
|
||||||
|
|
|
||||||
|
|
@ -6745,16 +6745,11 @@ final class NotificationDockBadgeTests: XCTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
let sourceURL = soundsDirectory.appendingPathComponent(
|
let sourceURL = soundsDirectory.appendingPathComponent(
|
||||||
"cmux-custom-notification-sound.source-\(UUID().uuidString).custtest",
|
"cmux-custom-notification-sound.source-\(UUID().uuidString).wav",
|
||||||
isDirectory: false
|
|
||||||
)
|
|
||||||
let stagedURL = soundsDirectory.appendingPathComponent(
|
|
||||||
"cmux-custom-notification-sound.custtest",
|
|
||||||
isDirectory: false
|
isDirectory: false
|
||||||
)
|
)
|
||||||
defer {
|
defer {
|
||||||
try? fileManager.removeItem(at: sourceURL)
|
try? fileManager.removeItem(at: sourceURL)
|
||||||
try? fileManager.removeItem(at: stagedURL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
|
@ -6769,8 +6764,162 @@ final class NotificationDockBadgeTests: XCTestCase {
|
||||||
|
|
||||||
_ = NotificationSoundSettings.sound(defaults: defaults)
|
_ = 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: sourceURL.path))
|
||||||
XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.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() {
|
func testNotificationAuthorizationStateMappingCoversKnownUNAuthorizationStatuses() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue