* Fix sidebar notification persisting after being read latestNotification(forTabId:) fell back to latestByTabId when no unread notifications existed, causing read notifications to persist in the sidebar even after the user marked them as read, killed all processes, or switched branches. The sidebar should only display unread notifications. Remove the fallback to latestByTabId so the sidebar notification text clears once all notifications for a workspace are read. Fixes #1642 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Update test expectation for unread-only latestNotification semantics After markRead, latestNotification(forTabId:) now returns nil since it no longer falls back to read notifications. Update the test assertion to match. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: CHE-3 <schumannzheng@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1392 lines
54 KiB
Swift
1392 lines
54 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import UserNotifications
|
|
import Bonsplit
|
|
|
|
// UNUserNotificationCenter.removeDeliveredNotifications(withIdentifiers:) and
|
|
// removePendingNotificationRequests(withIdentifiers:) perform synchronous XPC to
|
|
// usernoted under the hood. When usernoted is slow, this blocks the calling thread
|
|
// indefinitely. These helpers dispatch the calls off the main thread so they never
|
|
// freeze the UI.
|
|
extension UNUserNotificationCenter {
|
|
private static let removalQueue = DispatchQueue(
|
|
label: "com.cmuxterm.notification-removal",
|
|
qos: .utility
|
|
)
|
|
|
|
func removeDeliveredNotificationsOffMain(withIdentifiers ids: [String]) {
|
|
guard !ids.isEmpty else { return }
|
|
Self.removalQueue.async {
|
|
self.removeDeliveredNotifications(withIdentifiers: ids)
|
|
}
|
|
}
|
|
|
|
func removePendingNotificationRequestsOffMain(withIdentifiers ids: [String]) {
|
|
guard !ids.isEmpty else { return }
|
|
Self.removalQueue.async {
|
|
self.removePendingNotificationRequests(withIdentifiers: ids)
|
|
}
|
|
}
|
|
}
|
|
|
|
enum NotificationSoundSettings {
|
|
static let key = "notificationSound"
|
|
static let defaultValue = "default"
|
|
static let customFileValue = "custom_file"
|
|
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 activePlaybackSoundsLock = NSLock()
|
|
private static var activePlaybackSounds: [ObjectIdentifier: NSSound] = [:]
|
|
private static let activePlaybackSoundDelegate = ActivePlaybackSoundDelegate()
|
|
private static let notificationSoundSupportedExtensions: Set<String> = [
|
|
"aif",
|
|
"aiff",
|
|
"caf",
|
|
"wav",
|
|
]
|
|
|
|
private final class ActivePlaybackSoundDelegate: NSObject, NSSoundDelegate {
|
|
func sound(_ sound: NSSound, didFinishPlaying finishedPlaying: Bool) {
|
|
NotificationSoundSettings.releaseActivePlaybackSound(sound)
|
|
}
|
|
}
|
|
|
|
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 = ""
|
|
|
|
static let systemSounds: [(label: String, value: String)] = [
|
|
("Default", "default"),
|
|
("Basso", "Basso"),
|
|
("Blow", "Blow"),
|
|
("Bottle", "Bottle"),
|
|
("Frog", "Frog"),
|
|
("Funk", "Funk"),
|
|
("Glass", "Glass"),
|
|
("Hero", "Hero"),
|
|
("Morse", "Morse"),
|
|
("Ping", "Ping"),
|
|
("Pop", "Pop"),
|
|
("Purr", "Purr"),
|
|
("Sosumi", "Sosumi"),
|
|
("Submarine", "Submarine"),
|
|
("Tink", "Tink"),
|
|
("Custom File...", customFileValue),
|
|
("None", "none"),
|
|
]
|
|
|
|
static func sound(defaults: UserDefaults = .standard) -> UNNotificationSound? {
|
|
let value = defaults.string(forKey: key) ?? defaultValue
|
|
switch value {
|
|
case "default":
|
|
return .default
|
|
case "none":
|
|
return nil
|
|
case customFileValue:
|
|
guard let customSoundName = stagedCustomSoundName(defaults: defaults) else {
|
|
return nil
|
|
}
|
|
return UNNotificationSound(named: UNNotificationSoundName(rawValue: customSoundName))
|
|
default:
|
|
return UNNotificationSound(named: UNNotificationSoundName(rawValue: value))
|
|
}
|
|
}
|
|
|
|
static func usesSystemSound(defaults: UserDefaults = .standard) -> Bool {
|
|
let value = defaults.string(forKey: key) ?? defaultValue
|
|
switch value {
|
|
case "none":
|
|
return false
|
|
case customFileValue:
|
|
return customFileURL(defaults: defaults) != nil
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
static func isSilent(defaults: UserDefaults = .standard) -> Bool {
|
|
return (defaults.string(forKey: key) ?? defaultValue) == "none"
|
|
}
|
|
|
|
static func isCustomFileSelected(defaults: UserDefaults = .standard) -> Bool {
|
|
(defaults.string(forKey: key) ?? defaultValue) == customFileValue
|
|
}
|
|
|
|
static func stagedCustomSoundName(defaults: UserDefaults = .standard) -> String? {
|
|
let rawPath = defaults.string(forKey: customFilePathKey) ?? defaultCustomFilePath
|
|
guard let normalizedPath = normalizedCustomFilePath(rawPath) else {
|
|
NSLog("Notification custom sound unavailable: \(CustomSoundPreparationIssue.emptyPath.logMessage)")
|
|
return nil
|
|
}
|
|
|
|
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)
|
|
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 .success(destinationFileName)
|
|
} catch {
|
|
return .failure(.stagingFailed(path: sourcePath, details: error.localizedDescription))
|
|
}
|
|
}
|
|
|
|
static func customFileURL(defaults: UserDefaults = .standard) -> URL? {
|
|
guard let path = normalizedCustomFilePath(defaults.string(forKey: customFilePathKey) ?? defaultCustomFilePath) else {
|
|
return nil
|
|
}
|
|
return URL(fileURLWithPath: (path as NSString).expandingTildeInPath)
|
|
}
|
|
|
|
static func playCustomFileSound(defaults: UserDefaults = .standard) {
|
|
guard let url = customFileURL(defaults: defaults) else { return }
|
|
playSoundFile(at: url)
|
|
}
|
|
|
|
static func playCustomFileSound(path: String) {
|
|
guard let normalizedPath = normalizedCustomFilePath(path) else { return }
|
|
let url = URL(fileURLWithPath: (normalizedPath as NSString).expandingTildeInPath)
|
|
playSoundFile(at: url)
|
|
}
|
|
|
|
static func playSelectedSound(defaults: UserDefaults = .standard) {
|
|
let value = defaults.string(forKey: key) ?? defaultValue
|
|
playSound(value: value, defaults: defaults)
|
|
}
|
|
|
|
static func previewSound(value: String, defaults: UserDefaults = .standard) {
|
|
playSound(value: value, defaults: defaults)
|
|
}
|
|
|
|
private static func playSound(value: String, defaults: UserDefaults) {
|
|
switch value {
|
|
case "default":
|
|
NSSound.beep()
|
|
case "none":
|
|
break
|
|
case customFileValue:
|
|
playCustomFileSound(defaults: defaults)
|
|
default:
|
|
NSSound(named: NSSound.Name(value))?.play()
|
|
}
|
|
}
|
|
|
|
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 {
|
|
NSLog("Notification custom sound failed to load from path: \(url.path)")
|
|
return
|
|
}
|
|
retainActivePlaybackSound(sound)
|
|
sound.delegate = activePlaybackSoundDelegate
|
|
if !sound.play() {
|
|
releaseActivePlaybackSound(sound)
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func retainActivePlaybackSound(_ sound: NSSound) {
|
|
activePlaybackSoundsLock.lock()
|
|
activePlaybackSounds[ObjectIdentifier(sound)] = sound
|
|
activePlaybackSoundsLock.unlock()
|
|
}
|
|
|
|
private static func releaseActivePlaybackSound(_ sound: NSSound) {
|
|
activePlaybackSoundsLock.lock()
|
|
activePlaybackSounds.removeValue(forKey: ObjectIdentifier(sound))
|
|
activePlaybackSoundsLock.unlock()
|
|
}
|
|
|
|
private static func cleanupStaleStagedSoundFiles(
|
|
in directoryURL: URL,
|
|
keeping fileName: String,
|
|
preservingSourceURL: URL,
|
|
fileManager: FileManager
|
|
) throws {
|
|
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) {
|
|
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))
|
|
}
|
|
}
|
|
|
|
private static func copyStagedSoundIfNeeded(
|
|
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 sourceSize = sourceAttributes[.size] as? NSNumber
|
|
let destinationSize = destinationAttributes[.size] as? NSNumber
|
|
let sourceDate = sourceAttributes[.modificationDate] as? Date
|
|
let destinationDate = destinationAttributes[.modificationDate] as? Date
|
|
if sourceSize == destinationSize && sourceDate == destinationDate {
|
|
return
|
|
}
|
|
try fileManager.removeItem(at: 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(
|
|
label: "com.cmuxterm.notification-custom-command",
|
|
qos: .utility
|
|
)
|
|
|
|
static func runCustomCommand(title: String, subtitle: String, body: String, defaults: UserDefaults = .standard) {
|
|
let command = (defaults.string(forKey: customCommandKey) ?? defaultCustomCommand)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !command.isEmpty else { return }
|
|
customCommandQueue.async {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/bin/sh")
|
|
process.arguments = ["-c", command]
|
|
var env = ProcessInfo.processInfo.environment
|
|
env["CMUX_NOTIFICATION_TITLE"] = title
|
|
env["CMUX_NOTIFICATION_SUBTITLE"] = subtitle
|
|
env["CMUX_NOTIFICATION_BODY"] = body
|
|
process.environment = env
|
|
process.standardOutput = FileHandle.nullDevice
|
|
process.standardError = FileHandle.nullDevice
|
|
do {
|
|
try process.run()
|
|
} catch {
|
|
NSLog("Notification command failed to launch: \(error)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
enum NotificationBadgeSettings {
|
|
static let dockBadgeEnabledKey = "notificationDockBadgeEnabled"
|
|
static let defaultDockBadgeEnabled = true
|
|
|
|
static func isDockBadgeEnabled(defaults: UserDefaults = .standard) -> Bool {
|
|
if defaults.object(forKey: dockBadgeEnabledKey) == nil {
|
|
return defaultDockBadgeEnabled
|
|
}
|
|
return defaults.bool(forKey: dockBadgeEnabledKey)
|
|
}
|
|
}
|
|
|
|
enum NotificationPaneRingSettings {
|
|
static let enabledKey = "notificationPaneRingEnabled"
|
|
static let defaultEnabled = true
|
|
}
|
|
|
|
enum NotificationPaneFlashSettings {
|
|
static let enabledKey = "notificationPaneFlashEnabled"
|
|
static let defaultEnabled = true
|
|
|
|
static func isEnabled(defaults: UserDefaults = .standard) -> Bool {
|
|
if defaults.object(forKey: enabledKey) == nil {
|
|
return defaultEnabled
|
|
}
|
|
return defaults.bool(forKey: enabledKey)
|
|
}
|
|
}
|
|
|
|
enum TaggedRunBadgeSettings {
|
|
static let environmentKey = "CMUX_TAG"
|
|
private static let maxTagLength = 10
|
|
|
|
static func normalizedTag(from env: [String: String] = ProcessInfo.processInfo.environment) -> String? {
|
|
normalizedTag(env[environmentKey])
|
|
}
|
|
|
|
static func normalizedTag(_ rawTag: String?) -> String? {
|
|
guard var tag = rawTag?.trimmingCharacters(in: .whitespacesAndNewlines), !tag.isEmpty else {
|
|
return nil
|
|
}
|
|
if tag.count > maxTagLength {
|
|
tag = String(tag.prefix(maxTagLength))
|
|
}
|
|
return tag
|
|
}
|
|
}
|
|
|
|
enum AppFocusState {
|
|
static var overrideIsFocused: Bool?
|
|
|
|
static func isAppActive() -> Bool {
|
|
if let overrideIsFocused {
|
|
return overrideIsFocused
|
|
}
|
|
return NSApp.isActive
|
|
}
|
|
|
|
static func isAppFocused() -> Bool {
|
|
if let overrideIsFocused {
|
|
return overrideIsFocused
|
|
}
|
|
guard NSApp.isActive else { return false }
|
|
guard let keyWindow = NSApp.keyWindow, keyWindow.isKeyWindow else { return false }
|
|
// Only treat the app as "focused" for notification suppression when a main terminal window
|
|
// is key. If Settings/About/debug panels are key, we still want notifications to show.
|
|
if let raw = keyWindow.identifier?.rawValue {
|
|
return raw == "cmux.main" || raw.hasPrefix("cmux.main.")
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
enum NotificationAuthorizationState: Equatable {
|
|
case unknown
|
|
case notDetermined
|
|
case authorized
|
|
case denied
|
|
case provisional
|
|
case ephemeral
|
|
|
|
var statusLabel: String {
|
|
switch self {
|
|
case .unknown, .notDetermined:
|
|
return "Not Requested"
|
|
case .authorized:
|
|
return "Allowed"
|
|
case .denied:
|
|
return "Denied"
|
|
case .provisional:
|
|
return "Deliver Quietly"
|
|
case .ephemeral:
|
|
return "Temporary"
|
|
}
|
|
}
|
|
|
|
var allowsDelivery: Bool {
|
|
switch self {
|
|
case .authorized, .provisional, .ephemeral:
|
|
return true
|
|
case .unknown, .notDetermined, .denied:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
struct TerminalNotification: Identifiable, Hashable {
|
|
let id: UUID
|
|
let tabId: UUID
|
|
let surfaceId: UUID?
|
|
let title: String
|
|
let subtitle: String
|
|
let body: String
|
|
let createdAt: Date
|
|
var isRead: Bool
|
|
}
|
|
|
|
@MainActor
|
|
final class TerminalNotificationStore: ObservableObject {
|
|
private struct TabSurfaceKey: Hashable {
|
|
let tabId: UUID
|
|
let surfaceId: UUID?
|
|
}
|
|
|
|
private struct NotificationIndexes {
|
|
var unreadCount = 0
|
|
var unreadCountByTabId: [UUID: Int] = [:]
|
|
var unreadByTabSurface = Set<TabSurfaceKey>()
|
|
var latestUnreadByTabId: [UUID: TerminalNotification] = [:]
|
|
var latestByTabId: [UUID: TerminalNotification] = [:]
|
|
}
|
|
|
|
static let shared = TerminalNotificationStore()
|
|
|
|
static let categoryIdentifier = "com.cmuxterm.app.userNotification"
|
|
static let actionShowIdentifier = "com.cmuxterm.app.userNotification.show"
|
|
private enum AuthorizationRequestOrigin: String {
|
|
case notificationDelivery = "notification_delivery"
|
|
case settingsButton = "settings_button"
|
|
case settingsTest = "settings_test"
|
|
}
|
|
|
|
@Published private(set) var notifications: [TerminalNotification] = [] {
|
|
didSet {
|
|
indexes = Self.buildIndexes(for: notifications)
|
|
refreshDockBadge()
|
|
}
|
|
}
|
|
@Published private(set) var focusedReadIndicatorByTabId: [UUID: UUID] = [:]
|
|
@Published private(set) var authorizationState: NotificationAuthorizationState = .unknown
|
|
|
|
private let center = UNUserNotificationCenter.current()
|
|
private var hasRequestedAutomaticAuthorization = false
|
|
private var hasDeferredAuthorizationRequest = false
|
|
private var hasPromptedForSettings = false
|
|
private var userDefaultsObserver: NSObjectProtocol?
|
|
private let settingsPromptWindowRetryDelay: TimeInterval = 0.5
|
|
private let settingsPromptWindowRetryLimit = 20
|
|
private var notificationSettingsWindowProvider: () -> NSWindow? = {
|
|
NSApp.keyWindow ?? NSApp.mainWindow
|
|
}
|
|
private var notificationSettingsAlertFactory: () -> NSAlert = {
|
|
NSAlert()
|
|
}
|
|
private var notificationSettingsScheduler: (_ delay: TimeInterval, _ block: @escaping () -> Void) -> Void = {
|
|
delay,
|
|
block in
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
|
block()
|
|
}
|
|
}
|
|
private var notificationSettingsURLOpener: (URL) -> Void = { url in
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
private var notificationDeliveryHandler: (TerminalNotificationStore, TerminalNotification) -> Void = {
|
|
store,
|
|
notification in
|
|
store.scheduleUserNotification(notification)
|
|
}
|
|
private var suppressedNotificationFeedbackHandler: (TerminalNotificationStore, TerminalNotification) -> Void = {
|
|
store,
|
|
notification in
|
|
store.playSuppressedNotificationFeedback(for: notification)
|
|
}
|
|
private var indexes = NotificationIndexes()
|
|
|
|
private init() {
|
|
indexes = Self.buildIndexes(for: notifications)
|
|
userDefaultsObserver = NotificationCenter.default.addObserver(
|
|
forName: UserDefaults.didChangeNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.refreshDockBadge()
|
|
}
|
|
refreshDockBadge()
|
|
refreshAuthorizationStatus()
|
|
}
|
|
|
|
deinit {
|
|
if let userDefaultsObserver {
|
|
NotificationCenter.default.removeObserver(userDefaultsObserver)
|
|
}
|
|
}
|
|
|
|
static func dockBadgeLabel(unreadCount: Int, isEnabled: Bool, runTag: String? = nil) -> String? {
|
|
let unreadLabel: String? = {
|
|
guard isEnabled, unreadCount > 0 else { return nil }
|
|
if unreadCount > 99 {
|
|
return "99+"
|
|
}
|
|
return String(unreadCount)
|
|
}()
|
|
|
|
if let tag = TaggedRunBadgeSettings.normalizedTag(runTag) {
|
|
if let unreadLabel {
|
|
return "\(tag):\(unreadLabel)"
|
|
}
|
|
return tag
|
|
}
|
|
|
|
return unreadLabel
|
|
}
|
|
|
|
var unreadCount: Int {
|
|
indexes.unreadCount
|
|
}
|
|
|
|
private func logAuthorization(_ message: String) {
|
|
#if DEBUG
|
|
dlog("notification.auth \(message)")
|
|
#endif
|
|
NSLog("notification.auth %@", message)
|
|
}
|
|
|
|
private static func authorizationStatusLabel(_ status: UNAuthorizationStatus) -> String {
|
|
switch status {
|
|
case .notDetermined:
|
|
return "notDetermined"
|
|
case .denied:
|
|
return "denied"
|
|
case .authorized:
|
|
return "authorized"
|
|
case .provisional:
|
|
return "provisional"
|
|
case .ephemeral:
|
|
return "ephemeral"
|
|
@unknown default:
|
|
return "unknown(\(status.rawValue))"
|
|
}
|
|
}
|
|
|
|
func refreshAuthorizationStatus() {
|
|
center.getNotificationSettings { [weak self] settings in
|
|
DispatchQueue.main.async {
|
|
guard let self else { return }
|
|
self.authorizationState = Self.authorizationState(from: settings.authorizationStatus)
|
|
self.logAuthorization(
|
|
"refresh status=\(Self.authorizationStatusLabel(settings.authorizationStatus)) mapped=\(self.authorizationState.statusLabel)"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func requestAuthorizationFromSettings() {
|
|
logAuthorization("settings request tapped state=\(authorizationState.statusLabel)")
|
|
ensureAuthorization(origin: .settingsButton) { _ in }
|
|
}
|
|
|
|
func openNotificationSettings() {
|
|
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") else {
|
|
return
|
|
}
|
|
logAuthorization("open settings url=\(url.absoluteString)")
|
|
notificationSettingsURLOpener(url)
|
|
}
|
|
|
|
func sendSettingsTestNotification() {
|
|
logAuthorization("settings test tapped state=\(authorizationState.statusLabel)")
|
|
ensureAuthorization(origin: .settingsTest) { [weak self] authorized in
|
|
guard let self, authorized else { return }
|
|
|
|
let content = UNMutableNotificationContent()
|
|
content.title = "cmux test notification"
|
|
content.body = "Desktop notifications are enabled."
|
|
content.sound = NotificationSoundSettings.sound()
|
|
content.categoryIdentifier = Self.categoryIdentifier
|
|
|
|
let request = UNNotificationRequest(
|
|
identifier: "cmux.settings.test.\(UUID().uuidString)",
|
|
content: content,
|
|
trigger: nil
|
|
)
|
|
|
|
self.center.add(request) { error in
|
|
if let error {
|
|
NSLog("Failed to schedule test notification: \(error)")
|
|
self.logAuthorization("settings test schedule failed error=\(error.localizedDescription)")
|
|
} else {
|
|
self.logAuthorization("settings test schedule succeeded")
|
|
NotificationSoundSettings.runCustomCommand(
|
|
title: content.title,
|
|
subtitle: content.subtitle,
|
|
body: content.body
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func handleApplicationDidBecomeActive() {
|
|
logAuthorization("app became active deferred=\(hasDeferredAuthorizationRequest)")
|
|
if hasDeferredAuthorizationRequest {
|
|
hasDeferredAuthorizationRequest = false
|
|
ensureAuthorization(origin: .settingsButton) { _ in }
|
|
return
|
|
}
|
|
refreshAuthorizationStatus()
|
|
}
|
|
|
|
func unreadCount(forTabId tabId: UUID) -> Int {
|
|
indexes.unreadCountByTabId[tabId] ?? 0
|
|
}
|
|
|
|
func hasUnreadNotification(forTabId tabId: UUID, surfaceId: UUID?) -> Bool {
|
|
indexes.unreadByTabSurface.contains(TabSurfaceKey(tabId: tabId, surfaceId: surfaceId))
|
|
}
|
|
|
|
func hasVisibleNotificationIndicator(forTabId tabId: UUID, surfaceId: UUID?) -> Bool {
|
|
hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) ||
|
|
focusedReadIndicatorByTabId[tabId] == surfaceId
|
|
}
|
|
|
|
func latestNotification(forTabId tabId: UUID) -> TerminalNotification? {
|
|
indexes.latestUnreadByTabId[tabId]
|
|
}
|
|
|
|
func focusedReadIndicatorSurfaceId(forTabId tabId: UUID) -> UUID? {
|
|
focusedReadIndicatorByTabId[tabId]
|
|
}
|
|
|
|
func addNotification(tabId: UUID, surfaceId: UUID?, title: String, subtitle: String, body: String) {
|
|
var updated = notifications
|
|
var idsToClear: [String] = []
|
|
updated.removeAll { existing in
|
|
guard existing.tabId == tabId, existing.surfaceId == surfaceId else { return false }
|
|
idsToClear.append(existing.id.uuidString)
|
|
return true
|
|
}
|
|
|
|
if let existingIndicatorSurfaceId = focusedReadIndicatorByTabId[tabId],
|
|
existingIndicatorSurfaceId != surfaceId {
|
|
focusedReadIndicatorByTabId.removeValue(forKey: tabId)
|
|
}
|
|
|
|
let isActiveTab = AppDelegate.shared?.tabManager?.selectedTabId == tabId
|
|
let focusedSurfaceId = AppDelegate.shared?.tabManager?.focusedSurfaceId(for: tabId)
|
|
let isFocusedSurface = surfaceId == nil || focusedSurfaceId == surfaceId
|
|
let isFocusedPanel = isActiveTab && isFocusedSurface
|
|
let isAppFocused = AppFocusState.isAppFocused()
|
|
let shouldSuppressExternalDelivery = isAppFocused && isFocusedPanel
|
|
if shouldSuppressExternalDelivery {
|
|
setFocusedReadIndicator(forTabId: tabId, surfaceId: surfaceId)
|
|
}
|
|
|
|
if WorkspaceAutoReorderSettings.isEnabled() {
|
|
AppDelegate.shared?.tabManager?.moveTabToTopForNotification(tabId)
|
|
}
|
|
|
|
let notification = TerminalNotification(
|
|
id: UUID(),
|
|
tabId: tabId,
|
|
surfaceId: surfaceId,
|
|
title: title,
|
|
subtitle: subtitle,
|
|
body: body,
|
|
createdAt: Date(),
|
|
isRead: false
|
|
)
|
|
updated.insert(notification, at: 0)
|
|
notifications = updated
|
|
if !idsToClear.isEmpty {
|
|
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
|
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
|
|
}
|
|
if shouldSuppressExternalDelivery {
|
|
suppressedNotificationFeedbackHandler(self, notification)
|
|
} else {
|
|
notificationDeliveryHandler(self, notification)
|
|
}
|
|
}
|
|
|
|
func markRead(id: UUID) {
|
|
var updated = notifications
|
|
guard let index = updated.firstIndex(where: { $0.id == id }) else { return }
|
|
guard !updated[index].isRead else { return }
|
|
updated[index].isRead = true
|
|
notifications = updated
|
|
center.removeDeliveredNotificationsOffMain(withIdentifiers: [id.uuidString])
|
|
}
|
|
|
|
func markRead(forTabId tabId: UUID) {
|
|
var updated = notifications
|
|
var idsToClear: [String] = []
|
|
for index in updated.indices {
|
|
if updated[index].tabId == tabId && !updated[index].isRead {
|
|
updated[index].isRead = true
|
|
idsToClear.append(updated[index].id.uuidString)
|
|
}
|
|
}
|
|
if !idsToClear.isEmpty {
|
|
notifications = updated
|
|
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
|
}
|
|
}
|
|
|
|
func markRead(forTabId tabId: UUID, surfaceId: UUID?) {
|
|
var updated = notifications
|
|
var idsToClear: [String] = []
|
|
for index in updated.indices {
|
|
if updated[index].tabId == tabId,
|
|
updated[index].surfaceId == surfaceId,
|
|
!updated[index].isRead {
|
|
updated[index].isRead = true
|
|
idsToClear.append(updated[index].id.uuidString)
|
|
}
|
|
}
|
|
if !idsToClear.isEmpty {
|
|
notifications = updated
|
|
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
|
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
|
|
}
|
|
}
|
|
|
|
func markUnread(forTabId tabId: UUID) {
|
|
var updated = notifications
|
|
var didChange = false
|
|
for index in updated.indices {
|
|
if updated[index].tabId == tabId, updated[index].isRead {
|
|
updated[index].isRead = false
|
|
didChange = true
|
|
}
|
|
}
|
|
if didChange {
|
|
notifications = updated
|
|
}
|
|
}
|
|
|
|
func setFocusedReadIndicator(forTabId tabId: UUID, surfaceId: UUID?) {
|
|
guard let surfaceId else { return }
|
|
guard focusedReadIndicatorByTabId[tabId] != surfaceId else { return }
|
|
focusedReadIndicatorByTabId[tabId] = surfaceId
|
|
}
|
|
|
|
func clearFocusedReadIndicator(forTabId tabId: UUID, surfaceId: UUID? = nil) {
|
|
guard let existingSurfaceId = focusedReadIndicatorByTabId[tabId] else { return }
|
|
guard surfaceId == nil || existingSurfaceId == surfaceId else { return }
|
|
focusedReadIndicatorByTabId.removeValue(forKey: tabId)
|
|
}
|
|
|
|
func clearFocusedReadIndicatorIfSurfaceChanged(forTabId tabId: UUID, surfaceId: UUID?) {
|
|
guard let existingSurfaceId = focusedReadIndicatorByTabId[tabId] else { return }
|
|
guard existingSurfaceId != surfaceId else { return }
|
|
focusedReadIndicatorByTabId.removeValue(forKey: tabId)
|
|
}
|
|
|
|
func markAllRead() {
|
|
var updated = notifications
|
|
var idsToClear: [String] = []
|
|
for index in updated.indices {
|
|
if !updated[index].isRead {
|
|
updated[index].isRead = true
|
|
idsToClear.append(updated[index].id.uuidString)
|
|
}
|
|
}
|
|
if !idsToClear.isEmpty {
|
|
notifications = updated
|
|
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
|
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
|
|
}
|
|
}
|
|
|
|
func remove(id: UUID) {
|
|
var updated = notifications
|
|
let removed = updated.first(where: { $0.id == id })
|
|
let originalCount = updated.count
|
|
updated.removeAll { $0.id == id }
|
|
guard updated.count != originalCount else { return }
|
|
notifications = updated
|
|
if let removed {
|
|
clearFocusedReadIndicator(forTabId: removed.tabId, surfaceId: removed.surfaceId)
|
|
}
|
|
center.removeDeliveredNotificationsOffMain(withIdentifiers: [id.uuidString])
|
|
}
|
|
|
|
func clearAll() {
|
|
guard !notifications.isEmpty || !focusedReadIndicatorByTabId.isEmpty else { return }
|
|
let ids = notifications.map { $0.id.uuidString }
|
|
notifications.removeAll()
|
|
focusedReadIndicatorByTabId.removeAll()
|
|
center.removeDeliveredNotificationsOffMain(withIdentifiers: ids)
|
|
center.removePendingNotificationRequestsOffMain(withIdentifiers: ids)
|
|
}
|
|
|
|
func clearNotifications(forTabId tabId: UUID, surfaceId: UUID?) {
|
|
var updated: [TerminalNotification] = []
|
|
updated.reserveCapacity(notifications.count)
|
|
var idsToClear: [String] = []
|
|
for notification in notifications {
|
|
if notification.tabId == tabId, notification.surfaceId == surfaceId {
|
|
idsToClear.append(notification.id.uuidString)
|
|
} else {
|
|
updated.append(notification)
|
|
}
|
|
}
|
|
guard !idsToClear.isEmpty else { return }
|
|
notifications = updated
|
|
clearFocusedReadIndicator(forTabId: tabId, surfaceId: surfaceId)
|
|
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
|
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
|
|
}
|
|
|
|
func clearNotifications(forTabId tabId: UUID) {
|
|
var updated: [TerminalNotification] = []
|
|
updated.reserveCapacity(notifications.count)
|
|
var idsToClear: [String] = []
|
|
for notification in notifications {
|
|
if notification.tabId == tabId {
|
|
idsToClear.append(notification.id.uuidString)
|
|
} else {
|
|
updated.append(notification)
|
|
}
|
|
}
|
|
guard !idsToClear.isEmpty else { return }
|
|
notifications = updated
|
|
clearFocusedReadIndicator(forTabId: tabId)
|
|
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
|
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
|
|
}
|
|
|
|
private func resolvedNotificationTitle(for notification: TerminalNotification) -> String {
|
|
let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
|
|
?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
|
|
?? "cmux"
|
|
return notification.title.isEmpty ? appName : notification.title
|
|
}
|
|
|
|
private func scheduleUserNotification(_ notification: TerminalNotification) {
|
|
ensureAuthorization(origin: .notificationDelivery) { [weak self] authorized in
|
|
guard let self, authorized else { return }
|
|
|
|
let content = UNMutableNotificationContent()
|
|
content.title = self.resolvedNotificationTitle(for: notification)
|
|
content.subtitle = notification.subtitle
|
|
content.body = notification.body
|
|
content.sound = NotificationSoundSettings.sound()
|
|
content.categoryIdentifier = Self.categoryIdentifier
|
|
content.userInfo = [
|
|
"tabId": notification.tabId.uuidString,
|
|
"notificationId": notification.id.uuidString,
|
|
]
|
|
if let surfaceId = notification.surfaceId {
|
|
content.userInfo["surfaceId"] = surfaceId.uuidString
|
|
}
|
|
|
|
let request = UNNotificationRequest(
|
|
identifier: notification.id.uuidString,
|
|
content: content,
|
|
trigger: nil
|
|
)
|
|
|
|
self.center.add(request) { error in
|
|
if let error {
|
|
NSLog("Failed to schedule notification: \(error)")
|
|
} else {
|
|
NotificationSoundSettings.runCustomCommand(
|
|
title: content.title,
|
|
subtitle: content.subtitle,
|
|
body: content.body
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func playSuppressedNotificationFeedback(for notification: TerminalNotification) {
|
|
NotificationSoundSettings.playSelectedSound()
|
|
NotificationSoundSettings.runCustomCommand(
|
|
title: resolvedNotificationTitle(for: notification),
|
|
subtitle: notification.subtitle,
|
|
body: notification.body
|
|
)
|
|
}
|
|
|
|
private func ensureAuthorization(
|
|
origin: AuthorizationRequestOrigin,
|
|
_ completion: @escaping (Bool) -> Void
|
|
) {
|
|
logAuthorization("ensure start origin=\(origin.rawValue)")
|
|
center.getNotificationSettings { [weak self] settings in
|
|
DispatchQueue.main.async {
|
|
guard let self else {
|
|
completion(false)
|
|
return
|
|
}
|
|
|
|
self.authorizationState = Self.authorizationState(from: settings.authorizationStatus)
|
|
self.logAuthorization(
|
|
"ensure status origin=\(origin.rawValue) status=\(Self.authorizationStatusLabel(settings.authorizationStatus)) mapped=\(self.authorizationState.statusLabel) appActive=\(AppFocusState.isAppActive())"
|
|
)
|
|
switch settings.authorizationStatus {
|
|
case .authorized, .provisional, .ephemeral:
|
|
completion(true)
|
|
case .denied:
|
|
self.logAuthorization("ensure denied origin=\(origin.rawValue) prompting_settings")
|
|
self.promptToEnableNotifications()
|
|
completion(false)
|
|
case .notDetermined:
|
|
if Self.shouldDeferAutomaticAuthorizationRequest(
|
|
origin: origin,
|
|
status: settings.authorizationStatus,
|
|
isAppActive: AppFocusState.isAppActive()
|
|
) {
|
|
self.logAuthorization("ensure deferred origin=\(origin.rawValue)")
|
|
self.hasDeferredAuthorizationRequest = true
|
|
completion(false)
|
|
} else {
|
|
self.requestAuthorizationIfNeeded(origin: origin, completion)
|
|
}
|
|
@unknown default:
|
|
self.logAuthorization("ensure unknown status origin=\(origin.rawValue)")
|
|
completion(false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func requestAuthorizationIfNeeded(
|
|
origin: AuthorizationRequestOrigin,
|
|
_ completion: @escaping (Bool) -> Void
|
|
) {
|
|
let isAutomaticRequest = origin == .notificationDelivery
|
|
guard Self.shouldRequestAuthorization(
|
|
isAutomaticRequest: isAutomaticRequest,
|
|
hasRequestedAutomaticAuthorization: hasRequestedAutomaticAuthorization
|
|
) else {
|
|
logAuthorization(
|
|
"request blocked origin=\(origin.rawValue) automatic=\(isAutomaticRequest) hasRequestedAutomatic=\(hasRequestedAutomaticAuthorization)"
|
|
)
|
|
completion(false)
|
|
return
|
|
}
|
|
if isAutomaticRequest {
|
|
hasRequestedAutomaticAuthorization = true
|
|
}
|
|
hasDeferredAuthorizationRequest = false
|
|
logAuthorization(
|
|
"request starting origin=\(origin.rawValue) automatic=\(isAutomaticRequest) hasRequestedAutomatic=\(hasRequestedAutomaticAuthorization)"
|
|
)
|
|
center.requestAuthorization(options: [.alert, .sound]) { granted, error in
|
|
DispatchQueue.main.async {
|
|
if granted {
|
|
self.authorizationState = .authorized
|
|
} else {
|
|
self.refreshAuthorizationStatus()
|
|
}
|
|
self.logAuthorization(
|
|
"request callback origin=\(origin.rawValue) granted=\(granted) error=\(error?.localizedDescription ?? "nil") mapped=\(self.authorizationState.statusLabel)"
|
|
)
|
|
completion(granted)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func promptToEnableNotifications() {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self, !self.hasPromptedForSettings else { return }
|
|
self.logAuthorization("prompt settings shown")
|
|
self.hasPromptedForSettings = true
|
|
self.presentNotificationSettingsPrompt(attempt: 0)
|
|
}
|
|
}
|
|
|
|
private func presentNotificationSettingsPrompt(attempt: Int) {
|
|
guard let window = notificationSettingsWindowProvider() else {
|
|
guard attempt < settingsPromptWindowRetryLimit else {
|
|
// If no window is available after retries, allow a future denied callback
|
|
// to prompt again when the app has a key/main window.
|
|
hasPromptedForSettings = false
|
|
return
|
|
}
|
|
notificationSettingsScheduler(settingsPromptWindowRetryDelay) { [weak self] in
|
|
self?.presentNotificationSettingsPrompt(attempt: attempt + 1)
|
|
}
|
|
return
|
|
}
|
|
|
|
let alert = notificationSettingsAlertFactory()
|
|
alert.messageText = String(localized: "dialog.enableNotifications.title", defaultValue: "Enable Notifications for cmux")
|
|
alert.informativeText = String(localized: "dialog.enableNotifications.message", defaultValue: "Notifications are disabled for cmux. Enable them in System Settings to see alerts.")
|
|
alert.addButton(withTitle: String(localized: "dialog.enableNotifications.openSettings", defaultValue: "Open Settings"))
|
|
alert.addButton(withTitle: String(localized: "dialog.enableNotifications.notNow", defaultValue: "Not Now"))
|
|
alert.beginSheetModal(for: window) { [weak self] response in
|
|
guard response == .alertFirstButtonReturn else {
|
|
return
|
|
}
|
|
self?.openNotificationSettings()
|
|
}
|
|
}
|
|
|
|
static func authorizationState(from status: UNAuthorizationStatus) -> NotificationAuthorizationState {
|
|
switch status {
|
|
case .authorized:
|
|
return .authorized
|
|
case .denied:
|
|
return .denied
|
|
case .notDetermined:
|
|
return .notDetermined
|
|
case .provisional:
|
|
return .provisional
|
|
case .ephemeral:
|
|
return .ephemeral
|
|
@unknown default:
|
|
return .unknown
|
|
}
|
|
}
|
|
|
|
static func shouldDeferAutomaticAuthorizationRequest(
|
|
status: UNAuthorizationStatus,
|
|
isAppActive: Bool
|
|
) -> Bool {
|
|
status == .notDetermined && !isAppActive
|
|
}
|
|
|
|
static func shouldRequestAuthorization(
|
|
isAutomaticRequest: Bool,
|
|
hasRequestedAutomaticAuthorization: Bool
|
|
) -> Bool {
|
|
guard isAutomaticRequest else { return true }
|
|
return !hasRequestedAutomaticAuthorization
|
|
}
|
|
|
|
private static func shouldDeferAutomaticAuthorizationRequest(
|
|
origin: AuthorizationRequestOrigin,
|
|
status: UNAuthorizationStatus,
|
|
isAppActive: Bool
|
|
) -> Bool {
|
|
guard origin == .notificationDelivery else { return false }
|
|
return shouldDeferAutomaticAuthorizationRequest(status: status, isAppActive: isAppActive)
|
|
}
|
|
|
|
private static func buildIndexes(for notifications: [TerminalNotification]) -> NotificationIndexes {
|
|
var indexes = NotificationIndexes()
|
|
for notification in notifications {
|
|
if indexes.latestByTabId[notification.tabId] == nil {
|
|
indexes.latestByTabId[notification.tabId] = notification
|
|
}
|
|
guard !notification.isRead else { continue }
|
|
indexes.unreadCount += 1
|
|
indexes.unreadCountByTabId[notification.tabId, default: 0] += 1
|
|
indexes.unreadByTabSurface.insert(
|
|
TabSurfaceKey(tabId: notification.tabId, surfaceId: notification.surfaceId)
|
|
)
|
|
if indexes.latestUnreadByTabId[notification.tabId] == nil {
|
|
indexes.latestUnreadByTabId[notification.tabId] = notification
|
|
}
|
|
}
|
|
return indexes
|
|
}
|
|
|
|
#if DEBUG
|
|
func configureNotificationSettingsPromptHooksForTesting(
|
|
windowProvider: @escaping () -> NSWindow?,
|
|
alertFactory: @escaping () -> NSAlert,
|
|
scheduler: @escaping (_ delay: TimeInterval, _ block: @escaping () -> Void) -> Void,
|
|
urlOpener: @escaping (URL) -> Void
|
|
) {
|
|
notificationSettingsWindowProvider = windowProvider
|
|
notificationSettingsAlertFactory = alertFactory
|
|
notificationSettingsScheduler = scheduler
|
|
notificationSettingsURLOpener = urlOpener
|
|
hasPromptedForSettings = false
|
|
}
|
|
|
|
func resetNotificationSettingsPromptHooksForTesting() {
|
|
notificationSettingsWindowProvider = { NSApp.keyWindow ?? NSApp.mainWindow }
|
|
notificationSettingsAlertFactory = { NSAlert() }
|
|
notificationSettingsScheduler = { delay, block in
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
|
block()
|
|
}
|
|
}
|
|
notificationSettingsURLOpener = { url in
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
hasPromptedForSettings = false
|
|
}
|
|
|
|
func configureNotificationDeliveryHandlerForTesting(
|
|
_ handler: @escaping (TerminalNotificationStore, TerminalNotification) -> Void
|
|
) {
|
|
notificationDeliveryHandler = handler
|
|
}
|
|
|
|
func resetNotificationDeliveryHandlerForTesting() {
|
|
notificationDeliveryHandler = { store, notification in
|
|
store.scheduleUserNotification(notification)
|
|
}
|
|
}
|
|
|
|
func configureSuppressedNotificationFeedbackHandlerForTesting(
|
|
_ handler: @escaping (TerminalNotificationStore, TerminalNotification) -> Void
|
|
) {
|
|
suppressedNotificationFeedbackHandler = handler
|
|
}
|
|
|
|
func resetSuppressedNotificationFeedbackHandlerForTesting() {
|
|
suppressedNotificationFeedbackHandler = { store, notification in
|
|
store.playSuppressedNotificationFeedback(for: notification)
|
|
}
|
|
}
|
|
|
|
func promptToEnableNotificationsForTesting() {
|
|
promptToEnableNotifications()
|
|
}
|
|
|
|
func replaceNotificationsForTesting(_ notifications: [TerminalNotification]) {
|
|
self.notifications = notifications
|
|
focusedReadIndicatorByTabId.removeAll()
|
|
}
|
|
#endif
|
|
|
|
private func refreshDockBadge() {
|
|
let label = Self.dockBadgeLabel(
|
|
unreadCount: unreadCount,
|
|
isEnabled: NotificationBadgeSettings.isDockBadgeEnabled(),
|
|
runTag: TaggedRunBadgeSettings.normalizedTag()
|
|
)
|
|
NSApp?.dockTile.badgeLabel = label
|
|
}
|
|
}
|