From 354b071ec33156cb4f0c66ceedc9a842b3c4e765 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 08:02:07 -0400 Subject: [PATCH 1/3] Keep a pending banner quiet once its pane is focused A banner scheduled while its pane was in the background still played its sound if the user focused that pane before macOS presented it. willPresent now drops .sound when the target pane is focused and notifications.soundWhenFocused is off, matching the focused-pane path from #15233. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../NotificationDeliveryCoordinator.swift | 12 +++-- Sources/AppDelegate.swift | 12 ++++- Sources/TerminalNotificationStore.swift | 12 +++++ cmuxTests/NotificationAndMenuBarTests.swift | 51 +++++++++++++++++++ 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryCoordinator.swift b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryCoordinator.swift index e153bbeb2ca8..23709761b8d3 100644 --- a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryCoordinator.swift +++ b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryCoordinator.swift @@ -75,9 +75,15 @@ public final class NotificationDeliveryCoordinator { } /// Presentation options for a notification delivered while the app is in - /// the foreground. - public func presentationOptions(for notification: UNNotification) -> UNNotificationPresentationOptions { - presentationOptions(notificationHasSound: notification.request.content.sound != nil) + /// the foreground. `keepsSoundQuiet` drops the sound for a banner whose + /// target pane became focused after the banner was scheduled. + public func presentationOptions( + for notification: UNNotification, + keepsSoundQuiet: Bool = false + ) -> UNNotificationPresentationOptions { + presentationOptions( + notificationHasSound: notification.request.content.sound != nil && !keepsSoundQuiet + ) } /// Handles a notification response from `UNUserNotificationCenterDelegate`. diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 3297cf3f4141..265605565435 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -18358,7 +18358,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { Task { @MainActor [weak self] in - let options = self?.notificationDelivery.presentationOptions(for: notification) ?? [] + guard let self else { + completionHandler([]) + return + } + let keepsSoundQuiet = self.notificationStore?.keepsPresentedNotificationQuiet( + userInfo: notification.request.content.userInfo + ) ?? false + let options = self.notificationDelivery.presentationOptions( + for: notification, + keepsSoundQuiet: keepsSoundQuiet + ) completionHandler(options) } } diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index dfa01c1d1025..a755cd1e71e7 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -1646,6 +1646,18 @@ final class TerminalNotificationStore: ObservableObject { ) } + /// A banner scheduled while its pane was in the background can reach + /// `willPresent` after the user focused that pane. It then presents without + /// sound, like a notification that arrives while the pane is focused. + func keepsPresentedNotificationQuiet(userInfo: [AnyHashable: Any]) -> Bool { + guard !NotificationSoundSettings.soundWhenFocused(), + let tabId = (userInfo["tabId"] as? String).flatMap(UUID.init(uuidString:)) else { + return false + } + let surfaceId = (userInfo["surfaceId"] as? String).flatMap(UUID.init(uuidString:)) + return shouldSuppressExternalDelivery(tabId: tabId, surfaceId: surfaceId) + } + private func shouldSuppressExternalDelivery(tabId: UUID, surfaceId: UUID?) -> Bool { let focusState = notificationFocusState(tabId: tabId, surfaceId: surfaceId) return focusState.isAppFocused diff --git a/cmuxTests/NotificationAndMenuBarTests.swift b/cmuxTests/NotificationAndMenuBarTests.swift index fd3d0e6c25cd..6cce50820620 100644 --- a/cmuxTests/NotificationAndMenuBarTests.swift +++ b/cmuxTests/NotificationAndMenuBarTests.swift @@ -1698,6 +1698,57 @@ final class NotificationDockBadgeTests: XCTestCase { XCTAssertEqual(try focusedTerminalNotificationSoundEffect(soundWhenFocused: true), true) } + /// A banner scheduled for a background pane can reach `willPresent` after + /// the user focused that pane; it must present without sound by default. + func testPendingBannerForNowFocusedPanePresentsQuietlyByDefault() throws { + XCTAssertEqual(try pendingBannerKeepsSoundQuiet(soundWhenFocused: nil), [true, false]) + } + + func testPendingBannerForNowFocusedPaneKeepsSoundWhenOptedIn() throws { + XCTAssertEqual(try pendingBannerKeepsSoundQuiet(soundWhenFocused: true), [false, false]) + } + + /// Returns `keepsPresentedNotificationQuiet` for a banner that targets the + /// focused pane, then for the same banner once the app loses focus. + private func pendingBannerKeepsSoundQuiet(soundWhenFocused: Bool?) throws -> [Bool] { + let appDelegate = try XCTUnwrap(AppDelegate.shared, "AppDelegate.shared must be set for this test") + let manager = TabManager() + let store = TerminalNotificationStore.shared + let defaults = UserDefaults.standard + let soundWhenFocusedKey = "notificationSoundWhenFocused" + + let originalTabManager = appDelegate.tabManager + let originalAppFocusOverride = AppFocusState.overrideIsFocused + let originalSoundWhenFocused = defaults.object(forKey: soundWhenFocusedKey) + appDelegate.tabManager = manager + if let soundWhenFocused { + defaults.set(soundWhenFocused, forKey: soundWhenFocusedKey) + } else { + defaults.removeObject(forKey: soundWhenFocusedKey) + } + defer { + appDelegate.tabManager = originalTabManager + AppFocusState.overrideIsFocused = originalAppFocusOverride + if let originalSoundWhenFocused { + defaults.set(originalSoundWhenFocused, forKey: soundWhenFocusedKey) + } else { + defaults.removeObject(forKey: soundWhenFocusedKey) + } + } + + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let terminalPanel = try XCTUnwrap(workspace.focusedTerminalPanel) + let userInfo: [AnyHashable: Any] = [ + "tabId": workspace.id.uuidString, + "surfaceId": terminalPanel.id.uuidString, + ] + AppFocusState.overrideIsFocused = true + let focused = store.keepsPresentedNotificationQuiet(userInfo: userInfo) + AppFocusState.overrideIsFocused = false + let unfocused = store.keepsPresentedNotificationQuiet(userInfo: userInfo) + return [focused, unfocused] + } + /// Posts one notification to the focused terminal pane and returns the /// `sound` effect its suppressed local feedback receives. private func focusedTerminalNotificationSoundEffect(soundWhenFocused: Bool?) throws -> Bool? { From 2c6a41627107b0501172f17d3508f2a2846f3e38 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 08:04:01 -0400 Subject: [PATCH 2/3] Log the foreground presentation sound decision in DEBUG builds Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/AppDelegate.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 265605565435..0c5635af69a7 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -18369,6 +18369,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent for: notification, keepsSoundQuiet: keepsSoundQuiet ) +#if DEBUG + cmuxDebugLog( + "notification.present id=\(notification.request.identifier.prefix(8)) hasSound=\(notification.request.content.sound != nil ? 1 : 0) keepsSoundQuiet=\(keepsSoundQuiet ? 1 : 0) sound=\(options.contains(.sound) ? 1 : 0)" + ) +#endif completionHandler(options) } } From e527f6e1a7d101fd17f81290ebdebfafd9480c92 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 08:27:36 -0400 Subject: [PATCH 3/3] Test the willPresent path end to end foregroundPresentationOptions(for:) takes the notification content, so cmuxTests can drive the same path willPresent uses and assert the options, not only the store's focus answer. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../NotificationDeliveryCoordinator.swift | 6 ++-- ...NotificationDeliveryCoordinatorTests.swift | 14 ++++++++ Sources/AppDelegate.swift | 34 ++++++++++--------- cmuxTests/NotificationAndMenuBarTests.swift | 31 +++++++++++------ 4 files changed, 54 insertions(+), 31 deletions(-) diff --git a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryCoordinator.swift b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryCoordinator.swift index 23709761b8d3..bee016268d1a 100644 --- a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryCoordinator.swift +++ b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryCoordinator.swift @@ -78,12 +78,10 @@ public final class NotificationDeliveryCoordinator { /// the foreground. `keepsSoundQuiet` drops the sound for a banner whose /// target pane became focused after the banner was scheduled. public func presentationOptions( - for notification: UNNotification, + for content: UNNotificationContent, keepsSoundQuiet: Bool = false ) -> UNNotificationPresentationOptions { - presentationOptions( - notificationHasSound: notification.request.content.sound != nil && !keepsSoundQuiet - ) + presentationOptions(notificationHasSound: content.sound != nil && !keepsSoundQuiet) } /// Handles a notification response from `UNUserNotificationCenterDelegate`. diff --git a/Packages/macOS/CmuxNotifications/Tests/CmuxNotificationsTests/NotificationDeliveryCoordinatorTests.swift b/Packages/macOS/CmuxNotifications/Tests/CmuxNotificationsTests/NotificationDeliveryCoordinatorTests.swift index 8073cd02f3b3..ac1b1f8bbad2 100644 --- a/Packages/macOS/CmuxNotifications/Tests/CmuxNotificationsTests/NotificationDeliveryCoordinatorTests.swift +++ b/Packages/macOS/CmuxNotifications/Tests/CmuxNotificationsTests/NotificationDeliveryCoordinatorTests.swift @@ -295,6 +295,20 @@ struct NotificationDeliveryCoordinatorTests { #expect(audible.contains(.sound)) } + @Test("presentation options drop sound for a banner whose pane became focused") + func presentationOptionsKeepSoundQuiet() { + let coordinator = makeCoordinator() + let content = UNMutableNotificationContent() + content.sound = .default + + let quiet = coordinator.presentationOptions(for: content, keepsSoundQuiet: true) + #expect(quiet.contains(.banner)) + #expect(quiet.contains(.list)) + #expect(!quiet.contains(.sound)) + + #expect(coordinator.presentationOptions(for: content).contains(.sound)) + } + @Test("Feed permission always falls back to once when always is unsupported") func feedPermissionAlwaysFallsBackToOnce() { let feed = FakeFeedReplying() diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0c5635af69a7..8335dc1dc43e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -18358,24 +18358,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { Task { @MainActor [weak self] in - guard let self else { - completionHandler([]) - return - } - let keepsSoundQuiet = self.notificationStore?.keepsPresentedNotificationQuiet( - userInfo: notification.request.content.userInfo - ) ?? false - let options = self.notificationDelivery.presentationOptions( - for: notification, - keepsSoundQuiet: keepsSoundQuiet - ) + completionHandler(self?.foregroundPresentationOptions(for: notification.request.content) ?? []) + } + } + + /// Foreground presentation for a delivered banner. A banner whose pane became + /// focused after it was scheduled presents without sound. + func foregroundPresentationOptions(for content: UNNotificationContent) -> UNNotificationPresentationOptions { + let keepsSoundQuiet = notificationStore?.keepsPresentedNotificationQuiet( + userInfo: content.userInfo + ) ?? false + let options = notificationDelivery.presentationOptions( + for: content, + keepsSoundQuiet: keepsSoundQuiet + ) #if DEBUG - cmuxDebugLog( - "notification.present id=\(notification.request.identifier.prefix(8)) hasSound=\(notification.request.content.sound != nil ? 1 : 0) keepsSoundQuiet=\(keepsSoundQuiet ? 1 : 0) sound=\(options.contains(.sound) ? 1 : 0)" - ) + cmuxDebugLog( + "notification.present hasSound=\(content.sound != nil ? 1 : 0) keepsSoundQuiet=\(keepsSoundQuiet ? 1 : 0) sound=\(options.contains(.sound) ? 1 : 0)" + ) #endif - completionHandler(options) - } + return options } /// Installs window focus routing and returns the registrations to its lifecycle owner. diff --git a/cmuxTests/NotificationAndMenuBarTests.swift b/cmuxTests/NotificationAndMenuBarTests.swift index 6cce50820620..f79c2c1aee14 100644 --- a/cmuxTests/NotificationAndMenuBarTests.swift +++ b/cmuxTests/NotificationAndMenuBarTests.swift @@ -1701,16 +1701,16 @@ final class NotificationDockBadgeTests: XCTestCase { /// A banner scheduled for a background pane can reach `willPresent` after /// the user focused that pane; it must present without sound by default. func testPendingBannerForNowFocusedPanePresentsQuietlyByDefault() throws { - XCTAssertEqual(try pendingBannerKeepsSoundQuiet(soundWhenFocused: nil), [true, false]) + XCTAssertEqual(try pendingBannerPresentsSound(soundWhenFocused: nil), [false, true]) } func testPendingBannerForNowFocusedPaneKeepsSoundWhenOptedIn() throws { - XCTAssertEqual(try pendingBannerKeepsSoundQuiet(soundWhenFocused: true), [false, false]) + XCTAssertEqual(try pendingBannerPresentsSound(soundWhenFocused: true), [true, true]) } - /// Returns `keepsPresentedNotificationQuiet` for a banner that targets the - /// focused pane, then for the same banner once the app loses focus. - private func pendingBannerKeepsSoundQuiet(soundWhenFocused: Bool?) throws -> [Bool] { + /// Returns whether `willPresent` includes `.sound` for a banner that targets + /// the focused pane, then for the same banner once the app loses focus. + private func pendingBannerPresentsSound(soundWhenFocused: Bool?) throws -> [Bool] { let appDelegate = try XCTUnwrap(AppDelegate.shared, "AppDelegate.shared must be set for this test") let manager = TabManager() let store = TerminalNotificationStore.shared @@ -1718,9 +1718,11 @@ final class NotificationDockBadgeTests: XCTestCase { let soundWhenFocusedKey = "notificationSoundWhenFocused" let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore let originalAppFocusOverride = AppFocusState.overrideIsFocused let originalSoundWhenFocused = defaults.object(forKey: soundWhenFocusedKey) appDelegate.tabManager = manager + appDelegate.notificationStore = store if let soundWhenFocused { defaults.set(soundWhenFocused, forKey: soundWhenFocusedKey) } else { @@ -1728,6 +1730,7 @@ final class NotificationDockBadgeTests: XCTestCase { } defer { appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore AppFocusState.overrideIsFocused = originalAppFocusOverride if let originalSoundWhenFocused { defaults.set(originalSoundWhenFocused, forKey: soundWhenFocusedKey) @@ -1738,15 +1741,21 @@ final class NotificationDockBadgeTests: XCTestCase { let workspace = try XCTUnwrap(manager.selectedWorkspace) let terminalPanel = try XCTUnwrap(workspace.focusedTerminalPanel) - let userInfo: [AnyHashable: Any] = [ + let content = UNMutableNotificationContent() + content.sound = .default + content.userInfo = [ "tabId": workspace.id.uuidString, "surfaceId": terminalPanel.id.uuidString, ] - AppFocusState.overrideIsFocused = true - let focused = store.keepsPresentedNotificationQuiet(userInfo: userInfo) - AppFocusState.overrideIsFocused = false - let unfocused = store.keepsPresentedNotificationQuiet(userInfo: userInfo) - return [focused, unfocused] + var presentsSound: [Bool] = [] + for appFocused in [true, false] { + AppFocusState.overrideIsFocused = appFocused + let options = appDelegate.foregroundPresentationOptions(for: content) + XCTAssertTrue(options.contains(.banner)) + XCTAssertTrue(options.contains(.list)) + presentsSound.append(options.contains(.sound)) + } + return presentsSound } /// Posts one notification to the focused terminal pane and returns the