From 05bddfeeefac933aafb2c05d630a4c8a0117b1fd Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:00:09 -0700 Subject: [PATCH 01/13] ios: back the device list with the cross-device sync DO (durable, survives updates) Wires the already-built-but-dormant CmuxSyncStore into the iOS app so the device list is a durable, locally-cached, DO-synced collection instead of a volatile per-launch GET /api/devices read masked by an all-or-nothing fallback. Root cause of the lost-devices-on-update bug: deviceTreeDevices was registry XOR local (returns the registry list entirely if non-empty, else local paired Macs), and the registry result is in-memory and refetched each launch. Any saved Mac the registry did not currently list was masked out, and nothing durable backed the list across an update. This is the deferred Phase-2 wiring from plans/feat-do-device-list/DESIGN.md (server + package shipped; app integration was the gap): - CmuxMobileShell + cmuxFeature now depend on CmuxSyncStore; the root scene opens cmux-sync.sqlite3 next to paired-macs.sqlite3 (Application Support, survives updates) and injects it + the resolved mobileDeviceListLocalFirst flag. - New PresenceSyncTransport: a SyncTransport over its own authenticated /v1/presence/subscribe WebSocket (isolated from the live presence stream), so sync/v1 snapshot/delta frames ride alongside presence noise the client ignores. - MobileShellComposite runs a sync subscription mirroring the presence backoff loop, seeds provisional rows from the existing paired-Mac store via PairedMacMigration on first launch (so the list renders instantly pre-frame and an offline Mac is never lost), and reads the device list from DeviceSyncFacade when the flag is on. Flag OFF keeps today's /api/devices path verbatim. Cross-device: the Mac already publishes its record to the team's DO via presence heartbeat, so a fresh sign-in on another device subscribes and renders it with no setup. Gated DEBUG-on/Release-off; production list behavior is unchanged until the flag is flipped after dogfood. Tests (swift test, all green): device records survive a fresh store on the same file (the update simulation); a provisional seed renders and survives a snapshot that omits it; a snapshot from another device streams in and renders; the composite reads the sync store when the flag is on and ignores it when off. (Pre-existing MobileShellRenderGridLivenessTests fail identically on clean main.) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../DeviceListSyncWiringTests.swift | 114 +++++++++++++ Packages/iOS/CmuxMobileShell/Package.swift | 3 + .../MobileShellComposite.swift | 155 ++++++++++++++++++ .../PresenceSyncTransport.swift | 111 +++++++++++++ .../DeviceListLocalFirstWiringTests.swift | 67 ++++++++ ios/cmuxPackage/Package.swift | 3 + .../cmuxFeature/CMUXMobileRootScene.swift | 51 ++++++ 7 files changed, 504 insertions(+) create mode 100644 Packages/Shared/CmuxSyncStore/Tests/CmuxSyncStoreTests/DeviceListSyncWiringTests.swift create mode 100644 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSyncTransport.swift create mode 100644 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift diff --git a/Packages/Shared/CmuxSyncStore/Tests/CmuxSyncStoreTests/DeviceListSyncWiringTests.swift b/Packages/Shared/CmuxSyncStore/Tests/CmuxSyncStoreTests/DeviceListSyncWiringTests.swift new file mode 100644 index 000000000000..fdfc1b615910 --- /dev/null +++ b/Packages/Shared/CmuxSyncStore/Tests/CmuxSyncStoreTests/DeviceListSyncWiringTests.swift @@ -0,0 +1,114 @@ +import CMUXMobileCore +import CmuxMobilePairedMac +import CmuxMobileShellModel +import Foundation +import Testing +@testable import CmuxSyncStore + +/// Tests for the guarantees the iOS device-list wiring must hold: devices +/// survive an app update, and a fresh sign-in on another device sees them. +/// (Shared helpers `TEAM`/`COLL`/`T0_MS`/`makeStore`/`deviceRecord`/`sortKey` +/// live in `CmuxSyncStoreTests.swift` in this same test target.) +@Suite struct DeviceListUpdateSurvivalTests { + /// The core guarantee: device records written to the on-disk sync store are + /// still there — with display name, and resumable cursor/epoch — when a + /// brand-new store + facade open the same file, which is exactly what an app + /// version update does (same Application Support path, new process). + @Test func devicesSurviveStoreReopenAcrossUpdate() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let url = dir.appendingPathComponent("cmux-sync.sqlite3") + + // First app version: a devices snapshot from the DO lands and commits. + do { + let store = try CmuxSyncStore(databaseURL: url) + try await store.applySnapshot( + teamID: TEAM, collection: COLL, snapshotRev: 2, epoch: 1, + records: [ + try deviceRecord(id: "mac-A", rev: 1, displayName: "Lawrence's MacBook Pro"), + try deviceRecord(id: "mac-B", rev: 2, displayName: "Studio"), + ], + sortKeyFor: sortKey, now: Date() + ) + } + + // Second app version: a fresh store + facade on the same file (update). + let reopened = try CmuxSyncStore(databaseURL: url) + let devices = try await DeviceSyncFacade(store: reopened).registryDevices(teamID: TEAM) + #expect(Set(devices.map(\.deviceId)) == ["mac-A", "mac-B"]) + #expect(devices.first(where: { $0.deviceId == "mac-A" })?.displayName == "Lawrence's MacBook Pro") + // Cursor + epoch persisted, so the next launch resumes via deltas (no + // wholesale re-snapshot, and no window where the list is empty). + #expect(try await reopened.cursor(teamID: TEAM, collection: COLL) == 2) + #expect(try await reopened.epoch(teamID: TEAM, collection: COLL) == 1) + } + + /// First-launch-after-update seeding: a provisional row from the local + /// paired-Mac migration renders via the facade AND survives a later DO + /// snapshot that does not know about that Mac (offline at update time), so an + /// existing device is never lost. + @Test func provisionalSeedRendersAndSurvivesSnapshotOmission() async throws { + let (store, dir) = try makeStore() + defer { try? FileManager.default.removeItem(at: dir) } + + // The migration seeds the phone's existing Mac as a provisional rev==0 row. + let provisional = try JSONEncoder().encode(SyncedDeviceRecord( + deviceId: "mac-local", platform: "mac", displayName: "Old Mac", ownerUserId: nil, + lastSeenAtAtRev: T0_MS, + instances: [.init(tag: "default", routes: [], lastSeenAtAtRev: T0_MS)] + )) + try await store.seedProvisional( + teamID: TEAM, collection: COLL, recordID: "mac-local", + payloadJSON: provisional, sortKey: T0_MS, now: Date() + ) + // A DO snapshot arrives that only knows a DIFFERENT device. + try await store.applySnapshot( + teamID: TEAM, collection: COLL, snapshotRev: 1, epoch: 1, + records: [try deviceRecord(id: "mac-other", rev: 1)], + sortKeyFor: sortKey, now: Date() + ) + + let devices = try await DeviceSyncFacade(store: store).registryDevices(teamID: TEAM) + #expect(Set(devices.map(\.deviceId)) == ["mac-local", "mac-other"]) + } +} + +@Suite struct DeviceListCrossDeviceReadTests { + /// Replays a scripted frame set; records nothing it does not need to. + final class ScriptedTransport: SyncTransport, @unchecked Sendable { + let scripted: [Data] + init(scripted: [Data]) { self.scripted = scripted } + func send(_ data: Data) async throws {} + func frames() -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + for frame in scripted { continuation.yield(frame) } + continuation.finish() + } + } + } + + /// Cross-device "just works": a `devices` snapshot the Mac published (so, a + /// device the user only ever paired from ANOTHER phone) streams over the + /// transport, lands in the local store, and renders via the facade with no + /// local pairing on this device. + @Test func snapshotFromAnotherDeviceRendersLocally() async throws { + let (store, dir) = try makeStore() + defer { try? FileManager.default.removeItem(at: dir) } + let applier = SyncFrameApplier(store: store, teamID: TEAM, sortKeyFor: sortKey) + let snapshot = Data(#"{"type":"sync.snapshot","collection":"devices","snapshotRev":1,"records":[{"id":"mac-remote","rev":1,"updatedAt":1,"deleted":false,"payload":{"deviceId":"mac-remote","platform":"mac","displayName":"Office Mac","lastSeenAtAtRev":1750000000000,"instances":[]}}],"complete":true}"#.utf8) + // A presence tick shares the socket; the client must ignore it. + let presenceNoise = Data(#"{"type":"seen","deviceId":"mac-remote","tag":"default","lastSeenAt":1}"#.utf8) + let client = SyncClient( + transport: ScriptedTransport(scripted: [presenceNoise, snapshot]), + applier: applier, + collections: [COLL] + ) + try await client.run() + + let devices = try await DeviceSyncFacade(store: store).registryDevices(teamID: TEAM) + #expect(devices.map(\.deviceId) == ["mac-remote"]) + #expect(devices.first?.displayName == "Office Mac") + } +} diff --git a/Packages/iOS/CmuxMobileShell/Package.swift b/Packages/iOS/CmuxMobileShell/Package.swift index e57418f0170d..dbcb640277c2 100644 --- a/Packages/iOS/CmuxMobileShell/Package.swift +++ b/Packages/iOS/CmuxMobileShell/Package.swift @@ -17,6 +17,7 @@ let package = Package( dependencies: [ .package(path: "../../Shared/CMUXMobileCore"), .package(path: "../../Shared/CmuxAgentChat"), + .package(path: "../../Shared/CmuxSyncStore"), .package(path: "../CmuxMobileDiagnostics"), .package(path: "../CmuxMobilePairedMac"), .package(path: "../CmuxMobileRPC"), @@ -30,6 +31,7 @@ let package = Package( dependencies: [ "CMUXMobileCore", "CmuxAgentChat", + "CmuxSyncStore", "CmuxMobileDiagnostics", "CmuxMobilePairedMac", "CmuxMobileRPC", @@ -48,6 +50,7 @@ let package = Package( dependencies: [ "CmuxMobileShell", "CMUXMobileCore", + "CmuxSyncStore", "CmuxMobilePairedMac", "CmuxMobileRPC", "CmuxMobileShellModel", diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index d36532176b2f..7aff59d0b7d7 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -5,6 +5,7 @@ public import CmuxMobileRPC public import CmuxMobileShellModel internal import CmuxMobileSupport public import CmuxMobileTransport +public import CmuxSyncStore public import Foundation import Observation internal import OSLog @@ -87,6 +88,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // down (and blank the map) the moment the user signs out so a // shared device never renders the previous account's devices. evaluatePresenceSubscription() + evaluateSyncSubscription() } } public private(set) var connectionState: MobileConnectionState { @@ -473,6 +475,21 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// Optional and failure-tolerant like the registry: when `nil` or down, the /// device tree simply keeps its registry "last seen" hints. private let presence: (any PresenceSubscribing)? + /// Local-first device-list sync store (DESIGN.md feat-do-device-list). When + /// present AND `deviceListLocalFirst` is on, the device list is read from + /// this durable, DO-synced cache instead of a live `/api/devices` call, so it + /// survives app updates and appears on any device signed into the account. + /// `nil` keeps today's registry-driven list. + private let syncStore: (any CmuxSyncStoring)? + /// Resolved `mobileDeviceListLocalFirst` flag (DEBUG-on/Release-off). Gates + /// the whole sync path; OFF preserves the existing `/api/devices` behavior. + private let deviceListLocalFirst: Bool + /// Resolves the current team for the sync applier/read scope. Mirrors the + /// presence client's team scoping. + private let syncTeamIDProvider: (@Sendable () async -> String?)? + /// Builds a fresh `SyncTransport` (its own authenticated presence WebSocket) + /// pinned to the given team id, one per subscription attempt. + private let makeSyncTransport: (@Sendable (String) -> any SyncTransport)? private let identityProvider: (any MobileIdentityProviding)? private let reachability: any ReachabilityProviding // Internal (not private): used by the dismiss-sync extension file. @@ -652,6 +669,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { pairedMacStore: (any MobilePairedMacStoring)? = nil, deviceRegistry: (any DeviceRegistryRefreshing)? = nil, presence: (any PresenceSubscribing)? = nil, + syncStore: (any CmuxSyncStoring)? = nil, + deviceListLocalFirst: Bool = false, + syncTeamIDProvider: (@Sendable () async -> String?)? = nil, + makeSyncTransport: (@Sendable (String) -> any SyncTransport)? = nil, clientIDRepository: MobileClientIDRepository = MobileClientIDRepository(defaults: .standard), identityProvider: (any MobileIdentityProviding)? = nil, reachability: any ReachabilityProviding = ReachabilityService(), @@ -669,6 +690,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.pairedMacStore = pairedMacStore self.deviceRegistry = deviceRegistry self.presence = presence + self.syncStore = syncStore + self.deviceListLocalFirst = deviceListLocalFirst + self.syncTeamIDProvider = syncTeamIDProvider + self.makeSyncTransport = makeSyncTransport self.identityProvider = identityProvider self.reachability = reachability self.deliveredNotificationClearer = deliveredNotificationClearer @@ -733,6 +758,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { isolated deinit { presenceTask?.cancel() + syncTask?.cancel() networkPathObservationTask?.cancel() terminalEventListenerTask?.cancel() terminalSubscriptionStartTask?.cancel() @@ -867,6 +893,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // Covers stores constructed already-signed-in (no isSignedIn edge) and // restarts a subscription torn down while backgrounded. evaluatePresenceSubscription() + evaluateSyncSubscription() resyncTerminalOutput(reason: "foreground", restartEventStream: true) } @@ -1468,6 +1495,22 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// leads with the host the user is on. Mirrors ``loadPairedMacs()``: signed /// out yields an empty list. public func loadRegistryDevices() async { + // Local-first: when the flag is on and a sync store is wired, the device + // list is the durable, DO-synced, cross-device collection read from the + // local store (no network). The background sync subscription keeps it + // fresh; this just serves the instant launch read. Flag OFF keeps the + // `/api/devices` path below verbatim. + if deviceListLocalFirst, syncStore != nil { + guard isSignedIn else { + registryDevices = [] + return + } + let teamID = (await syncTeamIDProvider?()) ?? "" + if !teamID.isEmpty { + await reloadDeviceListFromSyncStore(teamID: teamID) + } + return + } guard isSignedIn, let deviceRegistry else { registryDevices = [] return @@ -1575,6 +1618,118 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } } + // MARK: - Device-list sync (local-first, cross-device) + + /// The local-first device-list sync subscription. Mirrors + /// ``presenceTask``: it runs while signed in with the flag on and a store + /// injected, and is torn down on sign-out. + private var syncTask: Task? + + /// Start or stop the device-list sync to match the session + flag. Called + /// from the `isSignedIn` edge and `resumeForegroundRefresh()`, exactly like + /// ``evaluatePresenceSubscription()``. + private func evaluateSyncSubscription() { + if isSignedIn, deviceListLocalFirst, syncStore != nil, makeSyncTransport != nil { + startSyncSubscription() + } else { + syncTask?.cancel() + syncTask = nil + } + } + + /// Run the sync/v1 subscription with the same 1s..60s backoff as presence. + /// Each attempt resolves the current team, runs the one-time local→DO seed so + /// the list renders instantly from the phone's existing paired Macs, then + /// streams `devices` snapshot/delta frames into the local sync store. A + /// committed frame reloads the rendered list from the store. + private func startSyncSubscription() { + guard syncTask == nil, deviceListLocalFirst, + let syncStore, let makeSyncTransport else { return } + syncTask = Task { @MainActor [weak self] in + let clock = ContinuousClock() + var backoff: Duration = .seconds(1) + var didSeed = false + while !Task.isCancelled { + guard let self else { return } + let teamID = (await self.syncTeamIDProvider?()) ?? "" + guard !teamID.isEmpty else { + // Team not resolved yet (session still settling); retry soon. + guard (try? await clock.sleep(for: .seconds(1))) != nil else { return } + continue + } + // One-time transparent local→DO seed (DESIGN.md §6): provisional + // rev==0 rows from the existing paired-Mac store render instantly + // before any frame and survive snapshot reconciliation. Idempotent. + if !didSeed, let pairedMacStore = self.pairedMacStore, + let accountID = self.identityProvider?.currentUserID { + do { + _ = try await PairedMacMigration(pairedStore: pairedMacStore, syncStore: syncStore) + .runIfNeeded(accountID: accountID, teamID: teamID) + didSeed = true + await self.reloadDeviceListFromSyncStore(teamID: teamID) + } catch { + mobileShellLog.debug( + "paired-mac sync seed failed: \(String(describing: error), privacy: .public)" + ) + } + } + do { + let applier = SyncFrameApplier( + store: syncStore, + teamID: teamID, + sortKeyFor: DeviceSyncFacade.sortKey(for:), + allowedCollections: [devicesSyncCollection] + ) + let client = SyncClient( + transport: makeSyncTransport(teamID), + applier: applier, + collections: [devicesSyncCollection], + onApplied: { [weak self] in + await self?.reloadDeviceListFromSyncStore(teamID: teamID) + } + ) + try await client.run() + backoff = .seconds(1) + } catch is CancellationError { + return + } catch { + mobileShellLog.debug( + "device sync stream ended: \(String(describing: error), privacy: .public)" + ) + } + if Task.isCancelled { return } + guard (try? await clock.sleep(for: backoff)) != nil else { return } + backoff = min(backoff * 2, .seconds(60)) + } + } + } + + /// Reload ``registryDevices`` from the local sync store (no network), with + /// the same connected-first / most-recently-seen sort and the same + /// signed-in-user guard as ``loadRegistryDevices()``. The presence online dot + /// is overlaid by the UI on top, exactly as for the registry path. + private func reloadDeviceListFromSyncStore(teamID: String) async { + guard deviceListLocalFirst, let syncStore else { return } + let requestingUserID = identityProvider?.currentUserID + let loaded: [RegistryDevice] + do { + loaded = try await DeviceSyncFacade(store: syncStore).registryDevices(teamID: teamID) + } catch { + mobileShellLog.debug( + "device sync facade read failed: \(String(describing: error), privacy: .public)" + ) + return + } + guard isSignedIn, identityProvider?.currentUserID == requestingUserID else { return } + let connectedID = connectedMacDeviceID + registryDevices = loaded.sorted { lhs, rhs in + let lhsConnected = lhs.deviceId == connectedID + let rhsConnected = rhs.deviceId == connectedID + if lhsConnected != rhsConnected { return lhsConnected } + return lhs.lastSeenAt > rhs.lastSeenAt + } + } + /// Run the subscribe stream with exponential backoff (1s..60s, reset on /// every received frame). The server bounds each stream to the token's /// expiry, so a clean finish (resubscribe with a fresh token) is the diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSyncTransport.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSyncTransport.swift new file mode 100644 index 000000000000..960429949759 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSyncTransport.swift @@ -0,0 +1,111 @@ +public import CmuxSyncStore +public import Foundation + +/// A ``SyncTransport`` over a dedicated authenticated presence WebSocket. +/// +/// The cmux-presence Durable Object serves the `sync/v1` snapshot/delta protocol +/// on the same `/v1/presence/subscribe` socket as presence. This transport opens +/// its OWN socket to that endpoint (so the live presence stream and its +/// online-dot / route-push path are never disturbed), sends the `sync.hello`, +/// and yields raw inbound frames to ``SyncClient``. ``SyncClient`` ignores the +/// presence snapshot/tick noise on this socket (non-sync JSON parses to +/// `.unknown` and commits nothing), so the two protocols coexist; consolidating +/// onto the one presence socket is a later optimization, not required for +/// correctness. +/// +/// One instance per subscription attempt: the socket opens lazily on first use +/// and closes when the frame stream terminates. Auth mirrors ``PresenceClient`` +/// (Bearer access token + optional `X-Cmux-Team-Id`), pinned to one team id so +/// the stored records and the socket scope agree. +public actor PresenceSyncTransport: SyncTransport { + private let serviceBaseURL: String + private let tokenSource: PresenceTokenSource + private let teamID: String + private let session: URLSession + private var task: URLSessionWebSocketTask? + + public init( + serviceBaseURL: String, + tokenSource: PresenceTokenSource, + teamID: String, + session: sending URLSession = .shared + ) { + self.serviceBaseURL = serviceBaseURL + self.tokenSource = tokenSource + self.teamID = teamID + self.session = session + } + + public func send(_ data: Data) async throws { + let task = try await connectedTask() + try await task.send(.string(String(decoding: data, as: UTF8.self))) + } + + public nonisolated func frames() -> AsyncThrowingStream { + // Bounded buffer like PresenceClient: the shared socket also carries the + // team's frequent presence ticks, so an unbounded policy would grow if + // the consumer stalls. + AsyncThrowingStream(bufferingPolicy: .bufferingNewest(256)) { continuation in + let pump = Task { + do { + let task = try await self.connectedTask() + while !Task.isCancelled { + let message = try await task.receive() + let frame: Data + switch message { + case .string(let text): frame = Data(text.utf8) + case .data(let raw): frame = raw + @unknown default: continue + } + switch continuation.yield(frame) { + case .enqueued: + break + case .dropped: + // The sync protocol is stateful (snapshot + deltas); + // continuing past a dropped frame could let the cursor + // advance over a gap. End the stream so SyncClient + // resets and re-hellos for a fresh snapshot. + continuation.finish(throwing: PresenceClientError.updatesDropped) + return + case .terminated: + return + @unknown default: + break + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in + pump.cancel() + Task { await self.close() } + } + } + } + + private func connectedTask() async throws -> URLSessionWebSocketTask { + if let task { return task } + guard let url = PresenceClient.subscribeURL(serviceBaseURL: serviceBaseURL) else { + throw PresenceClientError.invalidServiceURL + } + guard let accessToken = await tokenSource.accessToken() else { + throw PresenceClientError.notAuthenticated + } + var request = URLRequest(url: url) + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + if !teamID.isEmpty { + request.setValue(teamID, forHTTPHeaderField: "X-Cmux-Team-Id") + } + let task = session.webSocketTask(with: request) + task.resume() + self.task = task + return task + } + + private func close() { + task?.cancel(with: .goingAway, reason: nil) + task = nil + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift new file mode 100644 index 000000000000..bd5fcb8a32cc --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift @@ -0,0 +1,67 @@ +import CMUXMobileCore +import CmuxMobileShellModel +import CmuxSyncStore +import Foundation +import Testing +@testable import CmuxMobileShell + +/// Verifies the composite's local-first read swap: when the flag is on and a +/// sync store is wired, the device tree is sourced from the durable sync store +/// (not a `/api/devices` call), and a non-empty store never collapses to a +/// partial registry response — the exact masking that lost saved devices on +/// update. Flag OFF must not touch the sync store. +@MainActor +@Suite struct DeviceListLocalFirstWiringTests { + private func seededSyncStore(team: String, deviceIDs: [String]) async throws -> (CmuxSyncStore, URL) { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let store = try CmuxSyncStore(databaseURL: dir.appendingPathComponent("cmux-sync.sqlite3")) + let lastSeen = 1_750_000_000_000.0 + for id in deviceIDs { + let payload = try JSONEncoder().encode(SyncedDeviceRecord( + deviceId: id, platform: "mac", displayName: id, ownerUserId: nil, + lastSeenAtAtRev: lastSeen, + instances: [.init(tag: "default", routes: [], lastSeenAtAtRev: lastSeen)] + )) + try await store.seedProvisional( + teamID: team, collection: devicesSyncCollection, recordID: id, + payloadJSON: payload, sortKey: lastSeen, now: Date() + ) + } + return (store, dir) + } + + @Test func deviceTreeReadsSyncStoreWhenFlagOn() async throws { + let (store, dir) = try await seededSyncStore(team: "team-1", deviceIDs: ["mac-A", "mac-B"]) + defer { try? FileManager.default.removeItem(at: dir) } + + let composite = MobileShellComposite( + isSignedIn: true, + syncStore: store, + deviceListLocalFirst: true, + syncTeamIDProvider: { "team-1" }, + deliveredNotificationClearer: NoopDeliveredNotificationClearer() + ) + // No deviceRegistry injected: under the old XOR path this would have left + // the tree empty; the local-first read must populate it from the store. + await composite.loadRegistryDevices() + #expect(Set(composite.deviceTreeDevices.map(\.deviceId)) == ["mac-A", "mac-B"]) + } + + @Test func flagOffDoesNotReadSyncStore() async throws { + let (store, dir) = try await seededSyncStore(team: "team-1", deviceIDs: ["mac-A"]) + defer { try? FileManager.default.removeItem(at: dir) } + + let composite = MobileShellComposite( + isSignedIn: true, + syncStore: store, + deviceListLocalFirst: false, + syncTeamIDProvider: { "team-1" }, + deliveredNotificationClearer: NoopDeliveredNotificationClearer() + ) + // Flag off + no registry injected → today's path yields an empty list. + await composite.loadRegistryDevices() + #expect(composite.deviceTreeDevices.isEmpty) + } +} diff --git a/ios/cmuxPackage/Package.swift b/ios/cmuxPackage/Package.swift index 23c53222eac2..cbf8b14aee17 100644 --- a/ios/cmuxPackage/Package.swift +++ b/ios/cmuxPackage/Package.swift @@ -24,6 +24,7 @@ let package = Package( .package(path: "../../Packages/Shared/CMUXAuthCore"), .package(path: "../../Packages/Shared/CmuxAuthRuntime"), .package(path: "../../Packages/Shared/CMUXMobileCore"), + .package(path: "../../Packages/Shared/CmuxSyncStore"), .package(path: "../../Packages/iOS/CmuxMobileAnalytics"), .package(path: "../../Packages/iOS/CmuxMobileBrowser"), .package(path: "../../Packages/iOS/CmuxMobileCamera"), @@ -47,6 +48,7 @@ let package = Package( "CMUXAuthCore", "CmuxAuthRuntime", "CMUXMobileCore", + "CmuxSyncStore", "CmuxMobileAnalytics", "CmuxMobileBrowser", "CmuxMobileCamera", @@ -75,6 +77,7 @@ let package = Package( "CMUXAuthCore", "CmuxAuthRuntime", "CMUXMobileCore", + "CmuxSyncStore", "CmuxMobileAnalytics", "CmuxMobileBrowser", "CmuxMobileCamera", diff --git a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift index e81ead53a44e..02d452cb755d 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift @@ -5,6 +5,7 @@ import CmuxMobilePairedMac import CmuxMobileShell import CmuxMobileShellModel @_exported import CmuxMobileShellUI +import CmuxSyncStore import CmuxMobileTransport import Foundation import OSLog @@ -46,6 +47,12 @@ public struct CMUXMobileRootScene: View { /// non-iOS roots, which simply shows no Tailscale guidance. private let tailscaleStatusMonitor: (any TailscaleStatusObserving)? private let pairedMacStore: (any MobilePairedMacStoring)? + /// Durable, DO-synced device-list cache (feat-do-device-list). Opened once at + /// the composition root next to ``pairedMacStore``; survives app updates + /// (Application Support) and backs the local-first, cross-device device list + /// when the `mobileDeviceListLocalFirst` flag is on. `nil` falls back to the + /// registry-driven list. + private let syncStore: (any CmuxSyncStoring)? /// Per-terminal composer drafts for the app session, so an unsent message /// survives keyboard dismiss and terminal switches. In-memory only for now; /// a disk-backed ``TerminalDraftStoring`` (drafts surviving relaunch) lands @@ -97,6 +104,7 @@ public struct CMUXMobileRootScene: View { self.onboardingStore = onboardingStore self.tailscaleStatusMonitor = tailscaleStatusMonitor self.pairedMacStore = Self.openPairedMacStore() + self.syncStore = Self.openSyncStore() self.draftStore = InMemoryTerminalDraftStore() #if DEBUG self.diagnosticLog = diagnosticLog @@ -116,6 +124,7 @@ public struct CMUXMobileRootScene: View { self.analytics = analytics self.tailscaleStatusMonitor = nil self.pairedMacStore = Self.openPairedMacStore() + self.syncStore = Self.openSyncStore() self.draftStore = InMemoryTerminalDraftStore() #if DEBUG self.diagnosticLog = nil @@ -134,6 +143,17 @@ public struct CMUXMobileRootScene: View { } } + private static func openSyncStore() -> (any CmuxSyncStoring)? { + do { + return try CmuxSyncStore() + } catch { + mobileRootSceneLog.error( + "failed to open device sync store: \(String(describing: error), privacy: .public)" + ) + return nil + } + } + /// Build the team-scoped device-registry client over the auth coordinator. /// /// Tokens and the target team are read live through the coordinator so the @@ -210,12 +230,39 @@ public struct CMUXMobileRootScene: View { let feedbackStampProvider: @MainActor () -> MobileFeedbackStamp = { MobileFeedbackStamp.current() } + // Local-first device-list sync wiring (feat-do-device-list). Auth/team + // scoping mirrors `makePresenceClient()`; the transport opens its own + // presence WebSocket pinned to the resolved team. Gated by the + // `mobileDeviceListLocalFirst` flag (DEBUG-on/Release-off) and by a + // presence base URL resolving (nil in Release without an override, which + // keeps the registry-driven list). + let coordinator = auth.coordinator + let deviceListLocalFirst = MobileDeviceListLocalFirst.resolved().isEnabled + let syncTeamIDProvider: (@Sendable () async -> String?)? = { + await coordinator.resolvedTeamID + } + let makeSyncTransport: (@Sendable (String) -> any SyncTransport)? = + PresenceClient.resolvedServiceBaseURL().map { baseURL in + { teamID in + PresenceSyncTransport( + serviceBaseURL: baseURL, + tokenSource: PresenceTokenSource( + accessToken: { try? await coordinator.accessToken() } + ), + teamID: teamID + ) + } + } #if DEBUG return CMUXMobileShellStore( runtime: runtime, pairedMacStore: pairedMacStore, deviceRegistry: deviceRegistry, presence: makePresenceClient(), + syncStore: syncStore, + deviceListLocalFirst: deviceListLocalFirst, + syncTeamIDProvider: syncTeamIDProvider, + makeSyncTransport: makeSyncTransport, identityProvider: identityProvider, reachability: reachability, analytics: analytics, @@ -230,6 +277,10 @@ public struct CMUXMobileRootScene: View { pairedMacStore: pairedMacStore, deviceRegistry: deviceRegistry, presence: makePresenceClient(), + syncStore: syncStore, + deviceListLocalFirst: deviceListLocalFirst, + syncTeamIDProvider: syncTeamIDProvider, + makeSyncTransport: makeSyncTransport, identityProvider: identityProvider, reachability: reachability, analytics: analytics, From 3d324db5e6ed5848a84015c0811bab5474e97347 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:12:23 -0700 Subject: [PATCH 02/13] =?UTF-8?q?ios:=20fix=20autoreview=20P1s=20=E2=80=94?= =?UTF-8?q?=20coherent=20read/transport=20gating=20+=20team-switch=20safet?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autoreview found two real holes: 1. The local-first read swap gated on (flag && syncStore) but the sync subscription gates on makeSyncTransport != nil; enabling the flag without a presence URL bypassed /api/devices with nothing populating the store. Now the read swap requires makeSyncTransport too, and an empty store falls through to the registry instead of showing an empty tree. 2. The sync task pinned a team for a long-lived socket; a team switch could let an old-team frame overwrite the current list, and the seed never re-ran for the new team. reloadDeviceListFromSyncStore now drops a result whose team no longer matches the resolved team; the seed is tracked per-team; and loadRegistryDevices restarts the subscription when the resolved team changes. Tests: flag-on-without-transport falls back to registry; empty store falls back to registry; a stale team-A frame does not overwrite team-B's list. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../MobileShellComposite.swift | 62 ++++++++--- .../DeviceListLocalFirstWiringTests.swift | 105 ++++++++++++++++-- 2 files changed, 138 insertions(+), 29 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 7aff59d0b7d7..13737c05465c 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1495,21 +1495,30 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// leads with the host the user is on. Mirrors ``loadPairedMacs()``: signed /// out yields an empty list. public func loadRegistryDevices() async { - // Local-first: when the flag is on and a sync store is wired, the device - // list is the durable, DO-synced, cross-device collection read from the - // local store (no network). The background sync subscription keeps it - // fresh; this just serves the instant launch read. Flag OFF keeps the - // `/api/devices` path below verbatim. - if deviceListLocalFirst, syncStore != nil { - guard isSignedIn else { - registryDevices = [] - return - } + // Local-first: when the flag is on AND a sync store + transport are wired + // (so a subscription can actually populate the store), the device list is + // the durable, DO-synced, cross-device collection read from the local + // store (no network). The read swap is gated on the SAME transport + // availability as `evaluateSyncSubscription()`, so enabling the flag + // without a presence URL never disables the registry with nothing feeding + // the store. If the store is still empty (DO unreachable, pre-seed), fall + // through to the `/api/devices` registry path below rather than show an + // empty tree. Flag OFF keeps the registry path verbatim. + if deviceListLocalFirst, syncStore != nil, makeSyncTransport != nil, isSignedIn { let teamID = (await syncTeamIDProvider?()) ?? "" if !teamID.isEmpty { + // Team switched while a socket was pinned to the old team: restart + // the subscription so the new team streams + seeds. + if let active = syncSubscriptionTeamID, active != teamID { + syncTask?.cancel() + syncTask = nil + syncSubscriptionTeamID = nil + evaluateSyncSubscription() + } await reloadDeviceListFromSyncStore(teamID: teamID) + if !registryDevices.isEmpty { return } } - return + // Empty store or unresolved team: fall through to the registry fallback. } guard isSignedIn, let deviceRegistry else { registryDevices = [] @@ -1624,6 +1633,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// ``presenceTask``: it runs while signed in with the flag on and a store /// injected, and is torn down on sign-out. private var syncTask: Task? + /// The team the running ``syncTask`` is bound to (its socket + applier are + /// pinned to one team). Used to restart the subscription on a team switch. + private var syncSubscriptionTeamID: String? + /// Teams already seeded from the local paired-Mac store this session, so a + /// team switch re-seeds the new team (the per-team migration marker also + /// makes the underlying seed idempotent). + private var seededSyncTeams: Set = [] /// Start or stop the device-list sync to match the session + flag. Called /// from the `isSignedIn` edge and `resumeForegroundRefresh()`, exactly like @@ -1634,6 +1650,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } else { syncTask?.cancel() syncTask = nil + syncSubscriptionTeamID = nil + seededSyncTeams = [] } } @@ -1648,7 +1666,6 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { syncTask = Task { @MainActor [weak self] in let clock = ContinuousClock() var backoff: Duration = .seconds(1) - var didSeed = false while !Task.isCancelled { guard let self else { return } let teamID = (await self.syncTeamIDProvider?()) ?? "" @@ -1657,15 +1674,17 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { guard (try? await clock.sleep(for: .seconds(1))) != nil else { return } continue } - // One-time transparent local→DO seed (DESIGN.md §6): provisional - // rev==0 rows from the existing paired-Mac store render instantly - // before any frame and survive snapshot reconciliation. Idempotent. - if !didSeed, let pairedMacStore = self.pairedMacStore, + self.syncSubscriptionTeamID = teamID + // Transparent local→DO seed (DESIGN.md §6), once per team: + // provisional rev==0 rows from the existing paired-Mac store + // render instantly before any frame and survive snapshot + // reconciliation. Idempotent (per-team marker + INSERT OR IGNORE). + if !self.seededSyncTeams.contains(teamID), let pairedMacStore = self.pairedMacStore, let accountID = self.identityProvider?.currentUserID { do { _ = try await PairedMacMigration(pairedStore: pairedMacStore, syncStore: syncStore) .runIfNeeded(accountID: accountID, teamID: teamID) - didSeed = true + self.seededSyncTeams.insert(teamID) await self.reloadDeviceListFromSyncStore(teamID: teamID) } catch { mobileShellLog.debug( @@ -1708,7 +1727,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// the same connected-first / most-recently-seen sort and the same /// signed-in-user guard as ``loadRegistryDevices()``. The presence online dot /// is overlaid by the UI on top, exactly as for the registry path. - private func reloadDeviceListFromSyncStore(teamID: String) async { + /// Internal (not private) so the stale-team overwrite guard is unit-testable. + func reloadDeviceListFromSyncStore(teamID: String) async { guard deviceListLocalFirst, let syncStore else { return } let requestingUserID = identityProvider?.currentUserID let loaded: [RegistryDevice] @@ -1720,7 +1740,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { ) return } + // The facade read suspended the main actor. Drop the result unless we are + // still the same signed-in user AND the selected team is still the one + // this read was for, so a late old-team frame (a team switch mid-stream) + // can never overwrite the current team's list. Mirrors loadRegistryDevices's + // user guard, extended for the team a long-lived sync socket is pinned to. guard isSignedIn, identityProvider?.currentUserID == requestingUserID else { return } + guard (await syncTeamIDProvider?() ?? "") == teamID else { return } let connectedID = connectedMacDeviceID registryDevices = loaded.sorted { lhs, rhs in let lhsConnected = lhs.deviceId == connectedID diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift index bd5fcb8a32cc..22d54fe11e52 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift @@ -5,13 +5,33 @@ import Foundation import Testing @testable import CmuxMobileShell -/// Verifies the composite's local-first read swap: when the flag is on and a -/// sync store is wired, the device tree is sourced from the durable sync store -/// (not a `/api/devices` call), and a non-empty store never collapses to a -/// partial registry response — the exact masking that lost saved devices on -/// update. Flag OFF must not touch the sync store. +/// Verifies the composite's local-first read swap and its coherence with the +/// sync transport gating: the device tree reads the durable sync store when the +/// flag + transport are wired (no XOR masking), falls back to the registry when +/// sync cannot run or the store is empty, ignores the store when the flag is +/// off, and never lets a stale-team frame overwrite the current team's list. @MainActor @Suite struct DeviceListLocalFirstWiringTests { + /// A transport that is never actually driven in these tests; its mere + /// presence satisfies the read-swap's transport-availability gate. + struct NoopSyncTransport: SyncTransport { + func send(_ data: Data) async throws {} + func frames() -> AsyncThrowingStream { + AsyncThrowingStream { $0.finish() } + } + } + + /// A device registry returning a scripted `listDevices` outcome. + struct FakeDeviceRegistry: DeviceRegistryRefreshing { + let outcome: DeviceRegistryListOutcome + func freshRoutes(forMacDeviceID macDeviceID: String) async -> [CmxAttachRoute]? { nil } + func listDevices() async -> DeviceRegistryListOutcome { outcome } + } + + private func makeTransportFactory() -> @Sendable (String) -> any SyncTransport { + { _ in NoopSyncTransport() } + } + private func seededSyncStore(team: String, deviceIDs: [String]) async throws -> (CmuxSyncStore, URL) { let dir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -32,19 +52,29 @@ import Testing return (store, dir) } - @Test func deviceTreeReadsSyncStoreWhenFlagOn() async throws { + private func emptySyncStore() throws -> (CmuxSyncStore, URL) { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return (try CmuxSyncStore(databaseURL: dir.appendingPathComponent("cmux-sync.sqlite3")), dir) + } + + private func registryDevice(_ id: String) -> RegistryDevice { + RegistryDevice(deviceId: id, platform: "mac", displayName: id, lastSeenAt: Date(), + instances: [RegistryAppInstance(tag: "default", routes: [], lastSeenAt: Date())]) + } + + @Test func deviceTreeReadsSyncStoreWhenFlagAndTransportWired() async throws { let (store, dir) = try await seededSyncStore(team: "team-1", deviceIDs: ["mac-A", "mac-B"]) defer { try? FileManager.default.removeItem(at: dir) } - let composite = MobileShellComposite( isSignedIn: true, syncStore: store, deviceListLocalFirst: true, syncTeamIDProvider: { "team-1" }, + makeSyncTransport: makeTransportFactory(), deliveredNotificationClearer: NoopDeliveredNotificationClearer() ) - // No deviceRegistry injected: under the old XOR path this would have left - // the tree empty; the local-first read must populate it from the store. await composite.loadRegistryDevices() #expect(Set(composite.deviceTreeDevices.map(\.deviceId)) == ["mac-A", "mac-B"]) } @@ -52,16 +82,69 @@ import Testing @Test func flagOffDoesNotReadSyncStore() async throws { let (store, dir) = try await seededSyncStore(team: "team-1", deviceIDs: ["mac-A"]) defer { try? FileManager.default.removeItem(at: dir) } - let composite = MobileShellComposite( isSignedIn: true, syncStore: store, deviceListLocalFirst: false, syncTeamIDProvider: { "team-1" }, + makeSyncTransport: makeTransportFactory(), + deliveredNotificationClearer: NoopDeliveredNotificationClearer() + ) + await composite.loadRegistryDevices() + #expect(composite.deviceTreeDevices.isEmpty) + } + + /// Finding #1a: flag on but no transport (e.g. Release without a presence URL) + /// must NOT bypass the registry — it falls through to `/api/devices`. + @Test func flagOnWithoutTransportUsesRegistry() async throws { + let (store, dir) = try await seededSyncStore(team: "team-1", deviceIDs: ["mac-local"]) + defer { try? FileManager.default.removeItem(at: dir) } + let composite = MobileShellComposite( + isSignedIn: true, + deviceRegistry: FakeDeviceRegistry(outcome: .ok([registryDevice("mac-registry")])), + syncStore: store, + deviceListLocalFirst: true, + syncTeamIDProvider: { "team-1" }, + makeSyncTransport: nil, // no transport → sync can't run + deliveredNotificationClearer: NoopDeliveredNotificationClearer() + ) + await composite.loadRegistryDevices() + #expect(composite.deviceTreeDevices.map(\.deviceId) == ["mac-registry"]) + } + + /// Finding #1b: flag + transport on but the local store is still empty (DO + /// unreachable / pre-seed) must fall back to the registry, not show empty. + @Test func emptyStoreFallsBackToRegistry() async throws { + let (store, dir) = try emptySyncStore() + defer { try? FileManager.default.removeItem(at: dir) } + let composite = MobileShellComposite( + isSignedIn: true, + deviceRegistry: FakeDeviceRegistry(outcome: .ok([registryDevice("mac-registry")])), + syncStore: store, + deviceListLocalFirst: true, + syncTeamIDProvider: { "team-1" }, + makeSyncTransport: makeTransportFactory(), deliveredNotificationClearer: NoopDeliveredNotificationClearer() ) - // Flag off + no registry injected → today's path yields an empty list. await composite.loadRegistryDevices() + #expect(composite.deviceTreeDevices.map(\.deviceId) == ["mac-registry"]) + } + + /// Finding #2: a late frame for the OLD team (a team switch mid-stream) must + /// not overwrite the current team's list. + @Test func staleTeamFrameDoesNotOverwriteCurrentTeam() async throws { + let (store, dir) = try await seededSyncStore(team: "team-A", deviceIDs: ["mac-A"]) + defer { try? FileManager.default.removeItem(at: dir) } + let composite = MobileShellComposite( + isSignedIn: true, + syncStore: store, + deviceListLocalFirst: true, + syncTeamIDProvider: { "team-B" }, // user is now on team-B + makeSyncTransport: makeTransportFactory(), + deliveredNotificationClearer: NoopDeliveredNotificationClearer() + ) + // Simulate an old-team-A frame's onApplied landing after the switch. + await composite.reloadDeviceListFromSyncStore(teamID: "team-A") #expect(composite.deviceTreeDevices.isEmpty) } } From 2ad2ec6021ecbd57b5f84cc3687e1c20e0ed74a2 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:21:11 -0700 Subject: [PATCH 03/13] =?UTF-8?q?ios:=20fix=20autoreview=20P2=20=E2=80=94?= =?UTF-8?q?=20empty=20sync-store=20read=20must=20not=20blank=20a=20populat?= =?UTF-8?q?ed=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The empty-store fallback called reloadDeviceListFromSyncStore (which assigns registryDevices = [] for an empty store) before falling through to the registry. A transient registry failure is a deliberate no-op meant to KEEP the current tree, so the pre-clear surfaced as a blank tree on a blip. Split the read into a non-mutating syncStoreDevices() the launch path uses (assign only when non-empty, else fall through without clearing); reloadDeviceListFromSyncStore still assigns unconditionally for the apply-callback path, where an empty result is authoritative (a delta tombstoned the last device). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../MobileShellComposite.swift | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 13737c05465c..1e0c3d91dd8c 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1515,8 +1515,14 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { syncSubscriptionTeamID = nil evaluateSyncSubscription() } - await reloadDeviceListFromSyncStore(teamID: teamID) - if !registryDevices.isEmpty { return } + // Serve the durable list when present. A non-mutating read so an + // EMPTY store does not clear the current list before falling + // through — otherwise a transient registry failure (a no-op that + // is meant to keep the tree) would surface as a blank tree. + if let devices = await syncStoreDevices(teamID: teamID), !devices.isEmpty { + registryDevices = devices + return + } } // Empty store or unresolved team: fall through to the registry fallback. } @@ -1723,13 +1729,17 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } } - /// Reload ``registryDevices`` from the local sync store (no network), with - /// the same connected-first / most-recently-seen sort and the same - /// signed-in-user guard as ``loadRegistryDevices()``. The presence online dot - /// is overlaid by the UI on top, exactly as for the registry path. - /// Internal (not private) so the stale-team overwrite guard is unit-testable. - func reloadDeviceListFromSyncStore(teamID: String) async { - guard deviceListLocalFirst, let syncStore else { return } + /// Read + guard + sort the device list from the local sync store (no + /// network), with the same connected-first / most-recently-seen sort as the + /// registry path. Returns the rendered list (possibly empty) when the read is + /// still valid for the current user + team, or `nil` when it must be + /// discarded: a different signed-in user, or a team switch mid-stream (so a + /// late old-team frame can never overwrite the current team's list), or a + /// read error. Non-mutating, so callers choose whether an empty result clears + /// the list (a committed frame did) or falls through (a launch read must not + /// blank a populated tree). + private func syncStoreDevices(teamID: String) async -> [RegistryDevice]? { + guard deviceListLocalFirst, let syncStore else { return nil } let requestingUserID = identityProvider?.currentUserID let loaded: [RegistryDevice] do { @@ -1738,17 +1748,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { mobileShellLog.debug( "device sync facade read failed: \(String(describing: error), privacy: .public)" ) - return + return nil } - // The facade read suspended the main actor. Drop the result unless we are - // still the same signed-in user AND the selected team is still the one - // this read was for, so a late old-team frame (a team switch mid-stream) - // can never overwrite the current team's list. Mirrors loadRegistryDevices's - // user guard, extended for the team a long-lived sync socket is pinned to. - guard isSignedIn, identityProvider?.currentUserID == requestingUserID else { return } - guard (await syncTeamIDProvider?() ?? "") == teamID else { return } + guard isSignedIn, identityProvider?.currentUserID == requestingUserID else { return nil } + guard (await syncTeamIDProvider?() ?? "") == teamID else { return nil } let connectedID = connectedMacDeviceID - registryDevices = loaded.sorted { lhs, rhs in + return loaded.sorted { lhs, rhs in let lhsConnected = lhs.deviceId == connectedID let rhsConnected = rhs.deviceId == connectedID if lhsConnected != rhsConnected { return lhsConnected } @@ -1756,6 +1761,16 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } } + /// Assign ``registryDevices`` from the local sync store. Used by the sync + /// subscription's apply callback, where an empty result is authoritative (a + /// delta tombstoned the last device). Internal (not private) so the + /// stale-team overwrite guard is unit-testable. + func reloadDeviceListFromSyncStore(teamID: String) async { + if let devices = await syncStoreDevices(teamID: teamID) { + registryDevices = devices + } + } + /// Run the subscribe stream with exponential backoff (1s..60s, reset on /// every received frame). The server bounds each stream to the token's /// expiry, so a clean finish (resubscribe with a fresh token) is the From 84ade96e06904a06da67e706387d7c7401fe48f1 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:26:24 -0700 Subject: [PATCH 04/13] ios: don't retain the shell store for the sync socket's lifetime The subscription loop did guard let self at the top, holding a strong self across the long client.run() await, so an in-flight socket retained the whole composite past its owner's lifetime. Extract makeSyncSubscriptionClient() (resolve team + seed + build, with a weak-self apply callback) and call it via self?. so self is released before client.run(); the socket holds no strong self (transport/applier hold only the sync store, onApplied is weak). Mirrors the presence loop's per-frame weak-self pattern. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../MobileShellComposite.swift | 94 ++++++++++--------- 1 file changed, 52 insertions(+), 42 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 1e0c3d91dd8c..cbec7b0d6751 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1662,57 +1662,26 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } /// Run the sync/v1 subscription with the same 1s..60s backoff as presence. - /// Each attempt resolves the current team, runs the one-time local→DO seed so - /// the list renders instantly from the phone's existing paired Macs, then - /// streams `devices` snapshot/delta frames into the local sync store. A - /// committed frame reloads the rendered list from the store. + /// Each attempt builds the client via a SHORT-LIVED weak `self` (resolve + /// team, seed, construct), then runs the long-lived socket holding NO strong + /// `self` — so an in-flight socket never retains the shell store past its + /// owner's lifetime (the `onApplied` callback is weak too). Mirrors how the + /// presence loop re-grabs `self` per frame rather than across the await. private func startSyncSubscription() { - guard syncTask == nil, deviceListLocalFirst, - let syncStore, let makeSyncTransport else { return } + guard syncTask == nil, deviceListLocalFirst, syncStore != nil, makeSyncTransport != nil else { return } syncTask = Task { @MainActor [weak self] in let clock = ContinuousClock() var backoff: Duration = .seconds(1) while !Task.isCancelled { - guard let self else { return } - let teamID = (await self.syncTeamIDProvider?()) ?? "" - guard !teamID.isEmpty else { - // Team not resolved yet (session still settling); retry soon. + // Build via weak self; `self` is released before `client.run()`. + // If self was deallocated, its `isolated deinit` already cancelled + // this task, so the next loop check exits. + guard let client = await self?.makeSyncSubscriptionClient() else { + if Task.isCancelled { return } guard (try? await clock.sleep(for: .seconds(1))) != nil else { return } continue } - self.syncSubscriptionTeamID = teamID - // Transparent local→DO seed (DESIGN.md §6), once per team: - // provisional rev==0 rows from the existing paired-Mac store - // render instantly before any frame and survive snapshot - // reconciliation. Idempotent (per-team marker + INSERT OR IGNORE). - if !self.seededSyncTeams.contains(teamID), let pairedMacStore = self.pairedMacStore, - let accountID = self.identityProvider?.currentUserID { - do { - _ = try await PairedMacMigration(pairedStore: pairedMacStore, syncStore: syncStore) - .runIfNeeded(accountID: accountID, teamID: teamID) - self.seededSyncTeams.insert(teamID) - await self.reloadDeviceListFromSyncStore(teamID: teamID) - } catch { - mobileShellLog.debug( - "paired-mac sync seed failed: \(String(describing: error), privacy: .public)" - ) - } - } do { - let applier = SyncFrameApplier( - store: syncStore, - teamID: teamID, - sortKeyFor: DeviceSyncFacade.sortKey(for:), - allowedCollections: [devicesSyncCollection] - ) - let client = SyncClient( - transport: makeSyncTransport(teamID), - applier: applier, - collections: [devicesSyncCollection], - onApplied: { [weak self] in - await self?.reloadDeviceListFromSyncStore(teamID: teamID) - } - ) try await client.run() backoff = .seconds(1) } catch is CancellationError { @@ -1729,6 +1698,47 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } } + /// Build the sync client for one subscription attempt: resolve the team, run + /// the one-time per-team local→DO seed (DESIGN.md §6 — provisional rev==0 + /// rows from the paired-Mac store render instantly before any frame and + /// survive snapshot reconciliation; idempotent), and construct the client + /// with a weak-self apply callback. Returns nil when the team is not yet + /// resolved or sync is not configured. Separated so the subscription loop + /// touches `self` only here, never across the long `client.run()` await. + private func makeSyncSubscriptionClient() async -> SyncClient? { + guard deviceListLocalFirst, let syncStore, let makeSyncTransport else { return nil } + let teamID = (await syncTeamIDProvider?()) ?? "" + guard !teamID.isEmpty else { return nil } + syncSubscriptionTeamID = teamID + if !seededSyncTeams.contains(teamID), let pairedMacStore, + let accountID = identityProvider?.currentUserID { + do { + _ = try await PairedMacMigration(pairedStore: pairedMacStore, syncStore: syncStore) + .runIfNeeded(accountID: accountID, teamID: teamID) + seededSyncTeams.insert(teamID) + await reloadDeviceListFromSyncStore(teamID: teamID) + } catch { + mobileShellLog.debug( + "paired-mac sync seed failed: \(String(describing: error), privacy: .public)" + ) + } + } + let applier = SyncFrameApplier( + store: syncStore, + teamID: teamID, + sortKeyFor: DeviceSyncFacade.sortKey(for:), + allowedCollections: [devicesSyncCollection] + ) + return SyncClient( + transport: makeSyncTransport(teamID), + applier: applier, + collections: [devicesSyncCollection], + onApplied: { [weak self] in + await self?.reloadDeviceListFromSyncStore(teamID: teamID) + } + ) + } + /// Read + guard + sort the device list from the local sync store (no /// network), with the same connected-first / most-recently-seen sort as the /// registry path. Returns the rendered list (possibly empty) when the read is From 9b295aab3ee8592f929c866659582443bcfb46c7 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:33:22 -0700 Subject: [PATCH 05/13] ios: gap-safe sync transport buffer (bufferingOldest, not Newest) applyDelta advances the cursor to a frame's rev with no contiguity guard, so a frame dropped from the MIDDLE of the stream silently loses a rev. The transport's bufferingNewest(256) drops the OLDEST on overflow and delivers a non-contiguous tail, which the consumer would commit past the gap. Switch to bufferingOldest: overflow drops the NEWEST frames, so the delivered prefix stays contiguous and the cursor never steps over a gap; the dropped recent frames are re-sent from the persisted cursor on reconnect/re-hello. Still end the stream on any drop so that reconnect is prompt. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PresenceSyncTransport.swift | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSyncTransport.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSyncTransport.swift index 960429949759..bdbf6e681d5a 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSyncTransport.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSyncTransport.swift @@ -42,10 +42,19 @@ public actor PresenceSyncTransport: SyncTransport { } public nonisolated func frames() -> AsyncThrowingStream { - // Bounded buffer like PresenceClient: the shared socket also carries the - // team's frequent presence ticks, so an unbounded policy would grow if - // the consumer stalls. - AsyncThrowingStream(bufferingPolicy: .bufferingNewest(256)) { continuation in + // Bounded buffer (the shared socket also carries the team's frequent + // presence ticks, so unbounded could grow if the consumer stalls), but + // `bufferingOldest`, NOT `bufferingNewest`. The sync protocol is + // cursor-based: `applyDelta` advances the cursor to a frame's rev with no + // contiguity guard, so a dropped frame in the MIDDLE silently loses a rev + // (the cursor steps over it). `bufferingNewest` would drop the oldest and + // deliver a non-contiguous tail — exactly that bug. `bufferingOldest` + // instead drops the NEWEST frames on overflow, so what is delivered stays + // a contiguous prefix and the cursor never advances past a gap; the + // dropped recent frames are simply re-sent from the persisted cursor when + // we reconnect and re-`hello`. On any drop we still end the stream so that + // reconnect happens promptly rather than waiting out the stall. + AsyncThrowingStream(bufferingPolicy: .bufferingOldest(256)) { continuation in let pump = Task { do { let task = try await self.connectedTask() @@ -61,10 +70,11 @@ public actor PresenceSyncTransport: SyncTransport { case .enqueued: break case .dropped: - // The sync protocol is stateful (snapshot + deltas); - // continuing past a dropped frame could let the cursor - // advance over a gap. End the stream so SyncClient - // resets and re-hellos for a fresh snapshot. + // A newest frame was dropped (buffer full). The + // delivered prefix is still contiguous, so the cursor + // is safe; end the stream so SyncClient reconnects and + // re-hellos from that cursor to catch up the dropped + // tail. continuation.finish(throwing: PresenceClientError.updatesDropped) return case .terminated: From a118f5deb7df8bc9cf4ef70801efce7080ccdd1d Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:41:43 -0700 Subject: [PATCH 06/13] ios: account-isolate the sync cache on sign-out / account switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local→DO seed wrote account-derived provisional rows keyed only by team, and neither the in-flight race nor sign-out cleared them, so user A's local-only Macs could leak into a later user B on the same team/device. Two guards: - After the (non-cancellation-aware) migration await, recheck the account; if the user signed out or switched mid-seed, clear the team and abandon rather than persist/render A's rows. - On actual sign-out, clear the sync store for the teams this session seeded, so nothing account-derived survives for the next user. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../MobileShellComposite.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index cbec7b0d6751..28b85097c604 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1654,10 +1654,21 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { if isSignedIn, deviceListLocalFirst, syncStore != nil, makeSyncTransport != nil { startSyncSubscription() } else { + // On actual sign-out (not a flag-off while still signed in), wipe the + // account-derived sync cache for the teams this session touched, so + // the next user on this shared device never sees the previous user's + // devices — especially the local-only provisional rows, which the + // facade does not owner-filter and snapshot reconciliation preserves. + let teamsToClear: Set = isSignedIn + ? [] + : seededSyncTeams.union(syncSubscriptionTeamID.map { [$0] } ?? []) syncTask?.cancel() syncTask = nil syncSubscriptionTeamID = nil seededSyncTeams = [] + if !isSignedIn, let syncStore, !teamsToClear.isEmpty { + Task { for team in teamsToClear { try? await syncStore.clear(teamID: team) } } + } } } @@ -1715,6 +1726,17 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { do { _ = try await PairedMacMigration(pairedStore: pairedMacStore, syncStore: syncStore) .runIfNeeded(accountID: accountID, teamID: teamID) + // The migration await suspended. `runIfNeeded` is not + // cancellation-aware, so the account-derived provisional rows it + // just committed belong to `accountID`. If the user signed out or + // switched accounts while it ran, those rows must not persist or + // render for the new session: clear the team and abandon. (A clean + // sign-out also clears via the teardown path below; this covers + // the in-flight race.) + guard isSignedIn, identityProvider?.currentUserID == accountID else { + try? await syncStore.clear(teamID: teamID) + return nil + } seededSyncTeams.insert(teamID) await reloadDeviceListFromSyncStore(teamID: teamID) } catch { From 3100511cbf2e708f27a194ff4148b2ca84bae366 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:49:27 -0700 Subject: [PATCH 07/13] ios: owner-scoped reads (race-free account isolation) + authoritative-empty handling Round 7 autoreview: - The sign-out cache wipe was a fire-and-forget Task that raced the next account's sign-in/seed. Replace it with READ-time isolation: the facade owner-scopes provisional (rev==0) rows by ownerUserId; authoritative (rev>=1) rows stay team-shared. Same model as MobilePairedMacStore persisting across sign-out and filtering by stackUserID. No clear -> no race, and consistent with the rest of the app. - The empty-store registry fallback resurrected stale registry rows even when the DO had synced an authoritatively-empty team. Gate the fallback on cursor == 0 (cache not warm yet); once synced, an empty list is authoritative and shown. Tests: provisional rows owner-scoped while authoritative rows stay shared; an authoritative empty (cursor>0) does not fall back to a stale registry device. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../CmuxSyncStore/DeviceSyncFacade.swift | 23 ++++++++- .../DeviceListSyncWiringTests.swift | 30 ++++++++++++ .../MobileShellComposite.swift | 48 +++++++++++-------- .../DeviceListLocalFirstWiringTests.swift | 23 +++++++++ 4 files changed, 101 insertions(+), 23 deletions(-) diff --git a/Packages/Shared/CmuxSyncStore/Sources/CmuxSyncStore/DeviceSyncFacade.swift b/Packages/Shared/CmuxSyncStore/Sources/CmuxSyncStore/DeviceSyncFacade.swift index bfbb1013f596..e8514586b916 100644 --- a/Packages/Shared/CmuxSyncStore/Sources/CmuxSyncStore/DeviceSyncFacade.swift +++ b/Packages/Shared/CmuxSyncStore/Sources/CmuxSyncStore/DeviceSyncFacade.swift @@ -140,8 +140,27 @@ public struct DeviceSyncFacade: Sendable { /// shell's `loadRegistryDevices` local-first branch is a one-line swap with /// no new UI model (DESIGN.md §4.2: "No new UI model. The facade ... produc[es] /// the existing two-level RegistryDevice/RegistryAppInstance shape"). - public func registryDevices(teamID: String) async throws -> [RegistryDevice] { - try await devices(teamID: teamID).map(Self.registryDevice(from:)) + /// + /// `provisionalOwnerUserID` scopes the account-private migration seed: a + /// provisional `rev == 0` row (one account's local paired-Mac seed) is + /// included only when its `ownerUserId` matches. Authoritative `rev >= 1` + /// rows are team-shared and always included. This keeps one account's + /// local-only devices private on a shared device — same model as + /// `MobilePairedMacStore` filtering by `stackUserID` — without clearing the + /// cross-account cache (which would race the next sign-in). `nil` disables + /// the filter. + public func registryDevices(teamID: String, provisionalOwnerUserID: String? = nil) async throws -> [RegistryDevice] { + let rows = try await store.liveRecords(teamID: teamID, collection: devicesSyncCollection) + let decoder = JSONDecoder() + return rows.compactMap { row in + guard let record = try? decoder.decode(SyncedDeviceRecord.self, from: row.payloadJSON) else { + return nil + } + if let owner = provisionalOwnerUserID, row.rev == 0, record.ownerUserId != owner { + return nil + } + return Self.registryDevice(from: record) + } } /// Map one synced record to the UI model. `lastSeenAtAtRev` is epoch ms (the diff --git a/Packages/Shared/CmuxSyncStore/Tests/CmuxSyncStoreTests/DeviceListSyncWiringTests.swift b/Packages/Shared/CmuxSyncStore/Tests/CmuxSyncStoreTests/DeviceListSyncWiringTests.swift index fdfc1b615910..f34a8434a323 100644 --- a/Packages/Shared/CmuxSyncStore/Tests/CmuxSyncStoreTests/DeviceListSyncWiringTests.swift +++ b/Packages/Shared/CmuxSyncStore/Tests/CmuxSyncStoreTests/DeviceListSyncWiringTests.swift @@ -111,4 +111,34 @@ import Testing #expect(devices.map(\.deviceId) == ["mac-remote"]) #expect(devices.first?.displayName == "Office Mac") } + + /// Account isolation on a shared device: a provisional (rev==0) row is one + /// account's local seed and is visible only to that account; authoritative + /// (rev>=1) DO rows are team-shared and visible to everyone on the team. + @Test func provisionalRowsAreOwnerScopedAuthoritativeShared() async throws { + let (store, dir) = try makeStore() + defer { try? FileManager.default.removeItem(at: dir) } + func provisional(_ id: String, owner: String) throws -> Data { + try JSONEncoder().encode(SyncedDeviceRecord( + deviceId: id, platform: "mac", displayName: id, ownerUserId: owner, + lastSeenAtAtRev: T0_MS, instances: [] + )) + } + try await store.seedProvisional(teamID: TEAM, collection: COLL, recordID: "mac-A", + payloadJSON: try provisional("mac-A", owner: "user-A"), sortKey: T0_MS, now: Date()) + try await store.seedProvisional(teamID: TEAM, collection: COLL, recordID: "mac-B", + payloadJSON: try provisional("mac-B", owner: "user-B"), sortKey: T0_MS, now: Date()) + // A team-shared authoritative device from the DO. + try await store.applySnapshot(teamID: TEAM, collection: COLL, snapshotRev: 1, epoch: 1, + records: [try deviceRecord(id: "mac-shared", rev: 1)], sortKeyFor: sortKey, now: Date()) + + let facade = DeviceSyncFacade(store: store) + let asA = try await facade.registryDevices(teamID: TEAM, provisionalOwnerUserID: "user-A") + #expect(Set(asA.map(\.deviceId)) == ["mac-A", "mac-shared"]) + let asB = try await facade.registryDevices(teamID: TEAM, provisionalOwnerUserID: "user-B") + #expect(Set(asB.map(\.deviceId)) == ["mac-B", "mac-shared"]) + // No owner filter (the macOS/legacy callers) still sees everything. + let all = try await facade.registryDevices(teamID: TEAM) + #expect(Set(all.map(\.deviceId)) == ["mac-A", "mac-B", "mac-shared"]) + } } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 28b85097c604..71c517a5a233 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1523,8 +1523,19 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { registryDevices = devices return } + // Empty read. If the DO has already synced this team (cursor + // advanced past 0), that empty is AUTHORITATIVE — the team really + // has no devices — so show it rather than resurrecting stale + // registry rows the DO may have removed. Only fall back to the + // registry before the first sync (cursor 0 = cache not warm yet). + if let syncStore, + let cursor = try? await syncStore.cursor(teamID: teamID, collection: devicesSyncCollection), + cursor > 0 { + registryDevices = [] + return + } } - // Empty store or unresolved team: fall through to the registry fallback. + // Not-yet-synced store or unresolved team: fall through to the registry. } guard isSignedIn, let deviceRegistry else { registryDevices = [] @@ -1654,21 +1665,15 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { if isSignedIn, deviceListLocalFirst, syncStore != nil, makeSyncTransport != nil { startSyncSubscription() } else { - // On actual sign-out (not a flag-off while still signed in), wipe the - // account-derived sync cache for the teams this session touched, so - // the next user on this shared device never sees the previous user's - // devices — especially the local-only provisional rows, which the - // facade does not owner-filter and snapshot reconciliation preserves. - let teamsToClear: Set = isSignedIn - ? [] - : seededSyncTeams.union(syncSubscriptionTeamID.map { [$0] } ?? []) + // Account isolation is enforced at READ time (the facade owner-scopes + // provisional rows; authoritative rows are team-scoped), the same way + // MobilePairedMacStore persists across sign-out and filters by + // stackUserID. So we just tear the subscription down here; clearing on + // sign-out would race the next account's sign-in/seed. syncTask?.cancel() syncTask = nil syncSubscriptionTeamID = nil seededSyncTeams = [] - if !isSignedIn, let syncStore, !teamsToClear.isEmpty { - Task { for team in teamsToClear { try? await syncStore.clear(teamID: team) } } - } } } @@ -1726,15 +1731,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { do { _ = try await PairedMacMigration(pairedStore: pairedMacStore, syncStore: syncStore) .runIfNeeded(accountID: accountID, teamID: teamID) - // The migration await suspended. `runIfNeeded` is not - // cancellation-aware, so the account-derived provisional rows it - // just committed belong to `accountID`. If the user signed out or - // switched accounts while it ran, those rows must not persist or - // render for the new session: clear the team and abandon. (A clean - // sign-out also clears via the teardown path below; this covers - // the in-flight race.) + // The migration await suspended. If the user signed out or + // switched accounts while it ran, don't render or track this seed + // for the new session. The committed rows are `accountID`'s and + // stay on disk, but the facade owner-scopes them at read time, so + // the new account never sees them (no clear → no sign-in race). guard isSignedIn, identityProvider?.currentUserID == accountID else { - try? await syncStore.clear(teamID: teamID) return nil } seededSyncTeams.insert(teamID) @@ -1775,7 +1777,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { let requestingUserID = identityProvider?.currentUserID let loaded: [RegistryDevice] do { - loaded = try await DeviceSyncFacade(store: syncStore).registryDevices(teamID: teamID) + // Owner-scope the account-private provisional seed so this account + // never renders another account's local-only devices (no clearing, + // so no race with the next sign-in). + loaded = try await DeviceSyncFacade(store: syncStore) + .registryDevices(teamID: teamID, provisionalOwnerUserID: requestingUserID) } catch { mobileShellLog.debug( "device sync facade read failed: \(String(describing: error), privacy: .public)" diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift index 22d54fe11e52..825452e01493 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift @@ -130,6 +130,29 @@ import Testing #expect(composite.deviceTreeDevices.map(\.deviceId) == ["mac-registry"]) } + /// Once the DO has synced (cursor > 0), an empty team is AUTHORITATIVELY + /// empty and must not resurrect stale registry rows the DO removed. + @Test func authoritativeEmptyDoesNotFallBackToRegistry() async throws { + let (store, dir) = try emptySyncStore() + defer { try? FileManager.default.removeItem(at: dir) } + // An empty snapshot advances the cursor: synced, and genuinely no devices. + try await store.applySnapshot( + teamID: "team-1", collection: devicesSyncCollection, snapshotRev: 5, epoch: 1, + records: [], sortKeyFor: { DeviceSyncFacade.sortKey(for: $0) }, now: Date() + ) + let composite = MobileShellComposite( + isSignedIn: true, + deviceRegistry: FakeDeviceRegistry(outcome: .ok([registryDevice("mac-stale")])), + syncStore: store, + deviceListLocalFirst: true, + syncTeamIDProvider: { "team-1" }, + makeSyncTransport: makeTransportFactory(), + deliveredNotificationClearer: NoopDeliveredNotificationClearer() + ) + await composite.loadRegistryDevices() + #expect(composite.deviceTreeDevices.isEmpty) + } + /// Finding #2: a late frame for the OLD team (a team switch mid-stream) must /// not overwrite the current team's list. @Test func staleTeamFrameDoesNotOverwriteCurrentTeam() async throws { From 218042674a79cbeaafcf32da4d839018316f4edf Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:57:33 -0700 Subject: [PATCH 08/13] ios: fail closed when the signed-in owner id is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The facade owner filter only applied when provisionalOwnerUserID was non-nil, so a nil owner (signed in but user id momentarily unavailable) showed ALL accounts' provisional rows — fail open. Invert it: provisional (rev==0) rows require a present, matching owner; nil owner returns no provisional rows (authoritative team-shared rows unaffected). Tests updated to seed/read with an owner and to assert nil-owner yields only the shared authoritative device. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/CmuxSyncStore/DeviceSyncFacade.swift | 16 ++++++++++++---- .../DeviceListSyncWiringTests.swift | 12 +++++++----- .../DeviceListLocalFirstWiringTests.swift | 11 ++++++++++- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/Packages/Shared/CmuxSyncStore/Sources/CmuxSyncStore/DeviceSyncFacade.swift b/Packages/Shared/CmuxSyncStore/Sources/CmuxSyncStore/DeviceSyncFacade.swift index e8514586b916..5fd277beb341 100644 --- a/Packages/Shared/CmuxSyncStore/Sources/CmuxSyncStore/DeviceSyncFacade.swift +++ b/Packages/Shared/CmuxSyncStore/Sources/CmuxSyncStore/DeviceSyncFacade.swift @@ -147,8 +147,12 @@ public struct DeviceSyncFacade: Sendable { /// rows are team-shared and always included. This keeps one account's /// local-only devices private on a shared device — same model as /// `MobilePairedMacStore` filtering by `stackUserID` — without clearing the - /// cross-account cache (which would race the next sign-in). `nil` disables - /// the filter. + /// cross-account cache (which would race the next sign-in). + /// + /// Fails CLOSED: when `provisionalOwnerUserID` is `nil` (the current owner id + /// is unavailable), NO provisional rows are returned, so an unknown owner can + /// never see another account's local devices. Authoritative rows are + /// unaffected. public func registryDevices(teamID: String, provisionalOwnerUserID: String? = nil) async throws -> [RegistryDevice] { let rows = try await store.liveRecords(teamID: teamID, collection: devicesSyncCollection) let decoder = JSONDecoder() @@ -156,8 +160,12 @@ public struct DeviceSyncFacade: Sendable { guard let record = try? decoder.decode(SyncedDeviceRecord.self, from: row.payloadJSON) else { return nil } - if let owner = provisionalOwnerUserID, row.rev == 0, record.ownerUserId != owner { - return nil + if row.rev == 0 { + // Provisional (account-private) row: include only for its owner. + // Unknown current owner (nil) excludes it — fail closed. + guard let owner = provisionalOwnerUserID, record.ownerUserId == owner else { + return nil + } } return Self.registryDevice(from: record) } diff --git a/Packages/Shared/CmuxSyncStore/Tests/CmuxSyncStoreTests/DeviceListSyncWiringTests.swift b/Packages/Shared/CmuxSyncStore/Tests/CmuxSyncStoreTests/DeviceListSyncWiringTests.swift index f34a8434a323..5dbd9bb2765b 100644 --- a/Packages/Shared/CmuxSyncStore/Tests/CmuxSyncStoreTests/DeviceListSyncWiringTests.swift +++ b/Packages/Shared/CmuxSyncStore/Tests/CmuxSyncStoreTests/DeviceListSyncWiringTests.swift @@ -55,7 +55,7 @@ import Testing // The migration seeds the phone's existing Mac as a provisional rev==0 row. let provisional = try JSONEncoder().encode(SyncedDeviceRecord( - deviceId: "mac-local", platform: "mac", displayName: "Old Mac", ownerUserId: nil, + deviceId: "mac-local", platform: "mac", displayName: "Old Mac", ownerUserId: "user-A", lastSeenAtAtRev: T0_MS, instances: [.init(tag: "default", routes: [], lastSeenAtAtRev: T0_MS)] )) @@ -70,7 +70,8 @@ import Testing sortKeyFor: sortKey, now: Date() ) - let devices = try await DeviceSyncFacade(store: store).registryDevices(teamID: TEAM) + let devices = try await DeviceSyncFacade(store: store) + .registryDevices(teamID: TEAM, provisionalOwnerUserID: "user-A") #expect(Set(devices.map(\.deviceId)) == ["mac-local", "mac-other"]) } } @@ -137,8 +138,9 @@ import Testing #expect(Set(asA.map(\.deviceId)) == ["mac-A", "mac-shared"]) let asB = try await facade.registryDevices(teamID: TEAM, provisionalOwnerUserID: "user-B") #expect(Set(asB.map(\.deviceId)) == ["mac-B", "mac-shared"]) - // No owner filter (the macOS/legacy callers) still sees everything. - let all = try await facade.registryDevices(teamID: TEAM) - #expect(Set(all.map(\.deviceId)) == ["mac-A", "mac-B", "mac-shared"]) + // Owner unknown (nil) fails closed: only the team-shared authoritative + // row, never another account's local-only provisional rows. + let unknownOwner = try await facade.registryDevices(teamID: TEAM) + #expect(Set(unknownOwner.map(\.deviceId)) == ["mac-shared"]) } } diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift index 825452e01493..e4e2219a543d 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift @@ -28,6 +28,14 @@ import Testing func listDevices() async -> DeviceRegistryListOutcome { outcome } } + /// A fixed signed-in identity, so provisional rows (owner-scoped) render. + struct FakeIdentity: MobileIdentityProviding { + let userID: String? + @MainActor var currentUserID: String? { userID } + } + + private static let owner = "user-1" + private func makeTransportFactory() -> @Sendable (String) -> any SyncTransport { { _ in NoopSyncTransport() } } @@ -40,7 +48,7 @@ import Testing let lastSeen = 1_750_000_000_000.0 for id in deviceIDs { let payload = try JSONEncoder().encode(SyncedDeviceRecord( - deviceId: id, platform: "mac", displayName: id, ownerUserId: nil, + deviceId: id, platform: "mac", displayName: id, ownerUserId: Self.owner, lastSeenAtAtRev: lastSeen, instances: [.init(tag: "default", routes: [], lastSeenAtAtRev: lastSeen)] )) @@ -73,6 +81,7 @@ import Testing deviceListLocalFirst: true, syncTeamIDProvider: { "team-1" }, makeSyncTransport: makeTransportFactory(), + identityProvider: FakeIdentity(userID: Self.owner), deliveredNotificationClearer: NoopDeliveredNotificationClearer() ) await composite.loadRegistryDevices() From a78422e6540628d863435369ac609ba0c5f12d0c Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 20 Jun 2026 03:03:56 -0700 Subject: [PATCH 09/13] ios: local-first deviceTreeDevices must not resurrect paired Macs on authoritative empty The earlier authoritative-empty fix set registryDevices=[], but deviceTreeDevices' legacy XOR fallback then synthesized the tree from the OLD pairedMacs, re-showing stale local Macs the DO no longer knows about. In local-first mode the sync store is authoritative (paired Macs are seeded into it as provisional rows), so gate that pairedMacs fallback off; loadRegistryDevices still falls back to /api/devices before the first sync, so this only bites once the store is authoritatively empty. Test: with a local paired Mac loaded and an authoritatively-empty sync store, the tree stays empty (the Mac is not resurrected). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../MobileShellComposite.swift | 11 ++++++ .../DeviceListLocalFirstWiringTests.swift | 36 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 71c517a5a233..7db89ac92679 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1596,6 +1596,17 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// device sorts first, then most-recently-seen. public var deviceTreeDevices: [RegistryDevice] { if !registryDevices.isEmpty { return registryDevices } + // In local-first mode the sync store is authoritative: the local paired + // Macs are seeded into it as provisional rows, so an empty + // `registryDevices` (after the store has loaded) means the team genuinely + // has no devices. Do NOT synthesize from `pairedMacs` — that XOR fallback + // is only for the registry-only path; here it would resurrect stale local + // Macs the DO no longer knows about. (`loadRegistryDevices` still falls + // back to `/api/devices` before the first sync, so this only bites once + // the store is authoritatively empty.) + if deviceListLocalFirst, syncStore != nil, makeSyncTransport != nil { + return [] + } let connectedID = connectedMacDeviceID return pairedMacs .map { mac in diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift index e4e2219a543d..7f7e7b12546a 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift @@ -1,4 +1,5 @@ import CMUXMobileCore +import CmuxMobilePairedMac import CmuxMobileShellModel import CmuxSyncStore import Foundation @@ -162,6 +163,41 @@ import Testing #expect(composite.deviceTreeDevices.isEmpty) } + /// An authoritative-empty sync store must not resurrect the OLD local paired + /// Macs via `deviceTreeDevices`' legacy XOR fallback when local-first is on. + @Test func localFirstAuthoritativeEmptyDoesNotResurrectPairedMacs() async throws { + let (store, dir) = try emptySyncStore() + defer { try? FileManager.default.removeItem(at: dir) } + try await store.applySnapshot( + teamID: "team-1", collection: devicesSyncCollection, snapshotRev: 3, epoch: 1, + records: [], sortKeyFor: { DeviceSyncFacade.sortKey(for: $0) }, now: Date() + ) + let macDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: macDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: macDir) } + let pairedStore = try MobilePairedMacStore( + databaseURL: macDir.appendingPathComponent("paired-macs.sqlite3")) + try await pairedStore.upsert( + macDeviceID: "mac-old", displayName: "Old", routes: [], + markActive: true, stackUserID: Self.owner) + + let composite = MobileShellComposite( + isSignedIn: true, + pairedMacStore: pairedStore, + syncStore: store, + deviceListLocalFirst: true, + syncTeamIDProvider: { "team-1" }, + makeSyncTransport: makeTransportFactory(), + identityProvider: FakeIdentity(userID: Self.owner), + deliveredNotificationClearer: NoopDeliveredNotificationClearer() + ) + await composite.loadPairedMacs() + await composite.loadRegistryDevices() + #expect(!composite.pairedMacs.isEmpty) // the local Mac IS loaded + #expect(composite.deviceTreeDevices.isEmpty) // but NOT shown in the tree + } + /// Finding #2: a late frame for the OLD team (a team switch mid-stream) must /// not overwrite the current team's list. @Test func staleTeamFrameDoesNotOverwriteCurrentTeam() async throws { From b1004875ec6a41d5b678b5d3fb80bdf91db81d8d Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 20 Jun 2026 03:13:37 -0700 Subject: [PATCH 10/13] ios: run sync loop off-main + keep paired-Mac fallback until sync is authoritative Round 10 autoreview: - [P1] The sync subscription Task was @MainActor, so every frame parse + apply (including shared-socket presence-tick noise) ran on the UI thread. Drop @MainActor: makeSyncSubscriptionClient (team/seed/build) and the apply callback are @MainActor and reached via await; SyncFrameApplier is its own actor; SyncClient/transport are Sendable. The frame loop now parses off-main. - [P2] Gating deviceTreeDevices' paired-Mac fallback purely on local-first hid local Macs before the store had synced (offline first launch -> empty). Gate on a new deviceSyncAuthoritative flag (set when cursor>0 / a frame commits, reset on team change + sign-out), so the fallback persists until an empty list is authoritative. Tests: before sync the tree shows the local paired Mac; once authoritatively empty it does not. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../MobileShellComposite.swift | 45 ++++++++++++++----- .../DeviceListLocalFirstWiringTests.swift | 30 +++++++++++++ 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 7db89ac92679..c8718e90cda5 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1513,6 +1513,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { syncTask?.cancel() syncTask = nil syncSubscriptionTeamID = nil + deviceSyncAuthoritative = false // new team hasn't synced yet evaluateSyncSubscription() } // Serve the durable list when present. A non-mutating read so an @@ -1531,6 +1532,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { if let syncStore, let cursor = try? await syncStore.cursor(teamID: teamID, collection: devicesSyncCollection), cursor > 0 { + deviceSyncAuthoritative = true registryDevices = [] return } @@ -1596,15 +1598,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// device sorts first, then most-recently-seen. public var deviceTreeDevices: [RegistryDevice] { if !registryDevices.isEmpty { return registryDevices } - // In local-first mode the sync store is authoritative: the local paired - // Macs are seeded into it as provisional rows, so an empty - // `registryDevices` (after the store has loaded) means the team genuinely - // has no devices. Do NOT synthesize from `pairedMacs` — that XOR fallback - // is only for the registry-only path; here it would resurrect stale local - // Macs the DO no longer knows about. (`loadRegistryDevices` still falls - // back to `/api/devices` before the first sync, so this only bites once - // the store is authoritatively empty.) - if deviceListLocalFirst, syncStore != nil, makeSyncTransport != nil { + // In local-first mode, once the store has AUTHORITATIVELY synced this + // team (`deviceSyncAuthoritative`), an empty `registryDevices` means the + // team genuinely has no devices: do NOT synthesize from `pairedMacs` (that + // XOR fallback is only for the registry-only path; it would resurrect + // stale local Macs the DO removed). BEFORE the first sync we keep the + // fallback, so an offline first launch still shows the local paired Macs. + if deviceListLocalFirst, syncStore != nil, makeSyncTransport != nil, deviceSyncAuthoritative { return [] } let connectedID = connectedMacDeviceID @@ -1668,6 +1668,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// team switch re-seeds the new team (the per-team migration marker also /// makes the underlying seed idempotent). private var seededSyncTeams: Set = [] + /// Whether the sync store has synced the current team (cursor > 0), so an + /// empty list is AUTHORITATIVE. Until then `deviceTreeDevices` keeps the + /// paired-Mac fallback, so an offline first launch still shows local Macs + /// rather than collapsing to empty. Reset on team change / sign-out. + private var deviceSyncAuthoritative = false /// Start or stop the device-list sync to match the session + flag. Called /// from the `isSignedIn` edge and `resumeForegroundRefresh()`, exactly like @@ -1685,6 +1690,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { syncTask = nil syncSubscriptionTeamID = nil seededSyncTeams = [] + deviceSyncAuthoritative = false } } @@ -1696,7 +1702,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// presence loop re-grabs `self` per frame rather than across the await. private func startSyncSubscription() { guard syncTask == nil, deviceListLocalFirst, syncStore != nil, makeSyncTransport != nil else { return } - syncTask = Task { @MainActor [weak self] in + // NOT @MainActor: the frame loop (receive + JSON parse + apply) must run + // OFF the main actor so the shared socket's frequent presence-tick noise + // never parses on the UI thread. The pieces that touch main-actor state + // hop explicitly: `makeSyncSubscriptionClient()` (resolve team/seed/build) + // and the `onApplied` reload are `@MainActor` and reached via `await`; the + // applier is its own actor; `SyncClient`/transport are Sendable. + syncTask = Task { [weak self] in let clock = ContinuousClock() var backoff: Duration = .seconds(1) while !Task.isCancelled { @@ -1769,11 +1781,22 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { applier: applier, collections: [devicesSyncCollection], onApplied: { [weak self] in - await self?.reloadDeviceListFromSyncStore(teamID: teamID) + await self?.handleSyncCommit(teamID: teamID) } ) } + /// A committed sync frame for `teamID` (cursor advanced): the store has now + /// synced this team, so an empty list is authoritative. Mark it (when still + /// the current team) and refresh the rendered list. Runs on the main actor; + /// reached via `await` from the off-main sync loop's apply callback. + private func handleSyncCommit(teamID: String) async { + if (await syncTeamIDProvider?() ?? "") == teamID { + deviceSyncAuthoritative = true + } + await reloadDeviceListFromSyncStore(teamID: teamID) + } + /// Read + guard + sort the device list from the local sync store (no /// network), with the same connected-first / most-recently-seen sort as the /// registry path. Returns the rendered list (possibly empty) when the read is diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift index 7f7e7b12546a..892665cfe5e2 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift @@ -198,6 +198,36 @@ import Testing #expect(composite.deviceTreeDevices.isEmpty) // but NOT shown in the tree } + /// Before the first sync (cursor 0), an offline launch must still show the + /// local paired Macs via the fallback — the gate only hides them once synced. + @Test func localFirstBeforeSyncStillShowsPairedMacs() async throws { + let (store, dir) = try emptySyncStore() // never synced: cursor 0 + defer { try? FileManager.default.removeItem(at: dir) } + let macDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: macDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: macDir) } + let pairedStore = try MobilePairedMacStore( + databaseURL: macDir.appendingPathComponent("paired-macs.sqlite3")) + try await pairedStore.upsert( + macDeviceID: "mac-old", displayName: "Old", routes: [], + markActive: true, stackUserID: Self.owner) + + let composite = MobileShellComposite( + isSignedIn: true, + pairedMacStore: pairedStore, + syncStore: store, + deviceListLocalFirst: true, + syncTeamIDProvider: { "team-1" }, + makeSyncTransport: makeTransportFactory(), + identityProvider: FakeIdentity(userID: Self.owner), + deliveredNotificationClearer: NoopDeliveredNotificationClearer() + ) + await composite.loadPairedMacs() + await composite.loadRegistryDevices() // empty + not synced -> no authoritative gate + #expect(composite.deviceTreeDevices.map(\.deviceId) == ["mac-old"]) + } + /// Finding #2: a late frame for the OLD team (a team switch mid-stream) must /// not overwrite the current team's list. @Test func staleTeamFrameDoesNotOverwriteCurrentTeam() async throws { From cfe83215c7838c06457dba252eb046a3aa279a6b Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 20 Jun 2026 03:22:58 -0700 Subject: [PATCH 11/13] ios: scope the authoritative-empty gate to the team deviceSyncAuthoritative was a single Bool, so a synced (empty) team-A left the gate set when switching to an unsynced team-B, hiding team-B's paired-Mac fallback. Track the synced team (deviceSyncAuthoritativeTeamID) and the rendered team (deviceListTeamID); gate only when they match. Reset on sign-out; a team switch updates deviceListTeamID so the gate naturally disengages until the new team syncs. Test: team-A authoritative-empty gates; after switching to unsynced team-B the tree falls back to the local paired Mac. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../MobileShellComposite.swift | 35 +++++++++------ .../DeviceListLocalFirstWiringTests.swift | 45 +++++++++++++++++++ 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index c8718e90cda5..7e4086d27048 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1507,13 +1507,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { if deviceListLocalFirst, syncStore != nil, makeSyncTransport != nil, isSignedIn { let teamID = (await syncTeamIDProvider?()) ?? "" if !teamID.isEmpty { + deviceListTeamID = teamID // the team the rendered list is for // Team switched while a socket was pinned to the old team: restart // the subscription so the new team streams + seeds. if let active = syncSubscriptionTeamID, active != teamID { syncTask?.cancel() syncTask = nil syncSubscriptionTeamID = nil - deviceSyncAuthoritative = false // new team hasn't synced yet evaluateSyncSubscription() } // Serve the durable list when present. A non-mutating read so an @@ -1532,7 +1532,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { if let syncStore, let cursor = try? await syncStore.cursor(teamID: teamID, collection: devicesSyncCollection), cursor > 0 { - deviceSyncAuthoritative = true + deviceSyncAuthoritativeTeamID = teamID registryDevices = [] return } @@ -1598,13 +1598,15 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// device sorts first, then most-recently-seen. public var deviceTreeDevices: [RegistryDevice] { if !registryDevices.isEmpty { return registryDevices } - // In local-first mode, once the store has AUTHORITATIVELY synced this - // team (`deviceSyncAuthoritative`), an empty `registryDevices` means the - // team genuinely has no devices: do NOT synthesize from `pairedMacs` (that + // In local-first mode, once the store has AUTHORITATIVELY synced the team + // the list is showing (`deviceSyncAuthoritativeTeamID == deviceListTeamID`), + // an empty `registryDevices` means the team genuinely has no devices: do + // NOT synthesize from `pairedMacs` (that // XOR fallback is only for the registry-only path; it would resurrect // stale local Macs the DO removed). BEFORE the first sync we keep the // fallback, so an offline first launch still shows the local paired Macs. - if deviceListLocalFirst, syncStore != nil, makeSyncTransport != nil, deviceSyncAuthoritative { + if deviceListLocalFirst, syncStore != nil, makeSyncTransport != nil, + let synced = deviceSyncAuthoritativeTeamID, synced == deviceListTeamID { return [] } let connectedID = connectedMacDeviceID @@ -1668,11 +1670,16 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// team switch re-seeds the new team (the per-team migration marker also /// makes the underlying seed idempotent). private var seededSyncTeams: Set = [] - /// Whether the sync store has synced the current team (cursor > 0), so an - /// empty list is AUTHORITATIVE. Until then `deviceTreeDevices` keeps the - /// paired-Mac fallback, so an offline first launch still shows local Macs - /// rather than collapsing to empty. Reset on team change / sign-out. - private var deviceSyncAuthoritative = false + /// The team for which the sync store has synced (cursor > 0), so an empty + /// list for THAT team is AUTHORITATIVE. Compared against ``deviceListTeamID`` + /// (the team the rendered list is for) in `deviceTreeDevices`, so a synced + /// team-A never marks an unsynced team-B authoritative. Until a team is + /// synced, the paired-Mac fallback persists (offline first launch still shows + /// local Macs). Reset on sign-out. + private var deviceSyncAuthoritativeTeamID: String? + /// The team the currently-rendered ``registryDevices`` is for (local-first + /// path), so the authoritative-empty gate can scope to the displayed team. + private var deviceListTeamID: String? /// Start or stop the device-list sync to match the session + flag. Called /// from the `isSignedIn` edge and `resumeForegroundRefresh()`, exactly like @@ -1690,7 +1697,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { syncTask = nil syncSubscriptionTeamID = nil seededSyncTeams = [] - deviceSyncAuthoritative = false + deviceSyncAuthoritativeTeamID = nil + deviceListTeamID = nil } } @@ -1792,7 +1800,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// reached via `await` from the off-main sync loop's apply callback. private func handleSyncCommit(teamID: String) async { if (await syncTeamIDProvider?() ?? "") == teamID { - deviceSyncAuthoritative = true + deviceSyncAuthoritativeTeamID = teamID } await reloadDeviceListFromSyncStore(teamID: teamID) } @@ -1839,6 +1847,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// stale-team overwrite guard is unit-testable. func reloadDeviceListFromSyncStore(teamID: String) async { if let devices = await syncStoreDevices(teamID: teamID) { + deviceListTeamID = teamID registryDevices = devices } } diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift index 892665cfe5e2..d24aaafa9ffc 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift @@ -35,6 +35,12 @@ import Testing @MainActor var currentUserID: String? { userID } } + /// A flippable team, to simulate a team switch. + final class MutableTeam: @unchecked Sendable { + var current: String + init(_ t: String) { current = t } + } + private static let owner = "user-1" private func makeTransportFactory() -> @Sendable (String) -> any SyncTransport { @@ -228,6 +234,45 @@ import Testing #expect(composite.deviceTreeDevices.map(\.deviceId) == ["mac-old"]) } + /// The authoritative-empty gate is team-scoped: a synced (empty) team-A must + /// not mark an unsynced team-B authoritative, so after switching to team-B the + /// tree falls back to the local paired Macs. + @Test func authoritativeEmptyIsTeamScoped() async throws { + let (store, dir) = try emptySyncStore() + defer { try? FileManager.default.removeItem(at: dir) } + // team-A is synced and authoritatively empty. + try await store.applySnapshot( + teamID: "team-A", collection: devicesSyncCollection, snapshotRev: 5, epoch: 1, + records: [], sortKeyFor: { DeviceSyncFacade.sortKey(for: $0) }, now: Date()) + let macDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: macDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: macDir) } + let pairedStore = try MobilePairedMacStore( + databaseURL: macDir.appendingPathComponent("paired-macs.sqlite3")) + try await pairedStore.upsert( + macDeviceID: "mac-old", displayName: "Old", routes: [], + markActive: true, stackUserID: Self.owner) + + let team = MutableTeam("team-A") + let composite = MobileShellComposite( + isSignedIn: true, + pairedMacStore: pairedStore, + syncStore: store, + deviceListLocalFirst: true, + syncTeamIDProvider: { team.current }, + makeSyncTransport: makeTransportFactory(), + identityProvider: FakeIdentity(userID: Self.owner), + deliveredNotificationClearer: NoopDeliveredNotificationClearer() + ) + await composite.loadPairedMacs() + await composite.loadRegistryDevices() // team-A authoritative empty + #expect(composite.deviceTreeDevices.isEmpty) + team.current = "team-B" // switch to an unsynced team + await composite.loadRegistryDevices() + #expect(composite.deviceTreeDevices.map(\.deviceId) == ["mac-old"]) + } + /// Finding #2: a late frame for the OLD team (a team switch mid-stream) must /// not overwrite the current team's list. @Test func staleTeamFrameDoesNotOverwriteCurrentTeam() async throws { From 0133c1e97de23b19216009bacb7ed779dd08ea01 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 20 Jun 2026 03:29:06 -0700 Subject: [PATCH 12/13] ios: guard local-first reads against sign-out/account/team change after await MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cursor-read branch blanked registryDevices after an await without re-checking the session, so a stale read could clear the current device tree. Capture the requesting user, re-guard after the team-resolve await, and re-guard (user + team) after the cursor await before blanking — mirroring syncStoreDevices' own guards. A stale read now returns without mutating the new session's list. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../CmuxMobileShell/MobileShellComposite.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 7e4086d27048..338c0eccac97 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1505,8 +1505,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // through to the `/api/devices` registry path below rather than show an // empty tree. Flag OFF keeps the registry path verbatim. if deviceListLocalFirst, syncStore != nil, makeSyncTransport != nil, isSignedIn { + let requestingUserID = identityProvider?.currentUserID let teamID = (await syncTeamIDProvider?()) ?? "" - if !teamID.isEmpty { + // The team-resolve await may have suspended; only proceed if still the + // same signed-in user, so a stale read can't mutate the new session. + if isSignedIn, identityProvider?.currentUserID == requestingUserID, !teamID.isEmpty { deviceListTeamID = teamID // the team the rendered list is for // Team switched while a socket was pinned to the old team: restart // the subscription so the new team streams + seeds. @@ -1532,6 +1535,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { if let syncStore, let cursor = try? await syncStore.cursor(teamID: teamID, collection: devicesSyncCollection), cursor > 0 { + // The cursor read suspended; only blank the tree if we are + // still the same signed-in user on the same team this read was + // for, so a stale read can never clear the current session's + // list (mirrors syncStoreDevices' own guards). + guard isSignedIn, identityProvider?.currentUserID == requestingUserID, + (await syncTeamIDProvider?() ?? "") == teamID else { return } deviceSyncAuthoritativeTeamID = teamID registryDevices = [] return From 2c79ecf2a7c6c2a915555ac814bc49559f38e77c Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 20 Jun 2026 03:45:01 -0700 Subject: [PATCH 13/13] ios: fix cmuxFeature compile (typed makeSyncTransport closure) + DocC + lockfile - CMUXMobileRootScene: the makeSyncTransport closure via .map was type-ambiguous ('type of expression is ambiguous'); build it with an explicit @Sendable typed closure so cmuxFeature compiles (the iOS build CI caught this; local package builds didn't exercise cmuxFeature). - PresenceSyncTransport: add DocC docs on the public init/send/frames (Aziz docs policy). - Regenerate ios/cmuxPackage/Package.resolved (originHash) for the CmuxSyncStore dependency (SwiftPM lockfile policy). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PresenceSyncTransport.swift | 12 ++++++++++ ios/cmuxPackage/Package.resolved | 2 +- .../cmuxFeature/CMUXMobileRootScene.swift | 24 ++++++++++--------- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSyncTransport.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSyncTransport.swift index bdbf6e681d5a..6ed55590f2d1 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSyncTransport.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSyncTransport.swift @@ -24,6 +24,13 @@ public actor PresenceSyncTransport: SyncTransport { private let session: URLSession private var task: URLSessionWebSocketTask? + /// Create a sync transport. + /// - Parameters: + /// - serviceBaseURL: Presence service origin (no trailing slash), e.g. the + /// deployed cmux-presence worker URL. + /// - tokenSource: Supplies the Stack access token for the WebSocket auth. + /// - teamID: Team to pin the socket (and the stored records) to. + /// - session: URL session used for the WebSocket transport. public init( serviceBaseURL: String, tokenSource: PresenceTokenSource, @@ -36,11 +43,16 @@ public actor PresenceSyncTransport: SyncTransport { self.session = session } + /// Send one text frame (the `sync.hello`), opening the WebSocket lazily on + /// first use. Throws on auth/transport failure so the caller can reconnect. public func send(_ data: Data) async throws { let task = try await connectedTask() try await task.send(.string(String(decoding: data, as: UTF8.self))) } + /// The inbound raw-frame stream. Opens the WebSocket lazily and yields each + /// message verbatim (the client parses it); the stream ends when the socket + /// closes, on a dropped frame, or on transport error. public nonisolated func frames() -> AsyncThrowingStream { // Bounded buffer (the shared socket also carries the team's frequent // presence ticks, so unbounded could grow if the consumer stalls), but diff --git a/ios/cmuxPackage/Package.resolved b/ios/cmuxPackage/Package.resolved index 11eb8dc78db9..a56b9eea8ef4 100644 --- a/ios/cmuxPackage/Package.resolved +++ b/ios/cmuxPackage/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "cc0a83364cf864c5596b95ee12cb33ace7b9387d0803d9abed381da4e81388eb", + "originHash" : "3329c9757212f7ff6d569e0acda1ab01dd79a80b81e83dccf04518284a5e1729", "pins" : [ { "identity" : "swift-asn1", diff --git a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift index 02d452cb755d..3d1403ff6509 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift @@ -241,18 +241,20 @@ public struct CMUXMobileRootScene: View { let syncTeamIDProvider: (@Sendable () async -> String?)? = { await coordinator.resolvedTeamID } - let makeSyncTransport: (@Sendable (String) -> any SyncTransport)? = - PresenceClient.resolvedServiceBaseURL().map { baseURL in - { teamID in - PresenceSyncTransport( - serviceBaseURL: baseURL, - tokenSource: PresenceTokenSource( - accessToken: { try? await coordinator.accessToken() } - ), - teamID: teamID - ) - } + let makeSyncTransport: (@Sendable (String) -> any SyncTransport)? + if let presenceBaseURL = PresenceClient.resolvedServiceBaseURL() { + makeSyncTransport = { @Sendable (teamID: String) -> any SyncTransport in + PresenceSyncTransport( + serviceBaseURL: presenceBaseURL, + tokenSource: PresenceTokenSource( + accessToken: { try? await coordinator.accessToken() } + ), + teamID: teamID + ) } + } else { + makeSyncTransport = nil + } #if DEBUG return CMUXMobileShellStore( runtime: runtime,