cmux/Sources/TerminalNotificationStore.swift
Matthew Z. 818864dd4a
Fix sidebar notification persisting after being read (#1933)
* 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>
2026-03-24 20:54:41 -07:00

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
}
}