Use non-blocking notification settings prompt
This commit is contained in:
parent
ca2e975a0d
commit
564ba3ae2e
2 changed files with 170 additions and 11 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue