From c44e975855713f2e20c50878dddb8a90ed7777fe Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 20 Mar 2026 01:02:42 -0700 Subject: [PATCH] fix: preserve custom commands for focused notifications --- Sources/TerminalNotificationStore.swift | 18 ++-- cmuxTests/NotificationAndMenuBarTests.swift | 92 ++++++++++++++++++++- 2 files changed, 103 insertions(+), 7 deletions(-) diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index c3403f4f..84b7a5a1 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -1046,15 +1046,19 @@ final class TerminalNotificationStore: ObservableObject { center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } + private func resolvedNotificationTitle(for notification: TerminalNotification) -> String { + let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String + ?? "cmux" + return notification.title.isEmpty ? appName : notification.title + } + private func scheduleUserNotification(_ notification: TerminalNotification) { ensureAuthorization(origin: .notificationDelivery) { [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.title = self.resolvedNotificationTitle(for: notification) content.subtitle = notification.subtitle content.body = notification.body content.sound = NotificationSoundSettings.sound() @@ -1088,8 +1092,12 @@ final class TerminalNotificationStore: ObservableObject { } private func playSuppressedNotificationFeedback(for notification: TerminalNotification) { - _ = notification NotificationSoundSettings.playSelectedSound() + NotificationSoundSettings.runCustomCommand( + title: resolvedNotificationTitle(for: notification), + subtitle: notification.subtitle, + body: notification.body + ) } private func ensureAuthorization( diff --git a/cmuxTests/NotificationAndMenuBarTests.swift b/cmuxTests/NotificationAndMenuBarTests.swift index 5251fd50..fa358cf6 100644 --- a/cmuxTests/NotificationAndMenuBarTests.swift +++ b/cmuxTests/NotificationAndMenuBarTests.swift @@ -401,8 +401,11 @@ final class NotificationDockBadgeTests: XCTestCase { } } - func testFocusedTerminalNotificationStillRunsLocalSoundFeedbackWhenExternalDeliveryIsSuppressed() { - let appDelegate = AppDelegate.shared ?? AppDelegate() + func testFocusedTerminalNotificationStillRunsLocalSoundFeedbackWhenExternalDeliveryIsSuppressed() throws { + guard let appDelegate = AppDelegate.shared else { + XCTFail("AppDelegate.shared must be set for this test") + return + } let manager = TabManager() let store = TerminalNotificationStore.shared @@ -445,9 +448,94 @@ final class NotificationDockBadgeTests: XCTestCase { body: "" ) + let createdNotificationID = try XCTUnwrap(store.notifications.first?.id) XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) XCTAssertTrue(deliveredNotificationIDs.isEmpty) XCTAssertEqual(localFeedbackNotificationIDs.count, 1) + XCTAssertEqual(localFeedbackNotificationIDs, [createdNotificationID]) + } + + func testFocusedTerminalSuppressedNotificationRunsCustomCommand() throws { + guard let appDelegate = AppDelegate.shared else { + XCTFail("AppDelegate.shared must be set for this test") + return + } + let manager = TabManager() + let store = TerminalNotificationStore.shared + let defaults = UserDefaults.standard + let commandOutputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-notification-command-\(UUID().uuidString).txt", isDirectory: false) + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + let hadSoundValue = defaults.object(forKey: NotificationSoundSettings.key) != nil + let originalSoundValue = defaults.object(forKey: NotificationSoundSettings.key) + let hadCommandValue = defaults.object(forKey: NotificationSoundSettings.customCommandKey) != nil + let originalCommandValue = defaults.object(forKey: NotificationSoundSettings.customCommandKey) + + var deliveredNotificationIDs: [UUID] = [] + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, notification in + deliveredNotificationIDs.append(notification.id) + } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + AppFocusState.overrideIsFocused = true + defaults.set("none", forKey: NotificationSoundSettings.key) + defaults.set( + "printf '%s\\n%s\\n%s' \"$CMUX_NOTIFICATION_TITLE\" \"$CMUX_NOTIFICATION_SUBTITLE\" \"$CMUX_NOTIFICATION_BODY\" > '\(commandOutputURL.path)'", + forKey: NotificationSoundSettings.customCommandKey + ) + + defer { + store.replaceNotificationsForTesting([]) + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + if hadSoundValue { + defaults.set(originalSoundValue, forKey: NotificationSoundSettings.key) + } else { + defaults.removeObject(forKey: NotificationSoundSettings.key) + } + if hadCommandValue { + defaults.set(originalCommandValue, forKey: NotificationSoundSettings.customCommandKey) + } else { + defaults.removeObject(forKey: NotificationSoundSettings.customCommandKey) + } + try? FileManager.default.removeItem(at: commandOutputURL) + } + + guard let workspace = manager.selectedWorkspace, + let terminalPanel = workspace.focusedTerminalPanel else { + XCTFail("Expected selected workspace with a focused terminal panel") + return + } + + store.addNotification( + tabId: workspace.id, + surfaceId: terminalPanel.id, + title: "", + subtitle: "Focused subtitle", + body: "Focused body" + ) + + let commandFinished = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + FileManager.default.fileExists(atPath: commandOutputURL.path) + }, + object: NSObject() + ) + XCTAssertEqual(XCTWaiter().wait(for: [commandFinished], timeout: 2.0), .completed) + XCTAssertTrue(deliveredNotificationIDs.isEmpty) + + let output = try String(contentsOf: commandOutputURL, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + let expectedTitle = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String + ?? "cmux" + XCTAssertEqual(output.components(separatedBy: "\n"), [expectedTitle, "Focused subtitle", "Focused body"]) } func testNotificationAuthorizationStateMappingCoversKnownUNAuthorizationStatuses() {