Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -416,6 +421,8 @@ extension MobileShellComposite {
reason: "connectionRecovery.\(trigger).transportAlive",
restartEventStream: true
)
} else {
self.scheduleNotificationReconcile(client: expectedClient)
}
self.applyConnectionRecoveryOwnerState()
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -55,6 +56,7 @@ func makeRoutingConnectedStore(
],
pairedMacStore: pairedMacStore,
identityProvider: StaticIdentityProvider(userID: "routing-user"),
deliveredNotificationClearer: deliveredNotificationClearer,
pendingDismissQueue: pendingDismissQueue,
draftStore: draftStore,
taskModelCatalogClient: taskModelCatalogClient
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions ios/cmux/cmuxApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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
}
}

Expand Down
Loading
Loading