Use non-blocking notification settings prompt

This commit is contained in:
Lawrence Chen 2026-02-25 14:04:29 -08:00
parent ca2e975a0d
commit 564ba3ae2e
2 changed files with 170 additions and 11 deletions

View file

@ -86,6 +86,24 @@ final class TerminalNotificationStore: ObservableObject {
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 init() {
userDefaultsObserver = NotificationCenter.default.addObserver(
@ -336,20 +354,71 @@ final class TerminalNotificationStore: ObservableObject {
DispatchQueue.main.async { [weak self] in
guard let self, !self.hasPromptedForSettings else { return }
self.hasPromptedForSettings = true
let alert = NSAlert()
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")
let response = alert.runModal()
guard response == .alertFirstButtonReturn else { return }
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") {
NSWorkspace.shared.open(url)
}
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)
}
}
#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()
}
#endif
private func refreshDockBadge() {
let label = Self.dockBadgeLabel(
unreadCount: unreadCount,

View file

@ -5094,6 +5094,30 @@ final class OmnibarSuggestionRankingTests: XCTestCase {
@MainActor
final class NotificationDockBadgeTests: XCTestCase {
private final class NotificationSettingsAlertSpy: NSAlert {
private(set) var beginSheetModalCallCount = 0
private(set) var runModalCallCount = 0
var nextResponse: NSApplication.ModalResponse = .alertFirstButtonReturn
override func beginSheetModal(
for sheetWindow: NSWindow,
completionHandler handler: ((NSApplication.ModalResponse) -> Void)?
) {
beginSheetModalCallCount += 1
handler?(nextResponse)
}
override func runModal() -> NSApplication.ModalResponse {
runModalCallCount += 1
return nextResponse
}
}
override func tearDown() {
TerminalNotificationStore.shared.resetNotificationSettingsPromptHooksForTesting()
super.tearDown()
}
func testDockBadgeLabelEnabledAndCounted() {
XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 1, isEnabled: true), "1")
XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 42, isEnabled: true), "42")
@ -5141,6 +5165,72 @@ final class NotificationDockBadgeTests: XCTestCase {
defaults.set(true, forKey: NotificationBadgeSettings.dockBadgeEnabledKey)
XCTAssertTrue(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults))
}
func testNotificationSettingsPromptUsesSheetAndNeverRunsModal() {
let store = TerminalNotificationStore.shared
let alertSpy = NotificationSettingsAlertSpy()
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
styleMask: [.titled],
backing: .buffered,
defer: false
)
var openedURL: URL?
store.configureNotificationSettingsPromptHooksForTesting(
windowProvider: { window },
alertFactory: { alertSpy },
scheduler: { _, block in block() },
urlOpener: { openedURL = $0 }
)
store.promptToEnableNotificationsForTesting()
let drained = expectation(description: "main queue drained")
DispatchQueue.main.async { drained.fulfill() }
wait(for: [drained], timeout: 1.0)
XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1)
XCTAssertEqual(alertSpy.runModalCallCount, 0)
XCTAssertEqual(
openedURL?.absoluteString,
"x-apple.systempreferences:com.apple.preference.notifications"
)
}
func testNotificationSettingsPromptRetriesUntilWindowExists() {
let store = TerminalNotificationStore.shared
let alertSpy = NotificationSettingsAlertSpy()
alertSpy.nextResponse = .alertSecondButtonReturn
var queuedRetryBlocks: [() -> Void] = []
var promptWindow: NSWindow?
store.configureNotificationSettingsPromptHooksForTesting(
windowProvider: { promptWindow },
alertFactory: { alertSpy },
scheduler: { _, block in queuedRetryBlocks.append(block) },
urlOpener: { _ in XCTFail("Should not open settings for Not Now response") }
)
store.promptToEnableNotificationsForTesting()
let drained = expectation(description: "main queue drained")
DispatchQueue.main.async { drained.fulfill() }
wait(for: [drained], timeout: 1.0)
XCTAssertEqual(alertSpy.beginSheetModalCallCount, 0)
XCTAssertEqual(alertSpy.runModalCallCount, 0)
XCTAssertEqual(queuedRetryBlocks.count, 1)
promptWindow = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
styleMask: [.titled],
backing: .buffered,
defer: false
)
queuedRetryBlocks.removeFirst()()
XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1)
XCTAssertEqual(alertSpy.runModalCallCount, 0)
}
}