diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 35060ebe..e651a228 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -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, diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index ac345a9a..fe9c8431 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -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) + } }