* Move UNUserNotificationCenter remove calls off main thread removeDeliveredNotifications(withIdentifiers:) and removePendingNotificationRequests(withIdentifiers:) perform synchronous XPC to usernoted. When usernoted is slow or overwhelmed, this blocks the main thread indefinitely, freezing the entire UI (confirmed via sample showing __NSXPCCONNECTION_IS_WAITING_FOR_A_SYNCHRONOUS_REPLY__ for the full sample duration on both cmux and cmux NIGHTLY). Add extension methods on UNUserNotificationCenter that dispatch these calls to a background queue. All 13 call sites in TerminalNotificationStore are fire-and-forget (void, no data flows back), so moving them off-main is safe. The @Published property mutations and dock badge updates remain on @MainActor. * Use dedicated serial queue for notification removal dispatch Replaces DispatchQueue.global(qos: .utility) with a private static serial queue. If usernoted stalls, concurrent dispatches to the global pool could exhaust threads. A dedicated queue caps concurrency at 1.
538 lines
19 KiB
Swift
538 lines
19 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import UserNotifications
|
|
|
|
// 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 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 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
|
|
}
|
|
}
|
|
|
|
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"
|
|
|
|
@Published private(set) var notifications: [TerminalNotification] = [] {
|
|
didSet {
|
|
indexes = Self.buildIndexes(for: notifications)
|
|
refreshDockBadge()
|
|
}
|
|
}
|
|
|
|
private let center = UNUserNotificationCenter.current()
|
|
private var hasRequestedAuthorization = 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 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()
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 latestNotification(forTabId tabId: UUID) -> TerminalNotification? {
|
|
indexes.latestUnreadByTabId[tabId] ?? indexes.latestByTabId[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
|
|
}
|
|
|
|
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()
|
|
if isAppFocused && isFocusedPanel {
|
|
if !idsToClear.isEmpty {
|
|
notifications = updated
|
|
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
|
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
|
|
}
|
|
return
|
|
}
|
|
|
|
if WorkspaceAutoReorderSettings.isEnabled() {
|
|
AppDelegate.shared?.tabManager?.moveTabToTop(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)
|
|
}
|
|
scheduleUserNotification(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 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 originalCount = updated.count
|
|
updated.removeAll { $0.id == id }
|
|
guard updated.count != originalCount else { return }
|
|
notifications = updated
|
|
center.removeDeliveredNotificationsOffMain(withIdentifiers: [id.uuidString])
|
|
}
|
|
|
|
func clearAll() {
|
|
guard !notifications.isEmpty else { return }
|
|
let ids = notifications.map { $0.id.uuidString }
|
|
notifications.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
|
|
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
|
|
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
|
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
|
|
}
|
|
|
|
private func scheduleUserNotification(_ notification: TerminalNotification) {
|
|
ensureAuthorization { [weak self] authorized in
|
|
guard let self, authorized else { return }
|
|
|
|
let content = UNMutableNotificationContent()
|
|
let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
|
|
?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
|
|
?? "cmux"
|
|
content.title = notification.title.isEmpty ? appName : notification.title
|
|
content.subtitle = notification.subtitle
|
|
content.body = notification.body
|
|
content.sound = UNNotificationSound.default
|
|
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)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func ensureAuthorization(_ completion: @escaping (Bool) -> Void) {
|
|
center.getNotificationSettings { [weak self] settings in
|
|
guard let self else {
|
|
completion(false)
|
|
return
|
|
}
|
|
|
|
switch settings.authorizationStatus {
|
|
case .authorized, .provisional, .ephemeral:
|
|
completion(true)
|
|
case .denied:
|
|
self.promptToEnableNotifications()
|
|
completion(false)
|
|
case .notDetermined:
|
|
self.requestAuthorizationIfNeeded(completion)
|
|
@unknown default:
|
|
completion(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func requestAuthorizationIfNeeded(_ completion: @escaping (Bool) -> Void) {
|
|
guard !hasRequestedAuthorization else {
|
|
completion(false)
|
|
return
|
|
}
|
|
hasRequestedAuthorization = true
|
|
center.requestAuthorization(options: [.alert, .sound]) { granted, _ in
|
|
completion(granted)
|
|
}
|
|
}
|
|
|
|
private func promptToEnableNotifications() {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self, !self.hasPromptedForSettings else { return }
|
|
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 = "Enable Notifications for cmux"
|
|
alert.informativeText = "Notifications are disabled for cmux. Enable them in System Settings to see alerts."
|
|
alert.addButton(withTitle: "Open Settings")
|
|
alert.addButton(withTitle: "Not Now")
|
|
alert.beginSheetModal(for: window) { [weak self] response in
|
|
guard response == .alertFirstButtonReturn,
|
|
let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") else {
|
|
return
|
|
}
|
|
self?.notificationSettingsURLOpener(url)
|
|
}
|
|
}
|
|
|
|
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 promptToEnableNotificationsForTesting() {
|
|
promptToEnableNotifications()
|
|
}
|
|
|
|
func replaceNotificationsForTesting(_ notifications: [TerminalNotification]) {
|
|
self.notifications = notifications
|
|
}
|
|
#endif
|
|
|
|
private func refreshDockBadge() {
|
|
let label = Self.dockBadgeLabel(
|
|
unreadCount: unreadCount,
|
|
isEnabled: NotificationBadgeSettings.isDockBadgeEnabled(),
|
|
runTag: TaggedRunBadgeSettings.normalizedTag()
|
|
)
|
|
NSApp?.dockTile.badgeLabel = label
|
|
}
|
|
}
|