diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionRecovery.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionRecovery.swift index b43333f7dcde..65dc88e07a40 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionRecovery.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionRecovery.swift @@ -392,6 +392,11 @@ extension MobileShellComposite { reason: "connectionRecovery.\(trigger)", restartEventStream: true ) + } else { + // Retaining the terminal subscription must not + // skip foreground notification cleanup. Wait for + // this probe so recovery keeps ownership of dialing. + self.scheduleNotificationReconcile(client: expectedClient) } self.applyConnectionRecoveryOwnerState() return @@ -416,6 +421,8 @@ extension MobileShellComposite { reason: "connectionRecovery.\(trigger).transportAlive", restartEventStream: true ) + } else { + self.scheduleNotificationReconcile(client: expectedClient) } self.applyConnectionRecoveryOwnerState() return diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift index b872f51ef4de..22072fb863aa 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift @@ -455,6 +455,11 @@ extension MobileShellComposite { // route to redial, so retain their same-client resubscribe fallback. if shouldResync, pairedMacStore == nil { resyncTerminalOutput(reason: "foreground", restartEventStream: true) + } else if pairedMacStore == nil, let client = remoteClient, + connectionState == .connected { + // A short background dwell preserves the event subscription, but + // may still miss a notification dismissal or a delayed push. + scheduleNotificationReconcile(client: client) } restartActiveMobileBrowserStreams() restartActiveMobileSimulatorStreams() diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingStoreBuilders.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingStoreBuilders.swift index 41774146a3f3..8f486b770ade 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingStoreBuilders.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingStoreBuilders.swift @@ -29,6 +29,7 @@ func makeRoutingConnectedStore( routeKind: CmxAttachTransportKind = .debugLoopback, terminalLaneProvider: MobileTerminalLaneProvider? = nil, draftStore: (any TerminalDraftStoring)? = nil, + deliveredNotificationClearer: any DeliveredNotificationClearing = NoopDeliveredNotificationClearer(), rpcRequestTimeoutNanoseconds: UInt64 = 30 * 1_000_000_000, taskModelCatalogClient: MobileTaskModelCatalogClient = .live() ) async throws -> MobileShellComposite { @@ -55,6 +56,7 @@ func makeRoutingConnectedStore( ], pairedMacStore: pairedMacStore, identityProvider: StaticIdentityProvider(userID: "routing-user"), + deliveredNotificationClearer: deliveredNotificationClearer, pendingDismissQueue: pendingDismissQueue, draftStore: draftStore, taskModelCatalogClient: taskModelCatalogClient diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift index 5bed1b77f97d..254e7ef19e4a 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift @@ -63,6 +63,14 @@ actor RoutingHostRouter { let terminalInputRecorder = RoutingTerminalInputRecorder() private(set) var directorySearchQueries: [String] = [] private(set) var dismisses: [(notificationIDs: [String], clientID: String?)] = [] + private(set) var notificationReconciles: [[String]] = [] + private var handledNotificationIDs: [String] = [] + private var rejectsNotificationReconcile = false + + func setNotificationReconcile(handledIDs: [String], rejects: Bool = false) { + handledNotificationIDs = handledIDs + rejectsNotificationReconcile = rejects + } private var notificationFeedMarkAllReadCount = 0 private var workspaceCreates: [WorkspaceCreateRecord] = [] /// Reject the Nth (0-based) and later paste_image requests; `nil` accepts all. @@ -233,6 +241,7 @@ actor RoutingHostRouter { var imageFormat: String? var text: String? var notificationIDs: [String]? + var deliveredIDs: [String]? var clientID: String? var groupID: String? var title: String? @@ -470,6 +479,16 @@ actor RoutingHostRouter { clientID: info.clientID )) return try? Self.resultFrame(id: id, result: [:]) + case "notification.reconcile": + let delivered = info.deliveredIDs ?? [] + notificationReconciles.append(delivered) + if rejectsNotificationReconcile { + return try? Self.errorFrame(id: id, message: "Notification state unavailable") + } + return try? Self.resultFrame(id: id, result: [ + "handled_ids": delivered.filter { handledNotificationIDs.contains($0) }, + "unread_count": 1, + ]) case "notification.feed.mark_all_read": notificationFeedMarkAllReadCount += 1 return try? Self.resultFrame(id: id, result: [ @@ -600,6 +619,7 @@ private actor RoutingTransport: CmxByteTransport { imageFormat: params?["image_format"] as? String, text: params?["text"] as? String, notificationIDs: params?["notification_ids"] as? [String], + deliveredIDs: params?["delivered_ids"] as? [String], clientID: params?["client_id"] as? String, groupID: params?["group_id"] as? String, title: params?["title"] as? String, diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellForegroundNotificationReconcileTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellForegroundNotificationReconcileTests.swift new file mode 100644 index 000000000000..1f0477277998 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellForegroundNotificationReconcileTests.swift @@ -0,0 +1,86 @@ +import Foundation +import CmuxMobilePairedMac +import Testing +@testable import CmuxMobileShell + +@MainActor +@Suite struct MobileShellForegroundNotificationReconcileTests { + @Test(arguments: [false, true]) + func shortForegroundReturnClearsReadNotificationsWithoutRestartingConnection(hasStoredPairing: Bool) async throws { + let router = RoutingHostRouter() + let clearer = RecordingDeliveredNotificationClearer() + clearer.deliveredIDs = ["read-1", "unread", "read-2", "unknown"] + let store = try await connectedStore(router: router, clearer: clearer, hasStoredPairing: hasStoredPairing) + defer { store.suspendForegroundRefresh() } + let originalClient = store.remoteClient + + // No live dismissal arrives while backgrounded. The connection survives, + // so terminal resubscription must not be needed to repair notifications. + store.suspendForegroundRefresh() + #expect(!store.shouldResyncTerminalOutputOnForeground()) + await router.setNotificationReconcile(handledIDs: ["read-1", "read-2"]) + store.resumeForegroundRefresh() + #expect(try await pollUntil { await router.notificationReconciles.count == 2 }) + await store.notificationReconcileTask?.value + + #expect(clearer.clearedIDs == [["read-1", "read-2"]]) + #expect(clearer.clearedOwners.last?.macDeviceID == "test-mac") + #expect(await router.notificationReconciles.count == 2) + #expect(clearer.badgeCounts == [1, 1]) + #expect(store.remoteClient === originalClient) + } + + @Test(arguments: [false, true]) + func unavailableReadStateKeepsDeliveredNotificationsAndBadge(hasStoredPairing: Bool) async throws { + let router = RoutingHostRouter() + let clearer = RecordingDeliveredNotificationClearer() + clearer.deliveredIDs = ["read-1", "unread"] + let store = try await connectedStore(router: router, clearer: clearer, hasStoredPairing: hasStoredPairing) + defer { store.suspendForegroundRefresh() } + + store.suspendForegroundRefresh() + await router.setNotificationReconcile(handledIDs: ["read-1"], rejects: true) + store.resumeForegroundRefresh() + #expect(try await pollUntil { await router.notificationReconciles.count == 2 }) + await store.notificationReconcileTask?.value + + #expect(await router.notificationReconciles.count == 2) + #expect(clearer.clearedIDs.isEmpty) + #expect(clearer.badgeCounts == [1]) + #expect(store.connectionState == .connected) + } + + @Test func duplicateActiveSignalsCoalesceAndEachBackgroundReturnReconciles() async throws { + let router = RoutingHostRouter() + let clearer = RecordingDeliveredNotificationClearer() + let store = try await connectedStore(router: router, clearer: clearer) + defer { store.suspendForegroundRefresh() } + + for expectedCount in 2...3 { + store.suspendForegroundRefresh() + store.resumeForegroundRefresh() + store.resumeForegroundRefresh() + #expect(try await pollUntil { await router.notificationReconciles.count == expectedCount }) + await store.notificationReconcileTask?.value + #expect(await router.notificationReconciles.count == expectedCount) + } + } + + private func connectedStore( + router: RoutingHostRouter, + clearer: RecordingDeliveredNotificationClearer, + hasStoredPairing: Bool = false + ) async throws -> MobileShellComposite { + let store = try await makeRoutingConnectedStore( + router: router, + hostCapabilities: ["events.v1", "terminal.bytes.v1"], + pairedMacStore: hasStoredPairing + ? DelayedTeamPairedMacStore(recordsByTeam: [:], blockedTeams: []) : nil, + deliveredNotificationClearer: clearer + ) + store.resumeForegroundRefresh() + #expect(try await pollUntil { clearer.badgeCounts == [1] }) + #expect(store.terminalEventListenerTask != nil) + return store + } +} diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/MobileNotificationCleanupUITestFixture.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/MobileNotificationCleanupUITestFixture.swift new file mode 100644 index 000000000000..6c8bd211c7b0 --- /dev/null +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/MobileNotificationCleanupUITestFixture.swift @@ -0,0 +1,74 @@ +#if DEBUG && targetEnvironment(simulator) +import Foundation +import OSLog +import UIKit +import UserNotifications + +/// Seeds real Notification Center entries for the foreground-cleanup UI test. +/// Read state and removal still travel through the production shell and RPC. +@MainActor +public final class MobileNotificationCleanupUITestFixture { + private let payloads: [[String: String]] + private var scheduled = false + + public init() { + let raw = ProcessInfo.processInfo.environment["CMUX_UITEST_NOTIFICATION_CLEANUP"] ?? "" + payloads = UITestConfig.mockDataEnabled + ? (try? JSONDecoder().decode([[String: String]].self, from: Data(raw.utf8))) ?? [] + : [] + } + + public func prepare() async { + guard !payloads.isEmpty else { return } + _ = try? await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .badge]) + } + + public func scheduleOnBackground() async { + guard !payloads.isEmpty, !scheduled else { return } + + // Keep the async request additions alive after the test backgrounds + // the app. Without this, the scene-phase Task can be suspended before + // UserNotifications receives the requests, leaving Notification + // Center empty and making the UI test meaningless. + let backgroundTask = UIApplication.shared.beginBackgroundTask( + withName: "cmux.notification-cleanup-ui-fixture" + ) + defer { + if backgroundTask != .invalid { + UIApplication.shared.endBackgroundTask(backgroundTask) + } + } + await scheduleNotifications() + } + + private func scheduleNotifications() async { + guard !payloads.isEmpty, !scheduled else { return } + scheduled = true + for payload in payloads { + guard let id = payload["requestID"], let title = payload["title"] else { continue } + let content = UNMutableNotificationContent() + content.title = title + content.body = "Notification cleanup verification" + content.threadIdentifier = id + if let notificationID = payload["notificationId"] { + content.userInfo = ["cmux": [ + "notificationId": notificationID, + "macDeviceId": payload["macDeviceId"] ?? "ui-test-mac", + "macInstanceTag": payload["macInstanceTag"] ?? "dev", + ]] + } + do { + try await UNUserNotificationCenter.current().add(UNNotificationRequest( + identifier: id, + content: content, + trigger: UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) + )) + } catch { + Logger(subsystem: "dev.cmux.ios", category: "notification-cleanup-test") + .error("Failed to seed notification: \(error, privacy: .public)") + } + } + } +} +#endif diff --git a/ios/cmux/cmuxApp.swift b/ios/cmux/cmuxApp.swift index 101096b44d75..2cd67a3ffe39 100644 --- a/ios/cmux/cmuxApp.swift +++ b/ios/cmux/cmuxApp.swift @@ -21,6 +21,10 @@ struct cmuxApp: App { @UIApplicationDelegateAdaptor(CmuxAppDelegate.self) private var appDelegate @Environment(\.scenePhase) private var scenePhase + #if DEBUG && targetEnvironment(simulator) + private let notificationCleanupFixture = MobileNotificationCleanupUITestFixture() + #endif + /// Erases this device's cmux data for Settings > Reset. private static let localDataEraser = MobileLocalDataEraser.current() @@ -152,7 +156,15 @@ struct cmuxApp: App { // background-and-return. .onChange(of: scenePhase, initial: true) { _, newPhase in Self.root.handleScenePhase(newPhase) + #if DEBUG && targetEnvironment(simulator) + if newPhase == .background { + Task { await notificationCleanupFixture.scheduleOnBackground() } + } + #endif } + #if DEBUG && targetEnvironment(simulator) + .task { await notificationCleanupFixture.prepare() } + #endif } } diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index bc225d7984e6..0ba53a1780b9 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -11,6 +11,87 @@ final class cmuxUITests: XCTestCase { continueAfterFailure = false } + @MainActor + func testForegroundRemovesOnlyReadDeliveredNotifications() async throws { + let server = try MobileSyncMockHostServer() + let port = try await server.start() + defer { server.stop() } + let tag = mockHostInstanceTag() + let read1 = "11111111-1111-4111-8111-111111111111" + let read2 = "22222222-2222-4222-8222-222222222222" + let unread = "33333333-3333-4333-8333-333333333333" + let notifications: [[String: String]] = [ + ["requestID": "cleanup-read-1", "title": "Read on Mac 1", "notificationId": read1], + ["requestID": "cleanup-read-2", "title": "Read on Mac 2", "notificationId": read2], + ["requestID": "cleanup-unread", "title": "Still unread", "notificationId": unread], + ["requestID": "cleanup-other", "title": "Other computer", "notificationId": read1, "macDeviceId": "other-mac"], + ["requestID": "cleanup-unrelated", "title": "Unrelated alert"], + ].map { $0.merging(["macInstanceTag": tag]) { _, tag in tag } } + let fixture = String(decoding: try JSONEncoder().encode(notifications), as: UTF8.self) + let app = launchApp(mockData: true, environment: [ + "CMUX_UITEST_ATTACH_URL": try attachURL(port: port).absoluteString, + "CMUX_UITEST_NOTIFICATION_CLEANUP": fixture, + ]) + defer { app.terminate() } + grantNotificationAuthorizationIfRequested() + waitForWorkspaceShell(in: app) + func awaitReconcile(_ minimumCount: Int = 1) async { + let received = await server.waitForRequest( + method: "notification.reconcile", minimumCount: minimumCount + ) + XCTAssertTrue(received, "Foreground must reconcile delivered notifications") + } + await awaitReconcile() + + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + func openNotificationCenter() async { + XCUIDevice.shared.press(.home) + try? await Task.sleep(nanoseconds: 2_000_000_000) + let top = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.01)) + top.press(forDuration: 0.1, thenDragTo: springboard.coordinate( + withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8) + )) + } + func capture(_ name: String) { + let attachment = XCTAttachment(screenshot: springboard.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + + await openNotificationCenter() + capture("01-delivered-before-cleanup") + + await server.setNotificationReadState(handledIDs: [read1, read2], available: false) + var nextReconcile = await server.notificationReconcileRequests().count + 1 + app.activate() + await awaitReconcile(nextReconcile) + await openNotificationCenter() + capture("02-read-state-unavailable-keeps-notifications") + + await server.setNotificationReadState(handledIDs: [read1, read2], available: true) + nextReconcile = await server.notificationReconcileRequests().count + 1 + app.activate() + await awaitReconcile(nextReconcile) + await openNotificationCenter() + capture("03-read-notifications-cleared-unread-and-unrelated-retained") + + // The next real system enumeration proves removal independently of + // Notification Center's grouping and accessibility presentation. + nextReconcile = await server.notificationReconcileRequests().count + 1 + app.activate() + await awaitReconcile(nextReconcile) + let requests = await server.notificationReconcileRequests() + XCTAssertTrue(requests.contains { Set($0) == [read1, read2, unread] }) + XCTAssertEqual(Set(requests.last ?? []), [unread]) + let log = XCTAttachment(string: String( + decoding: try JSONEncoder().encode(requests), as: UTF8.self + )) + log.name = "notification-reconcile-delivered-ids" + log.lifetime = .keepAlways + add(log) + } + @MainActor func testFilesChipsScrollThroughSheetEdge() throws { let app = launchApp(mockData: false, environment: [ @@ -10904,6 +10985,9 @@ private final class MobileSyncMockHostServer: @unchecked Sendable { private var selectedTerminalID = "terminal-build" private var workspaceCreateRequests: [WorkspaceCreateRequest] = [] private var requestCountsByMethod: [String: Int] = [:] + private var notificationReconcileDeliveries: [[String]] = [] + private var handledNotificationIDs: Set = [] + private var notificationReadStateAvailable = true private var eventSubscriptionStreamIDsByConnection: [ObjectIdentifier: Set] = [:] private var replayCounts: [String: Int] = [:] @@ -11127,6 +11211,22 @@ private final class MobileSyncMockHostServer: @unchecked Sendable { } } + func setNotificationReadState(handledIDs: [String], available: Bool) async { + await withCheckedContinuation { continuation in + queue.async { + self.handledNotificationIDs = Set(handledIDs) + self.notificationReadStateAvailable = available + continuation.resume() + } + } + } + + func notificationReconcileRequests() async -> [[String]] { + await withCheckedContinuation { continuation in + queue.async { continuation.resume(returning: self.notificationReconcileDeliveries) } + } + } + private func requestCount(for method: String) async -> Int { await withCheckedContinuation { continuation in queue.async { @@ -11448,6 +11548,16 @@ private final class MobileSyncMockHostServer: @unchecked Sendable { let id = request["id"] as? String ?? "" let params = request["params"] as? [String: Any] ?? [:] requestCountsByMethod[method, default: 0] += 1 + if method == "notification.reconcile" { + notificationReconcileDeliveries.append(params["delivered_ids"] as? [String] ?? []) + if !notificationReadStateAvailable { + return Self.frame(try JSONSerialization.data(withJSONObject: [ + "id": id, + "ok": false, + "error": ["code": "unavailable", "message": "Read state unavailable"], + ])) + } + } if method == "mobile.attach_ticket.create", !supportsManualAttachTicket { let envelope: [String: Any] = [ "id": id, @@ -11524,6 +11634,13 @@ private final class MobileSyncMockHostServer: @unchecked Sendable { ] case "mobile.host.status": result = mobileHostStatusResult() + case "notification.reconcile": + result = [ + "handled_ids": (params["delivered_ids"] as? [String] ?? []).filter { + handledNotificationIDs.contains($0) + }, + "unread_count": 1, + ] case "caffeine.status": result = ["enabled": caffeineEnabled] case "caffeine.set":