cmux/Sources/TerminalNotificationStore.swift
Austin Wang e419fd9164
Fix remote proxy notification spam with cooldown, backoff, and SSH keepalive (#2325) (#2330)
- Add 5-minute per-host cooldown for remote error notifications
- Add exponential backoff (capped at 60s) to proxy broker and session controller retries
- Add default SSH ConnectTimeout/ServerAliveInterval/ServerAliveCountMax to detect dead connections faster
- Fix error status clearing to only reset on actual .connected state

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 18:07:39 -07:00

1418 lines
55 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 lastNotificationDateByCooldownKey: [String: Date] = [:]
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,
cooldownKey: String? = nil,
cooldownInterval: TimeInterval? = nil
) {
let now = Date()
let resolvedCooldownInterval: TimeInterval?
if let cooldownInterval, cooldownInterval.isFinite, cooldownInterval > 0 {
resolvedCooldownInterval = cooldownInterval
} else {
resolvedCooldownInterval = nil
}
if let cooldownKey,
let resolvedCooldownInterval,
let lastNotificationDate = lastNotificationDateByCooldownKey[cooldownKey],
now.timeIntervalSince(lastNotificationDate) < resolvedCooldownInterval {
return
}
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: now,
isRead: false
)
updated.insert(notification, at: 0)
notifications = updated
if let cooldownKey, resolvedCooldownInterval != nil {
lastNotificationDateByCooldownKey[cooldownKey] = now
}
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
}
}