diff --git a/Packages/Shared/CmuxSyncStore/Sources/CmuxSyncStore/DeviceSyncFacade.swift b/Packages/Shared/CmuxSyncStore/Sources/CmuxSyncStore/DeviceSyncFacade.swift index bfbb1013f596..5fd277beb341 100644 --- a/Packages/Shared/CmuxSyncStore/Sources/CmuxSyncStore/DeviceSyncFacade.swift +++ b/Packages/Shared/CmuxSyncStore/Sources/CmuxSyncStore/DeviceSyncFacade.swift @@ -140,8 +140,35 @@ 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). + /// + /// 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() + return rows.compactMap { row in + guard let record = try? decoder.decode(SyncedDeviceRecord.self, from: row.payloadJSON) else { + 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) + } } /// 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 new file mode 100644 index 000000000000..5dbd9bb2765b --- /dev/null +++ b/Packages/Shared/CmuxSyncStore/Tests/CmuxSyncStoreTests/DeviceListSyncWiringTests.swift @@ -0,0 +1,146 @@ +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: "user-A", + 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, provisionalOwnerUserID: "user-A") + #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") + } + + /// 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"]) + // 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/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..338c0eccac97 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,59 @@ 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 + 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 requestingUserID = identityProvider?.currentUserID + let teamID = (await syncTeamIDProvider?()) ?? "" + // 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. + if let active = syncSubscriptionTeamID, active != teamID { + syncTask?.cancel() + syncTask = nil + syncSubscriptionTeamID = nil + evaluateSyncSubscription() + } + // 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 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 { + // 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 + } + } + // Not-yet-synced store or unresolved team: fall through to the registry. + } guard isSignedIn, let deviceRegistry else { registryDevices = [] return @@ -1527,6 +1607,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, 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, + let synced = deviceSyncAuthoritativeTeamID, synced == deviceListTeamID { + return [] + } let connectedID = connectedMacDeviceID return pairedMacs .map { mac in @@ -1575,6 +1666,201 @@ 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? + /// 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 = [] + /// 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 + /// ``evaluatePresenceSubscription()``. + private func evaluateSyncSubscription() { + if isSignedIn, deviceListLocalFirst, syncStore != nil, makeSyncTransport != nil { + startSyncSubscription() + } else { + // 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 = [] + deviceSyncAuthoritativeTeamID = nil + deviceListTeamID = nil + } + } + + /// Run the sync/v1 subscription with the same 1s..60s backoff as presence. + /// 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, syncStore != nil, makeSyncTransport != nil else { return } + // 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 { + // 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 + } + do { + 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)) + } + } + } + + /// 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) + // 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 { + return nil + } + 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?.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 { + deviceSyncAuthoritativeTeamID = teamID + } + 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 + /// 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 { + // 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)" + ) + return nil + } + guard isSignedIn, identityProvider?.currentUserID == requestingUserID else { return nil } + guard (await syncTeamIDProvider?() ?? "") == teamID else { return nil } + let connectedID = connectedMacDeviceID + return 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 + } + } + + /// 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) { + deviceListTeamID = 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 diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSyncTransport.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSyncTransport.swift new file mode 100644 index 000000000000..6ed55590f2d1 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSyncTransport.swift @@ -0,0 +1,133 @@ +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? + + /// 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, + teamID: String, + session: sending URLSession = .shared + ) { + self.serviceBaseURL = serviceBaseURL + self.tokenSource = tokenSource + self.teamID = teamID + 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 + // `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() + 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: + // 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: + 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..d24aaafa9ffc --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift @@ -0,0 +1,293 @@ +import CMUXMobileCore +import CmuxMobilePairedMac +import CmuxMobileShellModel +import CmuxSyncStore +import Foundation +import Testing +@testable import CmuxMobileShell + +/// 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 } + } + + /// A fixed signed-in identity, so provisional rows (owner-scoped) render. + struct FakeIdentity: MobileIdentityProviding { + let userID: String? + @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 { + { _ in NoopSyncTransport() } + } + + 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: Self.owner, + 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) + } + + 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(), + identityProvider: FakeIdentity(userID: Self.owner), + deliveredNotificationClearer: NoopDeliveredNotificationClearer() + ) + 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" }, + 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() + ) + await composite.loadRegistryDevices() + #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) + } + + /// 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 + } + + /// 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"]) + } + + /// 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 { + 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) + } +} 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/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..3d1403ff6509 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,41 @@ 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)? + 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, pairedMacStore: pairedMacStore, deviceRegistry: deviceRegistry, presence: makePresenceClient(), + syncStore: syncStore, + deviceListLocalFirst: deviceListLocalFirst, + syncTeamIDProvider: syncTeamIDProvider, + makeSyncTransport: makeSyncTransport, identityProvider: identityProvider, reachability: reachability, analytics: analytics, @@ -230,6 +279,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,