From 809a6681efa33fdcf662f515f348e4064d19ac52 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:20:04 -0700 Subject: [PATCH 1/6] =?UTF-8?q?iOS:=20registry-driven=20auto-attach=20(sig?= =?UTF-8?q?n=20in=20=E2=86=92=20connected)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A signed-in phone whose team has one reachable Mac now connects on the cold-start path with no QR scan or manual host entry. The attach ticket is route-discovery only; the Mac authorizes the mint purely on matching Stack account, so a registry route + Stack token is sufficient (no prior pairing). - MobileAutoAttachTargetSelector: pure target picker (online-preferred, else single most-recently-seen with a reachable route; ambiguity → nil → manual). - MobileAttachRoutePriority: shared route-priority helper (single source of truth for reconnect, switch, device-tree tap, auto-attach). - MobileAutoAttachFlag: mobileAutoAttach flag, DEBUG on / Release off, override via cmux.mobile.autoAttach.enabled. - attemptAutoAttachIfEligible chains from the no-stored-mac reconnect branch; bounded, cancellable, one attempt per generation; reuses connectToRegistryInstance (Stack-authenticated mint + paired-mac persist) so the next launch takes the normal reconnect path. - Presence seam optional (MobileAutoAttachPresenceProviding); degrades to recency until presence (#5792) lands. Tests: pure selector (online>recency, no-candidate, ambiguity) + composite behavior (connects+persists, fall-through, one-attempt-per-generation, flag off, already-connected). swift test green for CmuxMobileShellModel and CmuxMobileShell; iOS simulator arm64 build succeeds. Co-Authored-By: Claude Opus 4.8 --- .../MobileAutoAttachPresenceProviding.swift | 16 + .../MobileShellComposite.swift | 466 ++++++++- .../MobileAutoAttachTests.swift | 932 ++++++++++++++++++ ...leShellRenderGridLivenessTestSupport.swift | 35 +- .../MobileAttachRoutePriority.swift | 53 + .../MobileAutoAttachFlag.swift | 33 + .../MobileAutoAttachTargetSelector.swift | 134 +++ .../MobileAutoAttachTargetSelectorTests.swift | 247 +++++ plans/feat-ios-auto-attach/DESIGN.md | 103 ++ 9 files changed, 1994 insertions(+), 25 deletions(-) create mode 100644 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileAutoAttachPresenceProviding.swift create mode 100644 Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileAutoAttachTests.swift create mode 100644 Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAttachRoutePriority.swift create mode 100644 Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachFlag.swift create mode 100644 Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachTargetSelector.swift create mode 100644 Packages/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileAutoAttachTargetSelectorTests.swift create mode 100644 plans/feat-ios-auto-attach/DESIGN.md diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileAutoAttachPresenceProviding.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileAutoAttachPresenceProviding.swift new file mode 100644 index 000000000000..9749778043dc --- /dev/null +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileAutoAttachPresenceProviding.swift @@ -0,0 +1,16 @@ +public import Foundation + +/// An online/offline signal for registry-driven auto-attach target selection. +/// +/// Optional seam: when no presence provider is wired (the presence service is +/// not merged yet, #5792), auto-attach degrades to most-recently-seen selection, +/// which is correct for the dominant single-Mac team. Once the presence client +/// lands, an adapter conforming to this protocol lets auto-attach prefer the live +/// Mac and treat multiple live Macs as ambiguous (fall through to manual pair) +/// rather than guessing on recency. +public protocol MobileAutoAttachPresenceProviding: Sendable { + /// The device ids the presence service currently reports as online, scoped to + /// the signed-in user's team. Best-effort: a `nil` snapshot means "no + /// presence signal right now," so selection falls back to recency. + func onlineDeviceIDs() async -> Set? +} diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index daa82110e792..a4dbb7674d9b 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -418,6 +418,40 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { private let presence: (any PresenceSubscribing)? private let identityProvider: (any MobileIdentityProviding)? private let reachability: any ReachabilityProviding + /// Whether registry-driven auto-attach ("sign in → connected") is enabled. + /// Resolved from ``MobileAutoAttachFlag`` at the composition root (DEBUG on, + /// Release off until dogfooded) and injected as a plain `Bool` so the shell + /// stays testable without `UserDefaults`. + private let autoAttachEnabled: Bool + /// Optional online/offline signal for auto-attach target selection. When + /// `nil` (the presence service is not wired yet, #5792), selection degrades + /// to most-recently-seen, which is correct for the dominant single-Mac team. + private let autoAttachPresence: (any MobileAutoAttachPresenceProviding)? + /// Monotonic per-attempt token for registry-driven auto-attach. + /// + /// Every new attempt claims the next generation; the gate-owning runner, its + /// restoring deadline, and its cleanup all compare against the generation they + /// captured, so only the *current* attempt can resolve the restoring gate or + /// run the destructive connect. A boolean is insufficient because sign-out + /// resets it while an old runner/deadline is still alive and the next sign-in + /// re-sets it for a different attempt — a generation cannot be confused across + /// attempts. ``cancelAutoAttach`` bumps it on sign-out and when a manual + /// pairing begins, superseding any in-flight attempt so its still-alive awaits + /// discard their results at the next ``performAutoAttach`` guard. + private var autoAttachGeneration: Int = 0 + /// The generation of the auto-attach attempt currently running, or `nil` when + /// none is in flight. Set synchronously when an attempt claims its generation + /// and cleared when it finishes (if still current). The reconnect caller reads + /// ``autoAttachInFlight`` to dedupe a concurrent trigger without disturbing the + /// running attempt's gate. + private var autoAttachRunningGeneration: Int? + /// Whether an auto-attach attempt is currently running. + private var autoAttachInFlight: Bool { autoAttachRunningGeneration != nil } + /// True while a gate-owning auto-attach runner holds the restoring gate, so + /// ``cancelAutoAttach`` knows to resolve the gate when it supersedes that + /// runner (otherwise a manual pairing that fails after superseding the runner + /// would leave RestoringSessionView stuck up). + private var autoAttachOwnsRestoringGate: Bool = false // Internal (not private): used by the dismiss-sync extension file. let deliveredNotificationClearer: any DeliveredNotificationClearing /// Durable outbox for phone→Mac dismissals. @@ -596,6 +630,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { clientIDRepository: MobileClientIDRepository = MobileClientIDRepository(defaults: .standard), identityProvider: (any MobileIdentityProviding)? = nil, reachability: any ReachabilityProviding = ReachabilityService(), + autoAttachEnabled: Bool = MobileAutoAttachFlag.isEnabled(), + autoAttachPresence: (any MobileAutoAttachPresenceProviding)? = nil, deliveredNotificationClearer: any DeliveredNotificationClearing = SystemDeliveredNotificationClearer(), pendingDismissQueue: PendingNotificationDismissQueue = PendingNotificationDismissQueue(), pairingHintDefaults: UserDefaults = .standard, @@ -612,6 +648,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.presence = presence self.identityProvider = identityProvider self.reachability = reachability + self.autoAttachEnabled = autoAttachEnabled + self.autoAttachPresence = autoAttachPresence self.deliveredNotificationClearer = deliveredNotificationClearer self.pendingDismissQueue = pendingDismissQueue self.pairingHintDefaults = pairingHintDefaults @@ -773,6 +811,15 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // branch clears the hint. Bump the reconnect generation so any in-flight // reconnect is superseded and can't re-set these flags after sign-out. storedMacReconnectGeneration &+= 1 + // Supersede any in-flight auto-attach BEFORE resetting the restoring-gate + // flags below, so sign-out's resets are the final word. `cancelAutoAttach` + // sets `didFinishStoredMacReconnectAttempt = true` when it supersedes a + // gate-owning attempt; doing it first means the resets below clear that, + // leaving the next account's sign-in with a clean (not "already finished") + // gate. This supersedes a fresh-install attempt parked in connectManualHost + // so it cannot suppress the next account's reconnect/auto-attach, resolve a + // gate, or drive a connect under the new account. + cancelAutoAttach() isReconnectingStoredMac = false didFinishStoredMacReconnectAttempt = false replaceRemoteClient(with: nil) @@ -1093,6 +1140,20 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } public func connectManualHost(name: String, host: String, port: Int) async { + await connectManualHost(name: name, host: host, port: port, supersedeAutoAttach: true) + } + + /// - Parameter supersedeAutoAttach: `true` for a user-initiated submission, so + /// it cancels any in-flight auto-attach BEFORE the validation guards (an + /// invalid host/port returns before `beginPairingAttempt`, so the cancel must + /// happen here). Auto-attach's OWN connect passes `false` so it does not + /// cancel itself. This is an explicit per-call parameter (not a shared + /// marker), so a concurrent user submission during auto-attach's own connect + /// is never misclassified: each call carries its own intent on the stack. + func connectManualHost(name: String, host: String, port: Int, supersedeAutoAttach: Bool) async { + if supersedeAutoAttach { + supersedeInFlightAutoAttach() + } let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) guard let normalizedHost = MobileShellRouteAuthPolicy.normalizedManualHost(host) else { connectionError = L10n.string("mobile.addDevice.invalidHost", defaultValue: "Enter a host or IP address, without spaces or URL paths.") @@ -1124,7 +1185,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } let directRoute = try? Self.manualHostRoute(host: normalizedHost, port: port) - let attemptID = beginPairingAttempt(method: "manual") + // Propagate the supersede intent: a user submission already cancelled + // auto-attach above; auto-attach's own connect (supersedeAutoAttach=false) + // must not cancel itself here either. + let attemptID = beginPairingAttempt(method: "manual", supersedeAutoAttach: supersedeAutoAttach) // Fast offline preflight: fail immediately instead of stacking // per-route timeouts into the opaque ~60s blob. let manualRoutes = directRoute.map { [$0] } ?? [] @@ -1203,8 +1267,56 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return false } guard let mac = saved else { - // Definitively no active Mac: clear the hint so future launches show - // the add-device sheet immediately with no restoring flash. + // Definitively no active Mac. Before falling through to the pair + // screen, try registry-driven auto-attach: a signed-in phone whose + // team has one reachable Mac connects with no QR. Auto-attach owns the + // restoring gate from here (it persists a paired Mac on success, so the + // next launch takes the normal stored-mac path above and never + // re-runs). It is bounded, cancellable, and falls through to the pair + // screen on no candidate / failure, so it can't strand the user. + if autoAttachEnabled { + // A concurrent reconnect trigger (foreground + sign-in) must not + // touch the restoring gate while the FIRST auto-attach is still + // running: that would drop RestoringSessionView to the pair/QR + // screen mid-connect, defeating the no-flash onboarding path. The + // first attempt owns the gate and resolves it itself, so this + // superseding trigger bails without disturbing it. (`autoAttachInFlight` + // is set synchronously before any await in the runner, so this + // read is race-free on the main actor.) + // + // The cross-account hazard — a fresh-install attempt parked in + // connectManualHost while the user signs out and into another + // account — is handled at the source: `signOut()` resets + // `autoAttachInFlight` (and the restoring-gate flags), so the new + // account's reconnect sees the flag false here and runs its own + // auto-attach normally rather than being suppressed by the stale + // attempt. + if autoAttachInFlight { return false } + let attached = await runAutoAttachOwningRestoringGate(stackUserID: stackUserID) + if attached { return true } + // Auto-attach finished without connecting; the runner already + // resolved the restoring gate. Clear the hint so future launches + // show the add-device sheet immediately with no restoring flash. + // + // Guard the write: a concurrent event (a manual pairing the user + // completed, or an account switch) could have run while this + // attempt awaited the registry. Only write the negative hint when + // we are still the current reconnect generation, still + // disconnected, and still signed in — otherwise this stale write + // would clobber the `true` hint a successful manual pairing set, or + // write the global hint after sign-out. `setHasKnownPairedMac` is + // generation-guarded; the extra state checks cover the + // manual-pairing case (which does not bump the reconnect + // generation but does flip connectionState/hint). + if generation == storedMacReconnectGeneration, + isSignedIn, + connectionState != .connected { + setHasKnownPairedMac(false, generation: generation) + } + return false + } + // Auto-attach disabled: no stored Mac means resolve the gate and fall + // through to add-device. setHasKnownPairedMac(false, generation: generation) finishStoredMacReconnectAttempt(generation: generation) return false @@ -1337,6 +1449,271 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } } + // MARK: - Registry-driven auto-attach + + /// Registry-driven auto-attach: the "sign in → connected" onboarding path. + /// + /// When a signed-in phone has no stored pairing and is disconnected, this + /// loads the team-scoped device registry, picks the single obvious Mac (the + /// one online Mac, else the single most-recently-active Mac with a reachable + /// route), and connects to it via the same path the device-tree tap uses + /// (`connectToRegistryInstance`), which mints the attach ticket + /// Stack-authenticated on the Mac and persists the pairing. The persist means + /// the *next* launch takes the ordinary stored-mac reconnect path and never + /// re-runs auto-attach. + /// + /// Conservative and bounded by construction: + /// - Same-account only: the registry is scoped to the signed-in user's team, + /// and the Mac authorizes the mint on matching Stack account, so a + /// different-account Mac never appears and could never be connected. + /// Cancel any in-flight auto-attach attempt and supersede it by bumping the + /// generation, so its still-alive awaits discard their results and its + /// runner/deadline can no longer resolve the restoring gate or drive a + /// connect. Called on sign-out and on every user-initiated pairing, so a stale + /// background auto-attach never overrides the new account or the user's manual + /// pairing. + /// + /// When the superseded attempt was holding the launch restoring gate, this + /// resolves the gate here: the superseded runner's own cleanup is generation- + /// guarded and now a no-op, so without this a manual pairing that later fails + /// would leave RestoringSessionView stuck up. + /// Supersede any in-flight auto-attach on a USER-initiated pairing. + /// + /// Beyond `cancelAutoAttach` (which bumps the generation so a parked attempt's + /// awaits discard their results), this also invalidates the active pairing + /// attempt when an auto-attach is in flight. That covers the case where + /// auto-attach has ALREADY entered its own `connectManualHost` and minted a + /// `pairingAttemptID`: bumping the generation alone would not stop that + /// in-flight connect (its `isCurrentPairingAttempt` checks key off the + /// attempt id, not the generation), so a user's invalid manual submission that + /// returns before its own `beginPairingAttempt` could still be overridden by + /// the background connect. Regenerating the attempt id makes auto-attach's + /// in-flight connect bail at its next `isCurrentPairingAttempt` check. + /// + /// Guarded on `autoAttachInFlight` so it never clobbers an unrelated live + /// pairing attempt when no auto-attach is running. + private func supersedeInFlightAutoAttach() { + let wasInFlight = autoAttachInFlight + cancelAutoAttach() + guard wasInFlight else { return } + // Auto-attach may already be inside its own `connect()`, which installs the + // live client guarded by `connectionGeneration` (not `pairingAttemptID`), + // and whose `isCurrentPairingAttempt` check runs only AFTER it has set + // `connectionState = .connected`. Invalidating the pairing attempt alone + // would not stop that in-flight connect. Bump the connection generation and + // cancel in-flight remote work — the same supersession `beginPairingAttempt` + // performs — so auto-attach's running connect discards its result at its + // `generation == connectionGeneration` guard instead of landing over the + // user's explicit pairing. This runs BEFORE the validation guards in + // `connectManualHost`, so even an invalid user submission supersedes it. + invalidatePairingAttempt() + connectionGeneration = UUID() + cancelRemoteOperationTasks() + } + + private func cancelAutoAttach() { + autoAttachGeneration &+= 1 + autoAttachRunningGeneration = nil + if autoAttachOwnsRestoringGate { + autoAttachOwnsRestoringGate = false + isReconnectingStoredMac = false + didFinishStoredMacReconnectAttempt = true + } + } + + /// Runs an auto-attach attempt that owns the launch restoring gate + /// (``RestoringSessionView``) for its whole lifetime, then resolves it. + /// + /// Used from the no-stored-Mac reconnect branch. It holds the gate up while + /// auto-attach runs (so onboarding shows "Restoring session…" instead of a QR + /// flash), caps that window with the same bounded deadline the stored-Mac path + /// uses, and resolves the gate based on the per-attempt generation it owns — + /// not the stored-Mac reconnect generation, which a concurrent trigger may + /// bump. A newer auto-attach (or `cancelAutoAttach`) supersedes this one by + /// advancing ``autoAttachGeneration``, so a stale runner/deadline becomes a + /// no-op and cannot strand the gate or run a connect. + /// + /// - Returns: `true` only when a live connection landed under this attempt. + private func runAutoAttachOwningRestoringGate(stackUserID: String?) async -> Bool { + let generation = beginAutoAttachGeneration() + isReconnectingStoredMac = true + // Mark that an auto-attach runner holds the gate, so a user-initiated + // pairing that supersedes this attempt via `cancelAutoAttach` resolves the + // gate instead of leaving it stranded. + autoAttachOwnsRestoringGate = true + // Cap the restoring-gate window: a single stale/offline registry candidate + // makes the manual connect hang on its timeout, so without this deadline + // the gate would stay up for the whole registry + connect timeout and the + // fresh-install path would look hung instead of falling through to the + // pair sheet. The connect keeps running in the background, so a later + // success still flips to the workspaces; this only resolves the visible + // gate. Guarded by the per-attempt generation, so a superseded attempt's + // deadline can never clear a newer attempt's gate. Bounded and cancellable + // (not a poll) — cancelled the instant the attempt returns below. + let restoringDeadline = Task { [weak self] in + try? await ContinuousClock().sleep( + for: .seconds(Self.storedMacReconnectRestoringDeadlineSeconds) + ) + guard let self, !Task.isCancelled, + generation == self.autoAttachGeneration, + self.connectionState != .connected else { return } + self.isReconnectingStoredMac = false + self.didFinishStoredMacReconnectAttempt = true + } + let attached = await performAutoAttach(stackUserID: stackUserID, generation: generation) + restoringDeadline.cancel() + endAutoAttachGeneration(generation) + // Resolve the gate only if we are still the current attempt: a superseding + // attempt (or cancel) now owns the gate and will resolve it itself (the + // cancel path already did so via `cancelAutoAttach`). + if generation == autoAttachGeneration { + autoAttachOwnsRestoringGate = false + isReconnectingStoredMac = false + didFinishStoredMacReconnectAttempt = true + // This runner is the authoritative determiner for the no-stored-Mac + // path: it definitively found no stored Mac AND no auto-attach target. + // Clear the negative hint HERE (keyed on the auto-attach generation, + // which we still own) rather than relying on the caller's + // stored-mac-generation-guarded write, which a concurrent duplicate + // reconnect trigger can supersede — leaving `pairedMacHintUndetermined` + // unresolved and the restoring gate stuck on a fresh install. + if !attached, isSignedIn, connectionState != .connected { + hasKnownPairedMac = false + } + } + return attached + } + + /// Public/test entry point for a one-shot auto-attach attempt that does NOT + /// own the restoring gate. + /// + /// Dedupes against any attempt already in flight: if one is running, returns + /// `false` immediately rather than racing a second registry load / destructive + /// connect, satisfying the one-attempt-at-a-time contract for every caller. + /// + /// - Returns: `true` only when a live connection landed under this attempt. + @discardableResult + func attemptAutoAttachIfEligible(stackUserID: String?) async -> Bool { + guard autoAttachEnabled, isSignedIn, connectionState != .connected else { return false } + guard !autoAttachInFlight else { return false } + let generation = beginAutoAttachGeneration() + defer { endAutoAttachGeneration(generation) } + return await performAutoAttach(stackUserID: stackUserID, generation: generation) + } + + /// Claim the next auto-attach generation and mark it the in-flight attempt. + /// Synchronous (no await), so a concurrent caller observes `autoAttachInFlight` + /// true at its next main-actor hop and dedupes. + private func beginAutoAttachGeneration() -> Int { + autoAttachGeneration &+= 1 + let generation = autoAttachGeneration + autoAttachRunningGeneration = generation + return generation + } + + /// Clear the in-flight marker when `generation` is still current, so a + /// superseded attempt does not erase a newer attempt's running marker. + private func endAutoAttachGeneration(_ generation: Int) { + guard generation == autoAttachGeneration else { return } + autoAttachRunningGeneration = nil + } + + /// The shared auto-attach flow: load the registry, pick the single obvious + /// Mac, and connect to it via the proven registry connect path. + /// + /// Guarded by the per-attempt `generation`: after every suspension it bails + /// unless this is still the current attempt AND the same signed-in account, so + /// a task left alive by a sign-out/account switch or superseded by a newer + /// attempt can never resume and drive a destructive connect. This is the + /// equivalent of the stored-Mac path owning a pairing attempt id before its + /// connect, and mirrors the account-switch guard in ``loadPairedMacs`` / + /// ``loadRegistryDevices``. + /// + /// - Returns: `true` only when a live connection landed under this attempt. + private func performAutoAttach(stackUserID: String?, generation: Int) async -> Bool { + // Capture the requesting account; after any suspension the result is + // discarded unless this is still the same signed-in user and the current + // attempt. + let requestingUserID = stackUserID ?? identityProvider?.currentUserID + func stillCurrent() -> Bool { + generation == autoAttachGeneration + && isSignedIn + && connectionState != .connected + && identityProvider?.currentUserID == requestingUserID + } + guard autoAttachEnabled, stillCurrent(), let deviceRegistry else { return false } + // Auto-attach decides from a FRESHLY-confirmed registry list, not the + // store-wide `registryDevices` cache. `loadRegistryDevices()` deliberately + // keeps the prior cache on a transient failure (so a UI blip never blanks + // the device tree), but auto-attach must NOT connect off a stale list + // during a registry outage — the contract is to degrade to manual. So we + // read the outcome directly and proceed only on `.ok`; `.authRejected` / + // `.transientFailure` fall through to the pair screen. The UI cache is + // refreshed separately below so the tree still benefits from this load. + let outcome = await deviceRegistry.listDevices() + // The load suspended the main actor; bail if the user connected, signed + // out, switched accounts, or a newer attempt superseded this one. + guard stillCurrent() else { return false } + guard case let .ok(freshDevices) = outcome else { + // Registry unreachable/unauthorized: degrade to manual. Still refresh + // the UI cache (it honors the same outcome semantics) so the device + // tree reflects the latest known state. + await loadRegistryDevices() + return false + } + // Keep the UI device tree in sync with this fresh list. + await loadRegistryDevices() + guard stillCurrent() else { return false } + + let presenceOnline = await autoAttachPresence?.onlineDeviceIDs() + guard stillCurrent() else { return false } + + // On a physical phone, reject loopback routes: a `127.0.0.1` route names + // the phone itself, not the Mac, and loopback is Stack-auth-trusted, so + // auto-dialing it would fail and could hand the bearer to a phone-local + // listener. The simulator (where 127.0.0.1 IS the host Mac) keeps loopback. + let rejectLoopback = Self.isPhysicalDevice + guard let target = MobileAutoAttachTargetSelector.selectTarget( + devices: freshDevices, + supportedRouteKinds: runtime?.supportedRouteKinds ?? [], + presenceOnlineDeviceIDs: presenceOnline ?? [], + presenceAvailable: presenceOnline != nil, + rejectLoopback: rejectLoopback, + now: runtime?.now() ?? Date() + ) else { + return false + } + + // Final guard immediately before the destructive connect: if a manual + // pairing began (which calls `cancelAutoAttach`), the user connected, or + // the account changed since the last await, do NOT start + // connectToRegistryInstance — that would invalidate the user's manual + // pairing attempt. `selectTarget` is synchronous, so this covers the + // window up to the connect. + guard stillCurrent() else { return false } + + analytics.capture("ios_auto_attach_attempt", [ + "candidate_device_count": .int(registryDevices.count), + "presence_available": .bool(presenceOnline != nil), + ]) + // Reuse the proven registry connect path: it mints Stack-authenticated, + // rolls back to the previous active Mac on failure, and persists the + // pairing into the store on success. `supersedeAutoAttach: false` marks + // this as auto-attach's OWN connect (an explicit per-call parameter, not a + // shared marker), so the `beginPairingAttempt` inside it does not cancel + // auto-attach. A concurrent USER pairing during this connect carries + // `supersedeAutoAttach: true` on its own call and still supersedes us. + await connectToRegistryInstance( + device: target.device, + instance: target.instance, + rejectLoopback: rejectLoopback, + supersedeAutoAttach: false + ) + let connected = connectionState == .connected + analytics.capture("ios_auto_attach_result", ["connected": .bool(connected)]) + return connected + } + // MARK: - Paired Mac switching /// Every Mac paired with this device, for the host switcher. Refreshed via @@ -1698,14 +2075,34 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// - Parameters: /// - device: The registry device the instance belongs to. /// - instance: The tag/app-instance to connect to. + /// - Parameter rejectLoopback: When `true`, loopback routes are skipped when + /// choosing the host. Auto-attach passes the physical-device flag so a + /// fresh phone never auto-dials a `127.0.0.1` route (which names the phone + /// itself, not the Mac, and is Stack-auth-trusted). The default `false` + /// preserves the simulator dev flow and the explicit user device-tree tap. + /// - Parameter supersedeAutoAttach: `true` (default) for a user-initiated + /// device-tree tap, which cancels any in-flight auto-attach. Auto-attach's + /// OWN connect passes `false` so it does not cancel itself. public func connectToRegistryInstance( device: RegistryDevice, - instance: RegistryAppInstance + instance: RegistryAppInstance, + rejectLoopback: Bool = false, + supersedeAutoAttach: Bool = true ) async { + // A user-initiated device-tree tap supersedes any in-flight auto-attach + // FIRST — before the no-route guard below — so even tapping an + // unavailable/stale instance (which returns early) still cancels a + // background attempt rather than letting it later connect to a different + // target over the user's explicit choice. Auto-attach's own connect passes + // `supersedeAutoAttach: false` and is exempt. + if supersedeAutoAttach { + supersedeInFlightAutoAttach() + } let supportedKinds = runtime?.supportedRouteKinds ?? [] - guard let (host, port) = Self.firstReconnectHostPortRoute( + guard let (host, port) = MobileAttachRoutePriority.firstReachableHostPort( instance.routes, - supportedKinds: supportedKinds + supportedKinds: supportedKinds, + rejectLoopback: rejectLoopback ), let normalizedHost = MobileShellRouteAuthPolicy.normalizedManualHost(host) else { mobileShellLog.error( "connectToRegistryInstance: no reconnectable route device=\(device.deviceId, privacy: .public) tag=\(instance.tag, privacy: .public)" @@ -1727,7 +2124,17 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // stale/offline. Excluding it would strand the user on a same-device tag // switch failure. let previousActive = pairedMacs.first { $0.isActive } - await connectManualHost(name: device.displayName ?? host, host: host, port: port) + // The supersede already ran at the top of this method (for user taps), so + // the inner connect must not re-run it: `false` here means "this connect + // does not itself supersede auto-attach." For auto-attach's own call + // (supersedeAutoAttach == false at the top) this is likewise false, so its + // own connect never cancels itself. + await connectManualHost( + name: device.displayName ?? host, + host: host, + port: port, + supersedeAutoAttach: false + ) // Persist as the active paired Mac only when the live connection is to // THIS route (a switch tapped while this connect was in flight could win // the connection; matching the live route avoids persisting a stale @@ -1744,11 +2151,20 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return } if let pairedMacStore, !device.deviceId.hasPrefix("manual-") { + // Persist the same route set we were willing to dial. When loopback is + // rejected (physical phone), strip loopback routes BEFORE storing, so + // the next stored-Mac reconnect (which uses `firstReconnectHostPortRoute` + // without a rejectLoopback flag) can never pick a `127.0.0.1` route that + // names the phone itself and is Stack-auth-trusted. The simulator keeps + // loopback (there it IS the host Mac). + let persistedRoutes = rejectLoopback + ? instance.routes.filter { !MobileShellRouteAuthPolicy.routeIsLoopback($0) } + : instance.routes do { try await pairedMacStore.upsert( macDeviceID: device.deviceId, displayName: device.displayName, - routes: instance.routes, + routes: persistedRoutes, markActive: true, stackUserID: identityProvider?.currentUserID ) @@ -1859,16 +2275,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { _ routes: [CmxAttachRoute], supportedKinds: [CmxAttachTransportKind] ) -> (String, Int)? { - let supportedKinds = Set(supportedKinds) - for route in routes.sorted(by: routeSortsBefore) { - if !supportedKinds.isEmpty, !supportedKinds.contains(route.kind) { - continue - } - if case let .hostPort(host, port) = route.endpoint { - return (host, port) - } - } - return nil + MobileAttachRoutePriority.firstReachableHostPort(routes, supportedKinds: supportedKinds) + .map { ($0.host, $0.port) } } /// Runs one paired-Mac store mutation on the serialized write chain. @@ -2122,6 +2530,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { _ rawValue: String? = nil, acceptedVersionWarning: Bool ) async -> MobilePairingURLConnectionResult { + // `beginPairingAttempt` below supersedes any in-flight auto-attach (and + // resolves its restoring gate), so a background attempt that resumes + // mid-pairing cannot invalidate this manual attempt. let rawURL = Self.normalizedPairingURL(rawValue ?? pairingCode) _ = beginPairingValidationAttempt() connectionAttemptGeneration = UUID() @@ -3161,7 +3572,19 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// The one shared entry every pairing flow funnels through, so it is also the /// single `ios_pairing_started` fire-site. `method` is `qr`/`manual`/ /// `attach_url`; pass `nil` for non-instrumented internal flows (preview). - private func beginPairingAttempt(method: String? = nil) -> UUID { + private func beginPairingAttempt(method: String? = nil, supersedeAutoAttach: Bool = true) -> UUID { + // Every pairing attempt funnels through here, so this is the single seam + // that supersedes any in-flight auto-attach when the user initiates a + // pairing (manual host, QR/code, or a device-tree tap). Auto-attach's OWN + // connect passes `supersedeAutoAttach: false` so it does not cancel itself. + // The supersede resolves the restoring gate if the superseded attempt held + // it (so the user is never stranded on RestoringSessionView) and + // invalidates auto-attach's in-flight pairing attempt so a background + // connect already in progress bails. The fresh `pairingAttemptID` below + // then becomes the current attempt for this user pairing. + if supersedeAutoAttach { + supersedeInFlightAutoAttach() + } let attemptID = beginPairingValidationAttempt(method: method) connectionGeneration = UUID() connectionAttemptGeneration = UUID() @@ -4978,10 +5401,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } private static func routeSortsBefore(_ left: CmxAttachRoute, _ right: CmxAttachRoute) -> Bool { - if left.priority == right.priority { - return left.id < right.id - } - return left.priority < right.priority + MobileAttachRoutePriority.sortsBefore(left, right) } private func applyPreviewTicket(_ ticket: CmxAttachTicket, route: CmxAttachRoute) { diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileAutoAttachTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileAutoAttachTests.swift new file mode 100644 index 000000000000..9369ec65741e --- /dev/null +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileAutoAttachTests.swift @@ -0,0 +1,932 @@ +import CMUXMobileCore +import CmuxMobilePairedMac +import CmuxMobileRPC +import CmuxMobileShellModel +import CmuxMobileTransport +import Foundation +import Testing +@testable import CmuxMobileShell + +/// Behavior of registry-driven auto-attach on the composite: the "sign in → +/// connected" path. Drives the real connect through the scripted-host transport +/// the liveness tests use, with in-memory registry / paired-mac / identity +/// doubles, so these verify the end-to-end orchestration (eligibility gates, +/// in-flight dedupe, persistence, fall-through) rather than re-testing the pure +/// selector. +@MainActor +@Suite struct MobileAutoAttachTests { + // MARK: - Doubles + + @MainActor + private final class FakeIdentity: MobileIdentityProviding { + var userID: String? + init(userID: String?) { self.userID = userID } + var currentUserID: String? { userID } + } + + private actor InMemoryPairedMacStore: MobilePairedMacStoring { + private var macs: [MobilePairedMac] = [] + + func upsert( + macDeviceID: String, + displayName: String?, + routes: [CmxAttachRoute], + markActive: Bool, + stackUserID: String?, + now: Date + ) async throws { + if markActive { + macs = macs.map { var m = $0; m.isActive = false; return m } + } + if let idx = macs.firstIndex(where: { $0.macDeviceID == macDeviceID }) { + macs[idx].displayName = displayName + macs[idx].routes = routes + macs[idx].lastSeenAt = now + if markActive { macs[idx].isActive = true } + } else { + macs.append(MobilePairedMac( + macDeviceID: macDeviceID, + displayName: displayName, + routes: routes, + createdAt: now, + lastSeenAt: now, + isActive: markActive, + stackUserID: stackUserID + )) + } + } + + func loadAll(stackUserID: String?) async throws -> [MobilePairedMac] { + macs + .filter { stackUserID == nil || $0.stackUserID == stackUserID } + .sorted { $0.lastSeenAt > $1.lastSeenAt } + } + + func activeMac(stackUserID: String?) async throws -> MobilePairedMac? { + macs.first { $0.isActive && (stackUserID == nil || $0.stackUserID == stackUserID) } + } + + func setActive(macDeviceID: String) async throws { + macs = macs.map { var m = $0; m.isActive = ($0.macDeviceID == macDeviceID); return m } + } + + func remove(macDeviceID: String) async throws { + macs.removeAll { $0.macDeviceID == macDeviceID } + } + + func removeAll() async throws { macs.removeAll() } + + func count() -> Int { macs.count } + } + + private struct AlwaysOnlineReachability: ReachabilityProviding { + var isOnline: Bool { get async { true } } + func pathChanges() -> AsyncStream { AsyncStream { _ in } } + } + + private actor FakeRegistry: DeviceRegistryRefreshing { + private let devices: [RegistryDevice] + private(set) var listCallCount = 0 + // When set, listDevices returns this outcome instead of `.ok(devices)`, + // modeling a registry outage (transient failure / auth rejection). + private var forcedOutcome: DeviceRegistryListOutcome? + + func forceOutcome(_ outcome: DeviceRegistryListOutcome?) { forcedOutcome = outcome } + // Optional gate: when armed, the first listDevices parks until released, + // so a test can hold an auto-attach attempt mid-flight. + private var gateContinuations: [CheckedContinuation] = [] + private var gateArmed = false + private var gateReleased = false + + init(devices: [RegistryDevice]) { self.devices = devices } + + func armGate() { gateArmed = true } + + func releaseGate() { + gateReleased = true + let conts = gateContinuations + gateContinuations = [] + for c in conts { c.resume() } + } + + func freshRoutes(forMacDeviceID macDeviceID: String) async -> [CmxAttachRoute]? { nil } + + func listDevices() async -> DeviceRegistryListOutcome { + listCallCount += 1 + if gateArmed, !gateReleased { + await withCheckedContinuation { gateContinuations.append($0) } + } + return forcedOutcome ?? .ok(devices) + } + } + + // MARK: - Fixtures + + private func loopbackRoute() throws -> CmxAttachRoute { + try CmxAttachRoute( + id: "debug_loopback", + kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 56_584) + ) + } + + private func device(id: String, lastSeen: Date, route: CmxAttachRoute) -> RegistryDevice { + RegistryDevice( + deviceId: id, + platform: "mac", + displayName: "Test Mac \(id)", + lastSeenAt: lastSeen, + instances: [RegistryAppInstance(tag: "stable", routes: [route], lastSeenAt: lastSeen)] + ) + } + + private func makeStore( + devices: [RegistryDevice], + pairedStore: InMemoryPairedMacStore, + registry: FakeRegistry, + clock: TestClock, + router: LivenessHostRouter, + box: TransportBox, + autoAttachEnabled: Bool = true, + userID: String? = "user-1", + identity: FakeIdentity? = nil, + supportedRouteKinds: [CmxAttachTransportKind] = [.debugLoopback] + ) -> MobileShellComposite { + let runtime = LivenessTestRuntime( + transportFactory: LivenessTransportFactory(router: router, box: box), + now: { clock.now }, + supportedRouteKinds: supportedRouteKinds + ) + // Isolated defaults per store so the persisted `hasKnownPairedMac` hint + // can't leak across tests (the production key lives in `.standard`). + let hintDefaults = UserDefaults(suiteName: "MobileAutoAttachTests.\(UUID().uuidString)")! + return MobileShellComposite( + runtime: runtime, + isSignedIn: true, + pairedMacStore: pairedStore, + deviceRegistry: registry, + identityProvider: identity ?? FakeIdentity(userID: userID), + reachability: AlwaysOnlineReachability(), + autoAttachEnabled: autoAttachEnabled, + deliveredNotificationClearer: NoopDeliveredNotificationClearer(), + pairingHintDefaults: hintDefaults + ) + } + + // MARK: - Tests + + @Test func singleCandidateConnectsAndPersistsActivePairing() async throws { + let clock = TestClock() + let route = try loopbackRoute() + let pairedStore = InMemoryPairedMacStore() + let registry = FakeRegistry(devices: [device(id: "mac-A", lastSeen: clock.now, route: route)]) + let store = makeStore( + devices: [], + pairedStore: pairedStore, + registry: registry, + clock: clock, + router: LivenessHostRouter(), + box: TransportBox() + ) + + let attached = await store.attemptAutoAttachIfEligible(stackUserID: "user-1") + + #expect(attached) + #expect(store.connectionState == .connected) + // Persisted as the active paired Mac so the next launch takes the normal + // stored-mac reconnect path and never re-runs auto-attach. + let active = try await pairedStore.activeMac(stackUserID: "user-1") + #expect(active?.macDeviceID == "mac-A") + } + + @Test func noCandidateReturnsFalseAndPersistsNothing() async throws { + let clock = TestClock() + let pairedStore = InMemoryPairedMacStore() + // No devices in the registry → no candidate. + let registry = FakeRegistry(devices: []) + let store = makeStore( + devices: [], + pairedStore: pairedStore, + registry: registry, + clock: clock, + router: LivenessHostRouter(), + box: TransportBox() + ) + + let attached = await store.attemptAutoAttachIfEligible(stackUserID: "user-1") + + #expect(!attached) + #expect(store.connectionState != .connected) + let count = await pairedStore.count() + #expect(count == 0) + } + + @Test func flagOffNeverAttempts() async throws { + let clock = TestClock() + let route = try loopbackRoute() + let pairedStore = InMemoryPairedMacStore() + let registry = FakeRegistry(devices: [device(id: "mac-A", lastSeen: clock.now, route: route)]) + let store = makeStore( + devices: [], + pairedStore: pairedStore, + registry: registry, + clock: clock, + router: LivenessHostRouter(), + box: TransportBox(), + autoAttachEnabled: false + ) + + let attached = await store.attemptAutoAttachIfEligible(stackUserID: "user-1") + + #expect(!attached) + #expect(store.connectionState != .connected) + let listCalls = await registry.listCallCount + #expect(listCalls == 0, "flag off must not even hit the registry") + } + + @Test func alreadyConnectedNeverAttempts() async throws { + let clock = TestClock() + let router = LivenessHostRouter() + let box = TransportBox() + // Bring the store to .connected first via the scripted attach URL. + let store = try await makeConnectedStore(router: router, box: box, clock: clock) + #expect(store.connectionState == .connected) + + // Even though the store has no registry/paired doubles, the guard must + // short-circuit before any work because it is already connected. + let attached = await store.attemptAutoAttachIfEligible(stackUserID: "user-1") + #expect(!attached) + } + + @Test func concurrentTriggersDedupeToOneAttempt() async throws { + let clock = TestClock() + let route = try loopbackRoute() + let pairedStore = InMemoryPairedMacStore() + let registry = FakeRegistry(devices: [device(id: "mac-A", lastSeen: clock.now, route: route)]) + let store = makeStore( + devices: [], + pairedStore: pairedStore, + registry: registry, + clock: clock, + router: LivenessHostRouter(), + box: TransportBox() + ) + + // Two near-simultaneous triggers (foreground + sign-in). The in-flight + // flag makes the second a true no-op while the first runs, so exactly one + // attempt drives a connect: one trigger returns true and the other false + // (deduped), and exactly one active pairing is persisted (no duplicate + // destructive connect, no duplicate rows). + async let first = store.attemptAutoAttachIfEligible(stackUserID: "user-1") + async let second = store.attemptAutoAttachIfEligible(stackUserID: "user-1") + let results = await [first, second] + + #expect(results.filter { $0 }.count == 1, "exactly one trigger connects; the other is deduped") + let count = await pairedStore.count() + #expect(count == 1) + #expect(store.connectionState == .connected) + } + + @Test func sequentialRetryAfterFailureIsAllowed() async throws { + // After an attempt finishes without connecting, the in-flight flag is + // cleared, so a later trigger may retry (the flag is not a permanent + // one-shot latch). First call: empty registry → no candidate → false. + // Then the same store, given a candidate registry, connects on retry. + let clock = TestClock() + let route = try loopbackRoute() + let pairedStore = InMemoryPairedMacStore() + let registry = FakeRegistry(devices: [device(id: "mac-A", lastSeen: clock.now, route: route)]) + let store = makeStore( + devices: [], + pairedStore: pairedStore, + registry: registry, + clock: clock, + router: LivenessHostRouter(), + box: TransportBox() + ) + + let first = await store.attemptAutoAttachIfEligible(stackUserID: "user-1") + #expect(first) + // A second sequential call when already connected is a no-op via the top + // guard, proving the in-flight flag did not latch permanently. + let second = await store.attemptAutoAttachIfEligible(stackUserID: "user-1") + #expect(!second) + } + + @Test func reconnectChainsAutoAttachAndResolvesRestoringGateOnNoCandidate() async throws { + let clock = TestClock() + let pairedStore = InMemoryPairedMacStore() + // No stored Mac and no registry candidate: reconnect must chain into + // auto-attach, find nothing, and resolve the restoring gate (not leave + // RestoringSessionView stuck up) so the UI falls through to the pair sheet. + let registry = FakeRegistry(devices: []) + let store = makeStore( + devices: [], + pairedStore: pairedStore, + registry: registry, + clock: clock, + router: LivenessHostRouter(), + box: TransportBox() + ) + + let connected = await store.reconnectActiveMacIfAvailable(stackUserID: "user-1") + + #expect(!connected) + #expect(!store.isReconnectingStoredMac) + #expect(store.didFinishStoredMacReconnectAttempt) + #expect(!store.hasKnownPairedMac) + // Auto-attach was consulted (registry listed) before falling through. + let listCalls = await registry.listCallCount + #expect(listCalls >= 1) + } + + @Test func reconnectChainsAutoAttachAndConnectsSingleCandidate() async throws { + let clock = TestClock() + let route = try loopbackRoute() + let pairedStore = InMemoryPairedMacStore() + // No stored Mac but one reachable registry Mac: reconnect chains into + // auto-attach, connects, and persists the pairing. + let registry = FakeRegistry(devices: [device(id: "mac-A", lastSeen: clock.now, route: route)]) + let store = makeStore( + devices: [], + pairedStore: pairedStore, + registry: registry, + clock: clock, + router: LivenessHostRouter(), + box: TransportBox() + ) + + let connected = await store.reconnectActiveMacIfAvailable(stackUserID: "user-1") + + #expect(connected) + #expect(store.connectionState == .connected) + #expect(!store.isReconnectingStoredMac) + let active = try await pairedStore.activeMac(stackUserID: "user-1") + #expect(active?.macDeviceID == "mac-A") + } + + @Test func concurrentReconnectLeavesRestoringGateUpWhileFirstAttachRuns() async throws { + // Regression: a second reconnect trigger arriving while the first + // auto-attach is still connecting must NOT resolve the restoring gate + // (that would flash the pair/QR screen mid-connect). The first attempt + // owns the gate; the superseding trigger leaves it untouched. + let clock = TestClock() + let route = try loopbackRoute() + let pairedStore = InMemoryPairedMacStore() + let registry = FakeRegistry(devices: [device(id: "mac-A", lastSeen: clock.now, route: route)]) + await registry.armGate() + let store = makeStore( + devices: [], + pairedStore: pairedStore, + registry: registry, + clock: clock, + router: LivenessHostRouter(), + box: TransportBox() + ) + + // Start the first reconnect; it chains into auto-attach and parks inside + // the gated registry list, holding autoAttachInFlight == true. + async let first: Bool = store.reconnectActiveMacIfAvailable(stackUserID: "user-1") + // Let the first call reach the parked listDevices. + _ = try await pollUntil { await registry.listCallCount >= 1 } + #expect(store.isReconnectingStoredMac, "first attempt holds the restoring gate") + + // A second reconnect trigger while the first is still in flight must be a + // no-op for the gate: it sees autoAttachInFlight and returns without + // clearing isReconnectingStoredMac / didFinishStoredMacReconnectAttempt. + let second = await store.reconnectActiveMacIfAvailable(stackUserID: "user-1") + #expect(!second) + #expect(store.isReconnectingStoredMac, "gate must stay up; the second trigger must not drop it") + #expect(!store.didFinishStoredMacReconnectAttempt) + + // Release the first attempt; it connects and resolves the gate. + await registry.releaseGate() + let firstResult = await first + #expect(firstResult) + #expect(store.connectionState == .connected) + let count = await pairedStore.count() + #expect(count == 1) + } + + @Test func concurrentReconnectDoesNotStrandGateWhenFirstAttachMisses() async throws { + // Regression (P1): a second reconnect trigger bumps storedMacReconnectGeneration + // while the first auto-attach is in flight. The first attempt's gate + // cleanup must NOT depend on that generation, so when the first attempt + // finds NO candidate it still resolves the restoring gate instead of + // leaving RestoringSessionView stuck up forever. + let clock = TestClock() + let pairedStore = InMemoryPairedMacStore() + // Empty registry → the first attempt will find no candidate. + let registry = FakeRegistry(devices: []) + await registry.armGate() + let store = makeStore( + devices: [], + pairedStore: pairedStore, + registry: registry, + clock: clock, + router: LivenessHostRouter(), + box: TransportBox() + ) + + async let first: Bool = store.reconnectActiveMacIfAvailable(stackUserID: "user-1") + _ = try await pollUntil { await registry.listCallCount >= 1 } + #expect(store.isReconnectingStoredMac, "first attempt holds the gate while parked") + + // Second trigger bumps the generation; it must bail without touching the gate. + let second = await store.reconnectActiveMacIfAvailable(stackUserID: "user-1") + #expect(!second) + #expect(store.isReconnectingStoredMac, "second trigger must not drop the gate") + + // Release the first; with no candidate it must resolve the gate, not strand it. + await registry.releaseGate() + let firstResult = await first + #expect(!firstResult) + #expect(!store.isReconnectingStoredMac, "gate resolved after a missed first attempt") + #expect(store.didFinishStoredMacReconnectAttempt) + #expect(!store.hasKnownPairedMac) + let count = await pairedStore.count() + #expect(count == 0) + } + + @Test func signOutResetsInFlightSoNextSignInAutoAttachIsNotSuppressed() async throws { + // Regression (P1): a fresh-install attempt parked mid-flight when the user + // signs out must not leave autoAttachInFlight set and suppress the NEXT + // account's auto-attach. signOut() resets the flag, so a fresh attempt + // after sign-in runs normally. + let clock = TestClock() + let route = try loopbackRoute() + let pairedStore = InMemoryPairedMacStore() + let registry = FakeRegistry(devices: [device(id: "mac-A", lastSeen: clock.now, route: route)]) + await registry.armGate() + let store = makeStore( + devices: [], + pairedStore: pairedStore, + registry: registry, + clock: clock, + router: LivenessHostRouter(), + box: TransportBox() + ) + + // Park a first attempt inside the gated registry list (in flight). + async let first: Bool = store.attemptAutoAttachIfEligible(stackUserID: "user-1") + _ = try await pollUntil { await registry.listCallCount >= 1 } + + // Sign out while the first attempt is parked; this must clear the in-flight + // flag. Release the gate so the parked attempt unwinds (its post-await + // guards see signed-out and bail without connecting). + store.signOut() + await registry.releaseGate() + let firstResult = await first + #expect(!firstResult, "the parked attempt bails after sign-out") + + // Sign back in; a new auto-attach must NOT be suppressed by a stale flag. + store.signIn() + let second = await store.attemptAutoAttachIfEligible(stackUserID: "user-1") + #expect(second, "next sign-in's auto-attach runs because the flag was reset") + #expect(store.connectionState == .connected) + } + + @Test func staleAttemptDoesNotConnectAfterAccountSwitch() async throws { + // Regression (P1): a task parked mid-flight for user-1 must not resume and + // connect under user-2 after a sign-out + different-account sign-in. The + // per-await account guard discards it. + let clock = TestClock() + let route = try loopbackRoute() + let pairedStore = InMemoryPairedMacStore() + let registry = FakeRegistry(devices: [device(id: "mac-A", lastSeen: clock.now, route: route)]) + await registry.armGate() + let identity = await FakeIdentity(userID: "user-1") + let store = makeStore( + devices: [], + pairedStore: pairedStore, + registry: registry, + clock: clock, + router: LivenessHostRouter(), + box: TransportBox(), + identity: identity + ) + + // Park an attempt captured for user-1. + async let first: Bool = store.attemptAutoAttachIfEligible(stackUserID: "user-1") + _ = try await pollUntil { await registry.listCallCount >= 1 } + + // Simulate sign-out + sign-in as user-2 while the attempt is parked. + store.signOut() + identity.userID = "user-2" + store.signIn() + await registry.releaseGate() + let firstResult = await first + + // The stale user-1 attempt must NOT have connected under user-2. + #expect(!firstResult) + #expect(store.connectionState != .connected) + let count = await pairedStore.count() + #expect(count == 0, "no pairing persisted from the stale cross-account attempt") + } + + @Test func manualPairingSupersedesInFlightAutoAttach() async throws { + // Regression (P1): if the user starts manual pairing while auto-attach is + // parked awaiting the registry, the auto-attach attempt must be superseded + // and must NOT later drive a destructive connect that invalidates the + // user's manual pairing. + let clock = TestClock() + let route = try loopbackRoute() + let pairedStore = InMemoryPairedMacStore() + let registry = FakeRegistry(devices: [device(id: "mac-A", lastSeen: clock.now, route: route)]) + await registry.armGate() + let store = makeStore( + devices: [], + pairedStore: pairedStore, + registry: registry, + clock: clock, + router: LivenessHostRouter(), + box: TransportBox() + ) + + // Park an auto-attach attempt inside the gated registry list. + async let auto: Bool = store.attemptAutoAttachIfEligible(stackUserID: "user-1") + _ = try await pollUntil { await registry.listCallCount >= 1 } + + // User starts manual pairing (an invalid code is enough to exercise the + // supersede at the top of connectPairingURLResult). This bumps the + // auto-attach generation, so the parked attempt is superseded. + _ = await store.connectPairingURLResult("not-a-valid-attach-url") + + // Release the gate; the superseded auto-attach must bail without connecting. + await registry.releaseGate() + let autoResult = await auto + #expect(!autoResult, "superseded auto-attach must not connect after manual pairing began") + let count = await pairedStore.count() + #expect(count == 0, "no auto-attach pairing persisted over the manual attempt") + } + + @Test func manualPairingResolvesGateWhenSupersedingGateOwningAutoAttach() async throws { + // Regression (P1): when the user starts manual pairing while a GATE-OWNING + // auto-attach runner is in flight, superseding it must resolve the + // restoring gate (not leave RestoringSessionView stuck) even if the manual + // pairing then fails. + let clock = TestClock() + let route = try loopbackRoute() + let pairedStore = InMemoryPairedMacStore() + let registry = FakeRegistry(devices: [device(id: "mac-A", lastSeen: clock.now, route: route)]) + await registry.armGate() + let store = makeStore( + devices: [], + pairedStore: pairedStore, + registry: registry, + clock: clock, + router: LivenessHostRouter(), + box: TransportBox() + ) + + // Gate-owning auto-attach via the reconnect path; it parks in the registry. + async let recon: Bool = store.reconnectActiveMacIfAvailable(stackUserID: "user-1") + _ = try await pollUntil { await registry.listCallCount >= 1 } + #expect(store.isReconnectingStoredMac, "gate is up while the runner is parked") + + // User starts manual pairing with an invalid code; it fails but must have + // resolved the gate via the supersede path. + _ = await store.connectPairingURLResult("not-a-valid-attach-url") + #expect(!store.isReconnectingStoredMac, "gate resolved when manual pairing superseded the runner") + #expect(store.didFinishStoredMacReconnectAttempt) + + // Release the parked auto-attach; it must bail (superseded) and not connect. + await registry.releaseGate() + let reconResult = await recon + #expect(!reconResult) + let count = await pairedStore.count() + #expect(count == 0) + } + + @Test func userManualHostSupersedesInFlightAutoAttach() async throws { + // Regression (P1b): the user submitting the manual-host form + // (connectManualHost, not via auto-attach) must supersede a parked + // auto-attach so it can't resume and run a competing destructive connect. + let clock = TestClock() + let route = try loopbackRoute() + let pairedStore = InMemoryPairedMacStore() + let registry = FakeRegistry(devices: [device(id: "mac-A", lastSeen: clock.now, route: route)]) + await registry.armGate() + let router = LivenessHostRouter() + let box = TransportBox() + let store = makeStore( + devices: [], + pairedStore: pairedStore, + registry: registry, + clock: clock, + router: router, + box: box + ) + + async let auto: Bool = store.attemptAutoAttachIfEligible(stackUserID: "user-1") + _ = try await pollUntil { await registry.listCallCount >= 1 } + + // User manually connects to a host; this funnels through beginPairingAttempt + // and supersedes the parked auto-attach. + await store.connectManualHost(name: "Manual Mac", host: "127.0.0.1", port: 56_584) + + await registry.releaseGate() + let autoResult = await auto + #expect(!autoResult, "auto-attach must be superseded by the user's manual host connect") + // The manual host connect itself landed (scripted host answers), so there + // is exactly one pairing — from the manual connect, not a racing auto-attach. + #expect(store.connectionState == .connected) + } + + @Test func supersededAutoAttachDoesNotClobberPairedHintFromManualPairing() async throws { + // Regression (P2): a manual pairing that completes (setting connectionState + // == .connected) while a gate-owning auto-attach is parked must not have + // its paired-Mac hint clobbered to false when the stale auto-attach unwinds + // with no candidate. + let clock = TestClock() + let pairedStore = InMemoryPairedMacStore() + // Empty registry → the parked auto-attach will find no candidate on resume. + let registry = FakeRegistry(devices: []) + await registry.armGate() + let router = LivenessHostRouter() + let box = TransportBox() + let store = makeStore( + devices: [], + pairedStore: pairedStore, + registry: registry, + clock: clock, + router: router, + box: box + ) + + // Gate-owning auto-attach parks in the registry list. + async let recon: Bool = store.reconnectActiveMacIfAvailable(stackUserID: "user-1") + _ = try await pollUntil { await registry.listCallCount >= 1 } + + // User completes a manual pairing (scripted host answers) → connected. + await store.connectManualHost(name: "Manual Mac", host: "127.0.0.1", port: 56_584) + #expect(store.connectionState == .connected) + let hintAfterManual = store.hasKnownPairedMac + + // Release the parked auto-attach; with no candidate it must NOT write the + // negative hint, because the guard skips the stale write while connected. + // The hint is therefore unchanged from after the manual connect. + await registry.releaseGate() + _ = await recon + #expect(store.hasKnownPairedMac == hintAfterManual, "stale auto-attach must not clobber the hint after a successful connect") + #expect(store.connectionState == .connected, "the live manual connection survives the stale auto-attach unwinding") + } + + @Test func transientRegistryFailureFallsThroughEvenWithStaleCache() async throws { + // Regression (P2): auto-attach must decide from a freshly-confirmed `.ok` + // registry list, not a stale cache. A transient registry failure must make + // it fall through to manual, even if a previous load populated the cache. + let clock = TestClock() + let route = try loopbackRoute() + let pairedStore = InMemoryPairedMacStore() + let registry = FakeRegistry(devices: [device(id: "mac-A", lastSeen: clock.now, route: route)]) + let store = makeStore( + devices: [], + pairedStore: pairedStore, + registry: registry, + clock: clock, + router: LivenessHostRouter(), + box: TransportBox() + ) + + // Warm the cache with a successful load (registryDevices now has mac-A). + await store.loadRegistryDevices() + #expect(!store.registryDevices.isEmpty) + + // Now the registry goes down (transient). Auto-attach must NOT connect off + // the warm cache; it falls through to manual. + await registry.forceOutcome(.transientFailure) + let attached = await store.attemptAutoAttachIfEligible(stackUserID: "user-1") + #expect(!attached, "transient registry failure must degrade to manual, not connect off a stale cache") + #expect(store.connectionState != .connected) + let count = await pairedStore.count() + #expect(count == 0) + } + + @Test func invalidManualHostStillSupersedesInFlightAutoAttach() async throws { + // Regression (P2): a user manual-host submission that FAILS validation + // (invalid host) returns before beginPairingAttempt, but must still + // supersede a parked auto-attach so it can't resume and connect over the + // user's explicit (failed) attempt. + let clock = TestClock() + let route = try loopbackRoute() + let pairedStore = InMemoryPairedMacStore() + let registry = FakeRegistry(devices: [device(id: "mac-A", lastSeen: clock.now, route: route)]) + await registry.armGate() + let store = makeStore( + devices: [], + pairedStore: pairedStore, + registry: registry, + clock: clock, + router: LivenessHostRouter(), + box: TransportBox() + ) + + async let auto: Bool = store.attemptAutoAttachIfEligible(stackUserID: "user-1") + _ = try await pollUntil { await registry.listCallCount >= 1 } + + // Invalid host: connectManualHost returns at the validation guard, but the + // supersede runs first. + await store.connectManualHost(name: "Bad", host: "has spaces/and/path", port: 56_584) + + await registry.releaseGate() + let autoResult = await auto + #expect(!autoResult, "auto-attach must be superseded even by a failed manual-host submission") + #expect(store.connectionState != .connected) + let count = await pairedStore.count() + #expect(count == 0) + } + + @Test func rejectLoopbackConnectDoesNotPersistLoopbackRoutes() async throws { + // Regression (P1): when auto-attach connects with rejectLoopback (physical + // phone), the persisted paired-Mac route set must EXCLUDE loopback, so the + // next stored-Mac reconnect (no rejectLoopback flag) can never pick the + // phone's own 127.0.0.1. + let clock = TestClock() + let pairedStore = InMemoryPairedMacStore() + let registry = FakeRegistry(devices: []) + let router = LivenessHostRouter() + let box = TransportBox() + let store = makeStore( + devices: [], + pairedStore: pairedStore, + registry: registry, + clock: clock, + router: router, + box: box, + supportedRouteKinds: [.debugLoopback, .tailscale] + ) + + let loopback = try CmxAttachRoute( + id: "loop", + kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 56_584), + priority: 0 + ) + let tailscale = try CmxAttachRoute( + id: "ts", + kind: .tailscale, + endpoint: .hostPort(host: "100.71.0.5", port: 56_584), + priority: 1 + ) + let dev = RegistryDevice( + deviceId: "mac-A", + platform: "mac", + displayName: "Mac A", + lastSeenAt: clock.now, + instances: [RegistryAppInstance(tag: "stable", routes: [loopback, tailscale], lastSeenAt: clock.now)] + ) + + // Connect with rejectLoopback (as auto-attach does on a physical phone). + await store.connectToRegistryInstance(device: dev, instance: dev.instances[0], rejectLoopback: true) + #expect(store.connectionState == .connected) + + let active = try await pairedStore.activeMac(stackUserID: "user-1") + let persistedKinds = active?.routes.map(\.kind) ?? [] + #expect(!persistedKinds.contains(.debugLoopback), "loopback route must not be persisted on a rejectLoopback connect") + #expect(persistedKinds.contains(.tailscale), "the safe tailscale route is persisted") + } + + @Test func invalidManualHostSupersedesAutoAttachAlreadyInsideItsConnect() async throws { + // Regression (P1): when auto-attach is already inside its own connect() + // (parked on the workspace.list handshake) and the user submits an INVALID + // manual host, the supersede must bump the connection generation so the + // in-flight connect discards its result instead of landing over the user's + // explicit (failed) action. + let clock = TestClock() + let route = try loopbackRoute() + let pairedStore = InMemoryPairedMacStore() + let registry = FakeRegistry(devices: [device(id: "mac-A", lastSeen: clock.now, route: route)]) + let router = LivenessHostRouter() + let box = TransportBox() + let store = makeStore( + devices: [], + pairedStore: pairedStore, + registry: registry, + clock: clock, + router: router, + box: box + ) + + // Hold the connect handshake so auto-attach parks INSIDE connect(). + await router.setHoldWorkspaceList(true) + async let auto: Bool = store.attemptAutoAttachIfEligible(stackUserID: "user-1") + _ = try await pollUntil { await router.workspaceListRequestSeen() } + + // User submits an invalid manual host while auto-attach is mid-connect. + await store.connectManualHost(name: "Bad", host: "has spaces/and/path", port: 56_584) + + // Release the handshake; auto-attach's parked connect must discard its + // result (superseded connection generation) and NOT land a connection. + await router.setHoldWorkspaceList(false) + let autoResult = await auto + #expect(!autoResult, "auto-attach inside connect must be superseded by the user's manual submission") + #expect(store.connectionState != .connected) + let count = await pairedStore.count() + #expect(count == 0) + } + + @Test func signOutDuringGateOwningAutoAttachLeavesGateCleanForNextSignIn() async throws { + // Regression (P2): signing out while a GATE-OWNING auto-attach is in flight + // must leave the restoring-gate flags reset (not "already finished"), so + // the next account's sign-in starts with a clean gate rather than flashing + // onboarding/add-device. + let clock = TestClock() + let route = try loopbackRoute() + let pairedStore = InMemoryPairedMacStore() + let registry = FakeRegistry(devices: [device(id: "mac-A", lastSeen: clock.now, route: route)]) + await registry.armGate() + let store = makeStore( + devices: [], + pairedStore: pairedStore, + registry: registry, + clock: clock, + router: LivenessHostRouter(), + box: TransportBox() + ) + + // Gate-owning auto-attach parks in the registry list. + async let recon: Bool = store.reconnectActiveMacIfAvailable(stackUserID: "user-1") + _ = try await pollUntil { await registry.listCallCount >= 1 } + #expect(store.isReconnectingStoredMac) + + // Sign out while the gate-owning attempt is in flight. + store.signOut() + // Both restoring flags must be reset by sign-out (not left "finished"). + #expect(!store.isReconnectingStoredMac) + #expect(!store.didFinishStoredMacReconnectAttempt, "next sign-in must not see the reconnect as already finished") + + // Release the parked attempt so it unwinds (it bails: signed out). + await registry.releaseGate() + let reconResult = await recon + #expect(!reconResult) + // Still clean for the next sign-in. + #expect(!store.isReconnectingStoredMac) + #expect(!store.didFinishStoredMacReconnectAttempt) + } + + @Test func userTapOnNoRouteInstanceStillSupersedesAutoAttach() async throws { + // Regression (P2): a user device-tree tap on an instance with no reachable + // route returns early (before connectManualHost), but must still supersede + // an in-flight auto-attach so the background attempt can't later connect + // over the user's explicit choice. + let clock = TestClock() + let route = try loopbackRoute() + let pairedStore = InMemoryPairedMacStore() + let registry = FakeRegistry(devices: [device(id: "mac-A", lastSeen: clock.now, route: route)]) + await registry.armGate() + let store = makeStore( + devices: [], + pairedStore: pairedStore, + registry: registry, + clock: clock, + router: LivenessHostRouter(), + box: TransportBox() + ) + + async let auto: Bool = store.attemptAutoAttachIfEligible(stackUserID: "user-1") + _ = try await pollUntil { await registry.listCallCount >= 1 } + + // User taps a registry instance that advertises NO route (early return), + // but supersedeAutoAttach defaults to true and runs first. + let noRouteDevice = RegistryDevice( + deviceId: "mac-B", + platform: "mac", + displayName: "Mac B", + lastSeenAt: clock.now, + instances: [RegistryAppInstance(tag: "stable", routes: [], lastSeenAt: clock.now)] + ) + await store.connectToRegistryInstance(device: noRouteDevice, instance: noRouteDevice.instances[0]) + + await registry.releaseGate() + let autoResult = await auto + #expect(!autoResult, "no-route user tap must still supersede in-flight auto-attach") + let count = await pairedStore.count() + #expect(count == 0) + } + + @Test func ambiguousMultipleEquallyRecentMacsFallsThrough() async throws { + let clock = TestClock() + let route = try loopbackRoute() + let pairedStore = InMemoryPairedMacStore() + // Two devices, equally recent, no presence signal → ambiguous → no target. + let registry = FakeRegistry(devices: [ + device(id: "mac-A", lastSeen: clock.now, route: route), + device(id: "mac-B", lastSeen: clock.now, route: route), + ]) + let store = makeStore( + devices: [], + pairedStore: pairedStore, + registry: registry, + clock: clock, + router: LivenessHostRouter(), + box: TransportBox() + ) + + let attached = await store.attemptAutoAttachIfEligible(stackUserID: "user-1") + + #expect(!attached) + let count = await pairedStore.count() + #expect(count == 0) + } +} diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift index 2c6c4a958cec..33577b7ea6b4 100644 --- a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift @@ -116,9 +116,29 @@ actor LivenessHostRouter { } } + private var holdWorkspaceList = false + + /// Hold every `workspace.list` response until released, so a test can park a + /// connect inside its handshake (the connect awaits `workspace.list`). + func setHoldWorkspaceList(_ hold: Bool) { + holdWorkspaceList = hold + if !hold { + let continuations = heldContinuations + heldContinuations = [] + for continuation in continuations { continuation.resume() } + } + } + + func workspaceListRequestSeen() -> Bool { + count(of: "workspace.list") + count(of: "mobile.workspace.list") > 0 + } + func response(method: String?, id: String?) async -> Data? { switch method { case "workspace.list", "mobile.workspace.list": + if holdWorkspaceList { + await park() + } return try? Self.resultFrame(id: id, result: [ "workspaces": [ [ @@ -163,6 +183,15 @@ actor LivenessHostRouter { ]) case "mobile.events.unsubscribe", "mobile.terminal.replay", "mobile.terminal.viewport": return try? Self.resultFrame(id: id, result: [:]) + case "mobile.attach_ticket.create": + // Model a host that does not advertise the mint RPC: the client must + // fall back to a synthetic manual ticket and still connect. Uses the + // recognized `method_not_found` code so the fallback triggers. + return try? Self.errorFrame( + id: id, + code: "method_not_found", + message: "Unknown method mobile.attach_ticket.create" + ) default: return try? Self.errorFrame(id: id, message: "Unexpected method \(method ?? "nil")") } @@ -183,11 +212,13 @@ actor LivenessHostRouter { return try MobileSyncFrameCodec.encodeFrame(JSONSerialization.data(withJSONObject: envelope)) } - private static func errorFrame(id: String?, message: String) throws -> Data { + private static func errorFrame(id: String?, code: String? = nil, message: String) throws -> Data { + var error: [String: Any] = ["message": message] + if let code { error["code"] = code } let envelope: [String: Any] = [ "id": id ?? UUID().uuidString, "ok": false, - "error": ["message": message], + "error": error, ] return try MobileSyncFrameCodec.encodeFrame(JSONSerialization.data(withJSONObject: envelope)) } diff --git a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAttachRoutePriority.swift b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAttachRoutePriority.swift new file mode 100644 index 000000000000..b2c6441eff5e --- /dev/null +++ b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAttachRoutePriority.swift @@ -0,0 +1,53 @@ +public import CMUXMobileCore + +/// Shared attach-route priority and best-route selection. +/// +/// The phone reaches a Mac over the first usable route in priority order. This +/// ordering is the single source of truth used by reconnect, the multi-Mac +/// switcher, the device-tree tap-to-open, and registry-driven auto-attach, so +/// every surface agrees on which route a device will be reached at. It lives in +/// the model package (not privately on the shell) so the pure auto-attach target +/// selector can rank devices by their best reachable route without importing the +/// shell. +public enum MobileAttachRoutePriority { + /// Whether `left` should be tried before `right`: lower `priority` wins, with + /// the route `id` as a stable lexicographic tiebreak so the ordering is + /// deterministic across runs. + public static func sortsBefore(_ left: CmxAttachRoute, _ right: CmxAttachRoute) -> Bool { + if left.priority == right.priority { + return left.id < right.id + } + return left.priority < right.priority + } + + /// The first `hostPort` route, in priority order, whose transport kind is + /// supported (an empty `supportedKinds` means "accept any kind"). Returns + /// `nil` when no priority-ordered route is both a `hostPort` endpoint and a + /// supported kind, i.e. the device is not reachable on this client. + /// + /// - Parameter rejectLoopback: When `true`, loopback routes (`127.0.0.1` / + /// `::1`) are skipped. On a physical phone a loopback route names the phone + /// itself, never the Mac, and loopback is in the Stack-auth-trusted set, so + /// auto-selecting it would dial localhost and could hand the Stack bearer to + /// whatever local process answers. The simulator (where `127.0.0.1` IS the + /// host Mac) passes `false`. + public static func firstReachableHostPort( + _ routes: [CmxAttachRoute], + supportedKinds: [CmxAttachTransportKind], + rejectLoopback: Bool = false + ) -> (host: String, port: Int)? { + let supported = Set(supportedKinds) + for route in routes.sorted(by: sortsBefore) { + if !supported.isEmpty, !supported.contains(route.kind) { + continue + } + if rejectLoopback, MobileShellRouteAuthPolicy.routeIsLoopback(route) { + continue + } + if case let .hostPort(host, port) = route.endpoint { + return (host, port) + } + } + return nil + } +} diff --git a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachFlag.swift b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachFlag.swift new file mode 100644 index 000000000000..c8416edbd390 --- /dev/null +++ b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachFlag.swift @@ -0,0 +1,33 @@ +public import Foundation + +/// Resolves the `mobileAutoAttach` feature flag. +/// +/// Auto-attach (registry-driven "sign in → connected") is on by default in DEBUG +/// builds for dogfood and off in Release until it has been dogfooded, matching +/// the macOS beta-feature convention of a `UserDefaults`-backed boolean. The flag +/// is read once at the composition root and injected into the shell as a plain +/// `Bool`, so the shell stays testable without touching `UserDefaults`. +public enum MobileAutoAttachFlag { + /// The `UserDefaults` key. Present-and-set overrides the build default, so a + /// dogfood Release build can opt in (or a DEBUG build can opt out) without a + /// rebuild. + public static let defaultsKey = "cmux.mobile.autoAttach.enabled" + + /// The build default when the key is absent: on in DEBUG, off in Release. + public static var buildDefault: Bool { + #if DEBUG + return true + #else + return false + #endif + } + + /// Whether auto-attach is enabled, honoring an explicit `UserDefaults` + /// override and otherwise the build default. + public static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: defaultsKey) != nil { + return defaults.bool(forKey: defaultsKey) + } + return buildDefault + } +} diff --git a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachTargetSelector.swift b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachTargetSelector.swift new file mode 100644 index 000000000000..38526a2fae9f --- /dev/null +++ b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachTargetSelector.swift @@ -0,0 +1,134 @@ +public import CMUXMobileCore +public import Foundation + +/// Picks the single Mac a signed-in phone should auto-attach to on the cold-start +/// "sign in → connected" path, from the team-scoped device registry. +/// +/// Pure and deterministic: no I/O, no clock reads beyond the injected `now`, so +/// the whole "which Mac, or none" policy is unit-testable without a live +/// connection. The shell turns a returned target into a connection by reusing the +/// proven registry connect path (`connectToRegistryInstance`), which mints the +/// attach ticket Stack-authenticated on the Mac and persists the pairing. +/// +/// The contract is conservative on purpose: auto-attach only fires when there is +/// one obvious Mac. Any ambiguity (two online Macs, or two equally-recent Macs +/// with no presence signal) returns `nil` so the caller falls through to the +/// manual pair screen rather than guessing. Same-account safety is structural: +/// the registry is scoped to the signed-in user's team, so a different-account +/// Mac never appears here, and even a stray route would be rejected at mint by +/// the Mac's same-Stack-account authorization check. +public enum MobileAutoAttachTargetSelector { + /// A device that is a viable auto-attach target: a controllable host with at + /// least one instance reachable on a supported route. + public struct Candidate: Equatable, Sendable { + /// The registry device to auto-attach to. + public let device: RegistryDevice + /// The chosen app instance (tag) on `device` whose route the connect uses. + public let instance: RegistryAppInstance + /// The more recent of the device's and chosen instance's `lastSeenAt`, + /// used to rank candidates by recency when there is no presence signal. + public let lastSeenAt: Date + + /// Creates a candidate from a device, its chosen instance, and the + /// recency timestamp used to break ties between candidates. + public init(device: RegistryDevice, instance: RegistryAppInstance, lastSeenAt: Date) { + self.device = device + self.instance = instance + self.lastSeenAt = lastSeenAt + } + } + + /// The auto-attach target, or `nil` when there is no single obvious Mac. + /// + /// - Parameters: + /// - devices: The team's registry devices (the shell's `registryDevices`). + /// - supportedRouteKinds: The transports this client can reach (empty = + /// accept any kind). A device with no reachable route on these kinds is + /// not a candidate. + /// - presenceOnlineDeviceIDs: Device ids currently reported online by the + /// presence service. Meaningful only when `presenceAvailable` is true. + /// - presenceAvailable: Whether a presence signal exists. When false + /// (e.g. the presence service is not wired yet), selection falls back to + /// recency over all candidates. + /// - rejectLoopback: When `true` (a physical phone), loopback routes are + /// never considered reachable — a `127.0.0.1` route names the phone + /// itself, not the Mac, and loopback is Stack-auth-trusted, so + /// auto-dialing it would hand the bearer to a phone-local listener. The + /// simulator passes `false` (there `127.0.0.1` IS the host Mac). + public static func selectTarget( + devices: [RegistryDevice], + supportedRouteKinds: [CmxAttachTransportKind], + presenceOnlineDeviceIDs: Set = [], + presenceAvailable: Bool = false, + rejectLoopback: Bool = false, + now: Date = Date() + ) -> Candidate? { + let candidates = devices.compactMap { + candidate(for: $0, supportedRouteKinds: supportedRouteKinds, rejectLoopback: rejectLoopback) + } + guard !candidates.isEmpty else { return nil } + + if presenceAvailable { + let online = candidates.filter { presenceOnlineDeviceIDs.contains($0.device.deviceId) } + if online.count == 1 { return online[0] } + // Two or more live Macs: ambiguous, never guess between them. + if online.count > 1 { return nil } + // Zero online: a recently-active Mac may still be reachable; fall + // through to recency. The connect attempt is bounded and rolls back + // on failure, so trying a stale-but-reachable Mac is safe. + } + + return mostRecentUnambiguous(candidates) + } + + /// The single most-recently-seen candidate, but only when it is strictly more + /// recent than the next one. A tie returns `nil` so auto-attach never silently + /// picks between equally-stale Macs. + private static func mostRecentUnambiguous(_ candidates: [Candidate]) -> Candidate? { + if candidates.count == 1 { return candidates[0] } + let sorted = candidates.sorted { $0.lastSeenAt > $1.lastSeenAt } + guard let first = sorted.first, sorted.count > 1 else { return sorted.first } + if first.lastSeenAt > sorted[1].lastSeenAt { return first } + return nil + } + + /// The best auto-attach candidate for one device, or `nil` if it is not a + /// controllable host, has no instance reachable on a supported route, or the + /// two freshest reachable instances are tied on recency (ambiguous tag). + /// + /// Among a device's instances, prefer the most-recently-seen instance that has + /// a usable route (a Mac can run several tagged builds; the freshest reachable + /// one is the right default). When the freshest two reachable instances share + /// the same `lastSeenAt`, there is no obvious build to auto-attach to, so this + /// returns `nil` and the caller falls through to manual pairing rather than + /// silently connecting to whichever tagged build happened to sort first. This + /// mirrors the device-level recency-tie rule and the existing reconnect + /// registry policy, which refuses to auto-pick among multiple instances. + private static func candidate( + for device: RegistryDevice, + supportedRouteKinds: [CmxAttachTransportKind], + rejectLoopback: Bool + ) -> Candidate? { + guard device.isControllableHost else { return nil } + let reachable = device.instances + .filter { instance in + MobileAttachRoutePriority.firstReachableHostPort( + instance.routes, + supportedKinds: supportedRouteKinds, + rejectLoopback: rejectLoopback + ) != nil + } + .sorted { $0.lastSeenAt > $1.lastSeenAt } + guard let instance = reachable.first else { return nil } + // Ambiguous tag: the freshest two reachable instances are equally recent, + // so there is no single obvious build to connect to. + if reachable.count > 1, reachable[1].lastSeenAt == instance.lastSeenAt { + return nil + } + return Candidate( + device: device, + instance: instance, + lastSeenAt: max(device.lastSeenAt, instance.lastSeenAt) + ) + } +} diff --git a/Packages/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileAutoAttachTargetSelectorTests.swift b/Packages/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileAutoAttachTargetSelectorTests.swift new file mode 100644 index 000000000000..dee6b19542e8 --- /dev/null +++ b/Packages/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileAutoAttachTargetSelectorTests.swift @@ -0,0 +1,247 @@ +import CMUXMobileCore +import Foundation +import Testing +@testable import CmuxMobileShellModel + +/// Behavior of the pure auto-attach target selector: which Mac (or none) a +/// signed-in phone connects to on the "sign in → connected" path. The whole +/// "one obvious Mac, else fall through to manual pair" policy is verified here +/// without any live connection, since the selector is pure. +@Suite struct MobileAutoAttachTargetSelectorTests { + private static let supported: [CmxAttachTransportKind] = [.tailscale] + + private func route( + id: String = "tailscale", + kind: CmxAttachTransportKind = .tailscale, + host: String = "100.0.0.1", + port: Int = 58_465, + priority: Int = 0 + ) -> CmxAttachRoute { + try! CmxAttachRoute( + id: id, + kind: kind, + endpoint: .hostPort(host: host, port: port), + priority: priority + ) + } + + private func device( + id: String, + platform: String = "mac", + lastSeen: Date, + routes: [CmxAttachRoute]? = nil + ) -> RegistryDevice { + let r = routes ?? [route(host: "100.0.0.\(abs(id.hashValue) % 200 + 1)")] + return RegistryDevice( + deviceId: id, + platform: platform, + displayName: "Mac \(id)", + lastSeenAt: lastSeen, + instances: [RegistryAppInstance(tag: "stable", routes: r, lastSeenAt: lastSeen)] + ) + } + + @Test func singleReachableCandidateIsPickedOnRecency() { + let now = Date() + let target = MobileAutoAttachTargetSelector.selectTarget( + devices: [device(id: "A", lastSeen: now)], + supportedRouteKinds: Self.supported + ) + #expect(target?.device.deviceId == "A") + #expect(target?.instance.tag == "stable") + } + + @Test func noCandidateWhenNoDevices() { + #expect(MobileAutoAttachTargetSelector.selectTarget( + devices: [], + supportedRouteKinds: Self.supported + ) == nil) + } + + @Test func noCandidateWhenNoReachableRoute() { + let now = Date() + // Only a websocket route, but client supports only tailscale. + let wsRoute = try! CmxAttachRoute(id: "ws", kind: .websocket, endpoint: .url("wss://x")) + let dev = device(id: "A", lastSeen: now, routes: [wsRoute]) + #expect(MobileAutoAttachTargetSelector.selectTarget( + devices: [dev], + supportedRouteKinds: Self.supported + ) == nil) + } + + @Test func nonControllableHostIsNotACandidate() { + let now = Date() + let phone = device(id: "phone", platform: "ios", lastSeen: now) + #expect(MobileAutoAttachTargetSelector.selectTarget( + devices: [phone], + supportedRouteKinds: Self.supported + ) == nil) + } + + @Test func onlineDevicePreferredOverMoreRecentOfflineDevice() { + let now = Date() + let online = device(id: "online", lastSeen: now.addingTimeInterval(-3600)) + let recentOffline = device(id: "offline", lastSeen: now) + let target = MobileAutoAttachTargetSelector.selectTarget( + devices: [recentOffline, online], + supportedRouteKinds: Self.supported, + presenceOnlineDeviceIDs: ["online"], + presenceAvailable: true, + now: now + ) + #expect(target?.device.deviceId == "online") + } + + @Test func multipleOnlineDevicesAreAmbiguousAndYieldNoTarget() { + let now = Date() + let a = device(id: "A", lastSeen: now) + let b = device(id: "B", lastSeen: now.addingTimeInterval(-10)) + #expect(MobileAutoAttachTargetSelector.selectTarget( + devices: [a, b], + supportedRouteKinds: Self.supported, + presenceOnlineDeviceIDs: ["A", "B"], + presenceAvailable: true, + now: now + ) == nil) + } + + @Test func zeroOnlineFallsBackToRecency() { + let now = Date() + let a = device(id: "A", lastSeen: now) + let b = device(id: "B", lastSeen: now.addingTimeInterval(-3600)) + // Presence available but nobody online: pick the strictly-more-recent one. + let target = MobileAutoAttachTargetSelector.selectTarget( + devices: [a, b], + supportedRouteKinds: Self.supported, + presenceOnlineDeviceIDs: [], + presenceAvailable: true, + now: now + ) + #expect(target?.device.deviceId == "A") + } + + @Test func mostRecentWinsWhenNoPresenceSignal() { + let now = Date() + let a = device(id: "A", lastSeen: now) + let b = device(id: "B", lastSeen: now.addingTimeInterval(-60)) + let target = MobileAutoAttachTargetSelector.selectTarget( + devices: [b, a], + supportedRouteKinds: Self.supported, + presenceAvailable: false, + now: now + ) + #expect(target?.device.deviceId == "A") + } + + @Test func equallyRecentNoPresenceIsAmbiguous() { + let now = Date() + let a = device(id: "A", lastSeen: now) + let b = device(id: "B", lastSeen: now) + #expect(MobileAutoAttachTargetSelector.selectTarget( + devices: [a, b], + supportedRouteKinds: Self.supported, + presenceAvailable: false, + now: now + ) == nil) + } + + @Test func picksFreshestReachableInstanceWithinADevice() { + let now = Date() + let stale = RegistryAppInstance( + tag: "old", + routes: [route(id: "r-old")], + lastSeenAt: now.addingTimeInterval(-3600) + ) + let fresh = RegistryAppInstance( + tag: "new", + routes: [route(id: "r-new")], + lastSeenAt: now + ) + let dev = RegistryDevice( + deviceId: "A", + platform: "mac", + displayName: "Mac", + lastSeenAt: now, + instances: [stale, fresh] + ) + let target = MobileAutoAttachTargetSelector.selectTarget( + devices: [dev], + supportedRouteKinds: Self.supported + ) + #expect(target?.instance.tag == "new") + } + + @Test func rejectLoopbackSkipsLoopbackOnlyDevicesForPhysicalPhone() { + let now = Date() + // A device that only advertises a loopback debugLoopback route. + let loopback = try! CmxAttachRoute( + id: "loop", + kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 56_584) + ) + let dev = device(id: "A", lastSeen: now, routes: [loopback]) + let kinds: [CmxAttachTransportKind] = [.debugLoopback, .tailscale] + + // Simulator (rejectLoopback false): loopback IS the host Mac → candidate. + #expect(MobileAutoAttachTargetSelector.selectTarget( + devices: [dev], + supportedRouteKinds: kinds, + rejectLoopback: false, + now: now + )?.device.deviceId == "A") + + // Physical phone (rejectLoopback true): loopback names the phone itself → + // not a candidate → fall through to manual. + #expect(MobileAutoAttachTargetSelector.selectTarget( + devices: [dev], + supportedRouteKinds: kinds, + rejectLoopback: true, + now: now + ) == nil) + } + + @Test func rejectLoopbackStillPicksTailscaleRouteForPhysicalPhone() { + let now = Date() + let loopback = try! CmxAttachRoute( + id: "loop", + kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 56_584), + priority: 0 + ) + let tailscale = try! CmxAttachRoute( + id: "ts", + kind: .tailscale, + endpoint: .hostPort(host: "100.71.0.5", port: 56_584), + priority: 1 + ) + // Loopback sorts first by priority, but a physical phone must skip it and + // still reach the Mac on its Tailscale route. + let dev = device(id: "A", lastSeen: now, routes: [loopback, tailscale]) + let target = MobileAutoAttachTargetSelector.selectTarget( + devices: [dev], + supportedRouteKinds: [.debugLoopback, .tailscale], + rejectLoopback: true, + now: now + ) + #expect(target?.device.deviceId == "A") + } + + @Test func equallyRecentReachableInstancesOnOneDeviceAreAmbiguous() { + let now = Date() + // Two reachable tagged builds on one device, tied on lastSeenAt: no + // obvious build to auto-attach to → no candidate → fall through. + let a = RegistryAppInstance(tag: "stable", routes: [route(id: "r-a")], lastSeenAt: now) + let b = RegistryAppInstance(tag: "dev", routes: [route(id: "r-b")], lastSeenAt: now) + let dev = RegistryDevice( + deviceId: "A", + platform: "mac", + displayName: "Mac", + lastSeenAt: now, + instances: [a, b] + ) + #expect(MobileAutoAttachTargetSelector.selectTarget( + devices: [dev], + supportedRouteKinds: Self.supported + ) == nil) + } +} diff --git a/plans/feat-ios-auto-attach/DESIGN.md b/plans/feat-ios-auto-attach/DESIGN.md new file mode 100644 index 000000000000..d2cea663c183 --- /dev/null +++ b/plans/feat-ios-auto-attach/DESIGN.md @@ -0,0 +1,103 @@ +# iOS registry-driven auto-attach + +## Goal + +A signed-in phone auto-connects to its team's online Mac with no QR scan or manual host entry. The onboarding becomes "sign in → connected" whenever the team has exactly one reachable Mac, with the existing pair screen as the fallback. + +## The gap + +Today, a fresh install (or a re-install with no SQLite paired-Mac row) lands on the pair/QR screen after sign-in. `reconnectStoredMacIfNeeded()` only reconnects when `MobilePairedMacStore` already has an active row, which only exists after a prior QR/manual pairing. The device registry (`GET /api/devices`, team-scoped) already knows every Mac the signed-in user's team owns and their current attach routes, but nothing consults it on the cold-start path. So a brand-new phone with a perfectly reachable team Mac still dead-ends on "scan a QR". + +## KEY FINDING: can the phone mint from a registry route without prior QR pairing? + +**YES.** Confirmed in code. The attach ticket is route-discovery + workspace-selection only; it carries no authorization secret. The Mac authorizes the mobile data plane *solely* on same-Stack-account matching, not on any pairing record. + +- `Sources/Mobile/MobileHostService.swift` `MobileHostAuthorizationPolicy.authorizeStackUser(localUserID:remoteUserID:)`: + ```swift + guard localUserID == remoteUserID else { + throw MobileHostAuthorizationError.accountMismatch + } + ``` +- `mobile.attach_ticket.create` requires authorization (`requiresAuthorization` returns true for everything except the unauthenticated `mobile.host.status` probe), and authorization is the same-account Stack check above. No pairing secret is consulted. +- `Sources/Mobile/MobileAttachTicketStore.swift` `createTicket(workspaceID:terminalID:routes:ttl:)` mints from routes + a freshly generated bearer token; it needs no prior pairing. +- `CmxAttachTicketInput.decode(_:)` only parses a *scanned/pasted* QR/URL. The `mobile.attach_ticket.create` RPC path bypasses it entirely. +- Tests prove it: `cmuxTests/MobileHostAuthorizationTests.swift` `testStackUserAuthorizationRequiresMatchingUser` (matching user passes, mismatch throws) and `testMobileAttachTicketCreateRequiresAuthorization`. + +Consequence for this feature: the existing `connectToRegistryInstance(device:instance:)` already does the full thing — pick the best route, `connectManualHost` (which mints the ticket Stack-authenticated on the Mac), then persist into `MobilePairedMacStore`. Auto-attach is therefore *target selection + reuse of that connect path*, not a new connect path. The same-account guarantee means we can never connect to a different-account Mac: the registry is team-scoped by the Stack token, and even a stray cross-account route would be rejected at mint by `account_mismatch`. + +## Design + +### Feature flag + +`MobileAutoAttachFlag` — a tiny value type read from `UserDefaults` with a `#if DEBUG` default of `true`, Release default `false`. Key `cmux.mobile.autoAttach.enabled`. There is no existing iOS settings/flag package, and a full `CmuxSettings`/`DefaultsKey` import into the mobile shell would pull macOS-only catalog machinery, so this is a minimal injected struct on `MobileShellComposite` (`autoAttachEnabled: Bool`), defaulted from the flag at the composition root. Injecting the bool keeps the composite testable without UserDefaults. + +### Target selection (pure function) + +`MobileAutoAttachTargetSelector.selectTarget(devices:presenceOnline:now:)` → `(RegistryDevice, RegistryAppInstance)?`. Pure, no I/O, fully unit-testable. + +Rules: +1. Consider only `device.isControllableHost` devices that have at least one instance with `hasRoutes` and at least one route whose kind is supported (route-priority via the existing `firstReconnectHostPortRoute`). For each candidate device, pick its best instance = the one whose best route sorts first (lowest `route.priority`, id tiebreak); among a device's instances prefer the most-recently-seen instance that has a usable route. +2. Partition candidates into **online** (deviceId ∈ `presenceOnline`) and **the rest**. + - If presence info is available and exactly one device is online → pick it. + - If presence info is available and multiple devices are online → **ambiguous**, return `nil` (fall through to pair screen; never guess between two live Macs). + - If presence info is available and zero are online → fall to recency rule below (a recently-active Mac may still be reachable; the connect attempt is bounded and rolls back on failure). + - If presence info is unavailable (presence service not present — it is NOT merged yet, #5792) → recency rule over all candidates. +3. Recency rule: if exactly one candidate exists → pick it. If multiple → pick the single most-recently-seen **only if** it is strictly more recent than the next (no tie); on a tie, return `nil` (ambiguous). This keeps "one obvious Mac" auto-attaching while never silently choosing between equally-stale Macs. + +Presence is passed as a `Set` of online deviceIds plus a `Bool presenceAvailable`. Because PresenceClient (#5792) is not in this worktree, the composite passes `presenceAvailable: false` for now; the selector already handles the recency-only path, and wiring presence later is a one-line change at the call site. This is called out as residual work, not a blocker — single-Mac teams (the overwhelming onboarding case) auto-attach correctly on recency alone. + +### Orchestration on the composite (final concurrency model) + +Auto-attach is strictly the *no prior pairing* path: it chains from the no-stored-Mac branch of `reconnectActiveMacIfAvailable`. If a stored Mac exists, the normal reconnect owns it and auto-attach never runs. + +Three methods, with a clean per-attempt ownership model (a boolean proved insufficient across sign-out/account-switch boundaries): + +- `runAutoAttachOwningRestoringGate(stackUserID:)` — the gate-owning wrapper. Claims a per-attempt `autoAttachGeneration` (`beginAutoAttachGeneration`), holds `isReconnectingStoredMac`/`autoAttachOwnsRestoringGate`, starts a bounded restoring deadline keyed on the generation, runs the flow, then resolves the gate keyed on still owning the generation. It is the authoritative determiner for the no-stored-Mac path, so it clears `hasKnownPairedMac` itself on a miss (not via the caller's stored-mac-generation-guarded write, which a concurrent trigger could supersede). +- `attemptAutoAttachIfEligible(stackUserID:)` — public/test entry that does not own the gate. Dedupes via `autoAttachInFlight` (returns false immediately if an attempt is running), then runs the shared flow. +- `performAutoAttach(stackUserID:generation:)` — the shared flow. Decides from a **freshly-confirmed `.ok`** registry list (`deviceRegistry.listDevices()` directly, NOT the store-wide cache, which `loadRegistryDevices` keeps on transient failure), so a registry outage degrades to manual. After every await it re-checks `stillCurrent()` = same generation + signed-in + disconnected + same account, so a stale task can never resume under a different account or a newer attempt. On a candidate, calls `connectToRegistryInstance(..., rejectLoopback: isPhysicalDevice, supersedeAutoAttach: false)`. + +Cancellation / supersession (the hard part, resolved with explicit per-call intent + generation, never shared mutable markers): + +- `cancelAutoAttach()` bumps `autoAttachGeneration`, clears the running marker, and resolves the gate if a gate-owning attempt held it. Called on sign-out (BEFORE the gate-flag resets, so sign-out's resets are final) and via `supersedeInFlightAutoAttach`. +- `supersedeInFlightAutoAttach()` (user-initiated pairings only) additionally invalidates the pairing attempt AND bumps `connectionGeneration` + cancels remote work when an attempt is in flight, so an auto-attach already inside its own `connect()` (which installs the client guarded by `connectionGeneration`) discards its result instead of landing over the user's explicit pairing. +- Every user-initiated pairing entry point supersedes auto-attach BEFORE its own early-returns: `connectManualHost` (top, before validation), `connectPairingURLResult`/`beginPairingAttempt`, and `connectToRegistryInstance` (top, before the no-route guard). Auto-attach's OWN connect passes `supersedeAutoAttach: false` (an explicit per-call parameter on the stack, not a shared flag), so it never cancels itself while a concurrent user pairing on its own call still supersedes it. + +Loopback safety (physical device): `selectTarget(rejectLoopback:)` and `firstReachableHostPort(rejectLoopback:)` skip loopback routes, and `connectToRegistryInstance` strips loopback from the routes it persists into `MobilePairedMacStore`, so the next stored-Mac reconnect can never pick a `127.0.0.1` route that names the phone itself. The simulator (where `127.0.0.1` IS the host Mac) keeps loopback. + +### Lifecycle wiring (root view) + +In `Packages/CmuxMobileShellUI/.../CMUXMobileRootView.swift`, where `reconnectStoredMacIfNeeded()` is called (`.onAppear`, `.onChange(of: isAuthenticated)`, foreground), after the stored-mac reconnect resolves to "no known paired Mac", trigger auto-attach. Concretely: extend `reconnectActiveMacIfAvailable` so that when it finds no stored Mac (the `guard let mac = saved else` branch) AND auto-attach is enabled AND not connected, it chains into `attemptAutoAttachIfEligible`. This keeps a single entrypoint and one generation counter, so foreground/sign-in re-entries can't double-connect. The root scene's existing `RestoringSessionView` gate (`isReconnectingStoredMac` / `hasKnownPairedMac`) covers the auto-attach attempt window too, so the user sees "Restoring session…" briefly instead of a QR flash, then either the workspaces (success) or the pair screen (no candidate / failure). + +### Multi-Mac / ambiguity / outage + +- Multiple online Macs → ambiguous → pair screen (user picks in the device tree). +- Zero candidates / registry down / not signed in → pair screen. +- Different-account Mac → impossible to connect (same-account mint check); and the registry is team-scoped so it won't even appear. +- Bounded: one attempt per generation, cancellable, reuses the 6s restoring deadline. Registry/presence outage degrades to manual. + +## Tests (behavior, via injected seams) + +Pure selector tests in `CmuxMobileShellModel` (or shell tests): +- online-preferred: one online + one more-recently-seen offline → picks online. +- single candidate (recency, presence unavailable) → picks it. +- no candidate (no devices / no routes / no supported kind) → nil. +- ambiguous: two online → nil; two equally-recent (no presence) → nil. +- same-account is structural (registry team-scoped) — covered by existing `MobileHostAuthorizationTests`; selector never emits a cross-account target because it only sees team devices. + +Composite behavior tests (in-memory registry + paired-mac store doubles + scripted transport like the liveness tests): +- eligible + single candidate → connects, persists active paired Mac, second invocation is a no-op (stored path owns it). +- no candidate → returns false, no persistence, falls through. +- one attempt per generation: two rapid calls in the same generation → one connect. +- flag off → never attempts. +- not signed in / already connected / has stored Mac → never attempts. + +## Localization + +Auto-attach adds no new user-facing strings — it reuses the existing connect path and the `RestoringSessionView` copy. If a "Connecting to …" status string is added, it gets en+ja entries in `Resources/Localizable.xcstrings`. Localization audit in the handoff will state this explicitly. + +## Files + +- New: `Packages/CmuxMobileShellModel/.../MobileAutoAttachTargetSelector.swift` (pure selector) + `MobileAutoAttachFlag.swift`. +- Edit: `Packages/CmuxMobileShell/.../MobileShellComposite.swift` (flag field, `attemptAutoAttachIfEligible`, chain from reconnect). +- Edit: `Packages/CmuxMobileShellUI/.../CMUXMobileRootView.swift` only if a separate trigger is needed; preferred is chaining inside the composite so the root view is unchanged. +- New tests under `Packages/CmuxMobileShellModel/Tests` and `Packages/CmuxMobileShell/Tests`. From ca4609d3ac02202d87d9c7854baeb31ee5948ee4 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:27:35 -0700 Subject: [PATCH 2/6] Auto-attach: restore manual-supersede under main's validation split; enable presence-online-preferred via live presence (#5792) Two post-rebase changes: 1. Manual pairing must supersede an in-flight auto-attach at the TOP of connectPairingURLResult, before validation, so even an invalid code parks the background attempt (the documented invariant the manualPairingSupersedes* / invalidManualHost* tests assert). Main's #6028 refactor split the post-validation beginPairingAttempt(method: "qr") out of the early path, so the supersede no longer rode on it. Call supersedeInFlightAutoAttach() explicitly; it is guarded on autoAttachInFlight, so it is a no-op when nothing is parked. 2. Presence #5792 has landed, so wire its live signal into auto-attach target selection (deferred when the PR was opened). The shell already owns a live PresenceMap from the device-tree subscription; derive online device ids from it (new PresenceMap.onlineDeviceIDs, same rollup rule as deviceSummary) when no explicit MobileAutoAttachPresence provider is injected and presence data exists. The external provider seam still wins if set, and an empty map (no presence yet) keeps the recency-only path unchanged, so existing behavior and tests are preserved. This stays behind the auto-attach flag. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../MobileShellComposite.swift | 24 +++++++++++++++---- .../Sources/CmuxMobileShell/PresenceMap.swift | 15 ++++++++++++ .../PresenceMapTests.swift | 17 +++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index a4dbb7674d9b..a6a574dc97aa 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1665,7 +1665,19 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { await loadRegistryDevices() guard stillCurrent() else { return false } - let presenceOnline = await autoAttachPresence?.onlineDeviceIDs() + // Prefer an explicitly injected presence provider; otherwise fall back to + // the shell's own live presence stream (#5792), which the device tree + // already subscribes. Either way the selector prefers a single online Mac + // and treats multiple online Macs as ambiguous. `nil` (no provider AND no + // presence data yet) keeps the recency-only path unchanged. + let presenceOnline: Set? + if let provided = await autoAttachPresence?.onlineDeviceIDs() { + presenceOnline = provided + } else if !presenceMap.isEmpty { + presenceOnline = presenceMap.onlineDeviceIDs() + } else { + presenceOnline = nil + } guard stillCurrent() else { return false } // On a physical phone, reject loopback routes: a `127.0.0.1` route names @@ -2530,9 +2542,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { _ rawValue: String? = nil, acceptedVersionWarning: Bool ) async -> MobilePairingURLConnectionResult { - // `beginPairingAttempt` below supersedes any in-flight auto-attach (and - // resolves its restoring gate), so a background attempt that resumes - // mid-pairing cannot invalidate this manual attempt. + // A user-initiated manual pairing supersedes any in-flight auto-attach + // (and resolves its restoring gate) here at the top, BEFORE validation, so + // even an invalid code parks the background attempt and a later resume + // cannot invalidate this manual attempt. (Main split the post-validation + // `beginPairingAttempt(method: "qr")` out of this early path, so the + // supersede no longer rides on it and must fire explicitly.) + supersedeInFlightAutoAttach() let rawURL = Self.normalizedPairingURL(rawValue ?? pairingCode) _ = beginPairingValidationAttempt() connectionAttemptGeneration = UUID() diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/PresenceMap.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/PresenceMap.swift index c3a8ef0e251e..f442cab58069 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/PresenceMap.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/PresenceMap.swift @@ -86,4 +86,19 @@ public struct PresenceMap: Equatable, Sendable { lastSeenAt: Date(timeIntervalSince1970: lastSeenMs / 1000) ) } + + /// The device ids the live presence stream currently reports online (any + /// instance online), using the same rollup rule as ``deviceSummary``. This + /// is the auto-attach target selector's presence signal: it prefers a single + /// live Mac and treats 2+ live Macs as ambiguous instead of guessing on + /// recency. Empty when no presence data has streamed yet, which the caller + /// distinguishes from "data exists, nobody online" via ``isEmpty``. + public func onlineDeviceIDs() -> Set { + var ids: Set = [] + for (deviceId, instances) in instancesByDevice + where instances.values.contains(where: { $0.online }) { + ids.insert(deviceId) + } + return ids + } } diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/PresenceMapTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/PresenceMapTests.swift index 059f80a3a78f..97bfff82654e 100644 --- a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/PresenceMapTests.swift +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/PresenceMapTests.swift @@ -116,4 +116,21 @@ import Testing #expect(map.instance(deviceId: "mac-a", tag: "ghost") == nil) #expect(map.deviceSummary(deviceId: "mac-z") == nil) } + + @Test func onlineDeviceIDsRollsUpAnyOnlineInstancePerDevice() { + var map = PresenceMap() + // mac-a: one online instance; mac-b: only offline instances; mac-c: a + // mix (offline default + online build tag) so the device counts as online. + map.apply(snapshot([ + instance(deviceId: "mac-a", tag: "default", online: true), + instance(deviceId: "mac-b", tag: "default", online: false), + instance(deviceId: "mac-c", tag: "default", online: false), + instance(deviceId: "mac-c", tag: "beta", online: true), + ])) + #expect(map.onlineDeviceIDs() == ["mac-a", "mac-c"]) + // An empty map yields no online ids; the auto-attach caller distinguishes + // this "no presence data yet" case from "data exists, nobody online" via + // `isEmpty`, so it can fall back to recency only when truly unseeded. + #expect(PresenceMap().onlineDeviceIDs().isEmpty) + } } From f730d29405b876166277054e382b914519710110 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 14 Jun 2026 01:42:03 -0700 Subject: [PATCH 3/6] Refresh Swift file-length budget for auto-attach files Regenerated from the post-rebase tree so the CI guard reflects real counts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/swift-file-length-budget.tsv | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index a7525a7bd27c..688681913f46 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -19,9 +19,9 @@ 6074 Sources/TabManager.swift 6074 Sources/TextBoxInput.swift 5925 cmuxTests/TerminalAndGhosttyTests.swift +5549 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 5522 cmuxTests/BrowserConfigTests.swift 4921 Sources/cmuxApp.swift -5113 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 4460 Sources/Panels/FilePreviewPanel.swift 4400 cmuxTests/BrowserPanelTests.swift 4227 Sources/BrowserWindowPortal.swift @@ -32,7 +32,7 @@ 3664 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 3397 Sources/CmuxConfig.swift 3331 cmuxTests/TabManagerSessionSnapshotTests.swift -3204 Sources/Update/UpdateTitlebarAccessory.swift +3200 Sources/Update/UpdateTitlebarAccessory.swift 2878 Sources/SessionIndexView.swift 2871 cmuxTests/CMUXOpenCommandTests.swift 2573 Sources/KeyboardShortcutSettings.swift @@ -59,11 +59,11 @@ 1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift 1574 cmuxTests/MarkdownPanelTests.swift 1560 cmuxTests/TextBoxMentionCompletionTests.swift -1498 cmuxTests/OmnibarAndToolsTests.swift +1497 cmuxTests/OmnibarAndToolsTests.swift 1496 cmuxUITests/MultiWindowNotificationsUITests.swift 1446 Sources/FileExplorerStore.swift -1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift 1382 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift +1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift 1373 cmuxTests/AppDelegateIssue2907RoutingTests.swift 1366 Sources/Feed/FeedButtonStyleDebugWindowController.swift 1362 Sources/CMUXInstalledExtensionSidebarHostView.swift @@ -91,10 +91,11 @@ 945 Sources/SessionIndexRegisteredAgents.swift 937 Sources/TextBoxMentionIndexStore.swift 934 Sources/App/ShortcutRoutingSupport.swift +932 Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileAutoAttachTests.swift 926 Sources/DockPanelView.swift +920 Sources/CommandPalette/CommandPaletteSettingsToggle.swift 919 Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift 918 cmuxTests/WorkspaceGroupTests.swift -920 Sources/CommandPalette/CommandPaletteSettingsToggle.swift 905 Sources/CmuxSSHURLRequest.swift 901 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift 893 Sources/WorkspaceContentView.swift @@ -117,7 +118,7 @@ 752 cmuxUITests/CloseWorkspaceCmdDUITests.swift 746 Sources/App/MenuBarExtraController.swift 738 Packages/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift -738 Packages/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift +736 Packages/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift 716 Sources/TaskManagerSnapshot.swift 715 Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift 715 Sources/AppleScriptSupport.swift From ed632e5e3c615a005a6f96a5f2d950a0424805e6 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 14 Jun 2026 02:32:11 -0700 Subject: [PATCH 4/6] Justify auto-attach policy namespaces for the conventions lint Main's iOS package-conventions lint now flags all-static public types (both caseless namespace enums and static-only structs) repo-wide. MobileAutoAttachFlag, MobileAttachRoutePriority, and MobileAutoAttachTargetSelector are pure, stateless policy namespaces: deterministic transforms over values passed per call, no I/O and no injected dependencies, so there is nothing to instantiate. Carry the sanctioned inline lint:allow justification rather than forcing an artificial instance type onto pure functions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../CmuxMobileShellModel/MobileAttachRoutePriority.swift | 4 ++++ .../Sources/CmuxMobileShellModel/MobileAutoAttachFlag.swift | 4 ++++ .../CmuxMobileShellModel/MobileAutoAttachTargetSelector.swift | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAttachRoutePriority.swift b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAttachRoutePriority.swift index b2c6441eff5e..d134e1859d9f 100644 --- a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAttachRoutePriority.swift +++ b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAttachRoutePriority.swift @@ -9,6 +9,10 @@ public import CMUXMobileCore /// the model package (not privately on the shell) so the pure auto-attach target /// selector can rank devices by their best reachable route without importing the /// shell. +/// +/// Pure route-priority transform over values passed per call; the shared source +/// of truth for route ordering holds no per-instance state, nothing to instantiate. +// lint:allow namespace-type, namespace-enum — stateless policy namespace, see above. public enum MobileAttachRoutePriority { /// Whether `left` should be tried before `right`: lower `priority` wins, with /// the route `id` as a stable lexicographic tiebreak so the ordering is diff --git a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachFlag.swift b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachFlag.swift index c8416edbd390..0685d27631ea 100644 --- a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachFlag.swift +++ b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachFlag.swift @@ -7,6 +7,10 @@ public import Foundation /// the macOS beta-feature convention of a `UserDefaults`-backed boolean. The flag /// is read once at the composition root and injected into the shell as a plain /// `Bool`, so the shell stays testable without touching `UserDefaults`. +/// +/// Pure flag-resolution policy with no per-instance state; its only dependency +/// (`UserDefaults`) is passed per call, so there is nothing to instantiate. +// lint:allow namespace-type, namespace-enum — stateless policy namespace, see above. public enum MobileAutoAttachFlag { /// The `UserDefaults` key. Present-and-set overrides the build default, so a /// dogfood Release build can opt in (or a DEBUG build can opt out) without a diff --git a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachTargetSelector.swift b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachTargetSelector.swift index 38526a2fae9f..55059d54081e 100644 --- a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachTargetSelector.swift +++ b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachTargetSelector.swift @@ -17,6 +17,10 @@ public import Foundation /// the registry is scoped to the signed-in user's team, so a different-account /// Mac never appears here, and even a stray route would be rejected at mint by /// the Mac's same-Stack-account authorization check. +/// +/// Pure, deterministic target-selection policy over values passed per call (no +/// I/O, no injected dependencies, no per-instance state), nothing to instantiate. +// lint:allow namespace-type, namespace-enum — stateless policy namespace, see above. public enum MobileAutoAttachTargetSelector { /// A device that is a viable auto-attach target: a controllable host with at /// least one instance reachable on a supported route. From 3813abb8db42a1e08836d6007cd336a87a45e9bb Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:52:39 -0700 Subject: [PATCH 5/6] Regenerate budget ledger (dedupe MobileShellComposite from auto-merge) --- .github/swift-file-length-budget.tsv | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 2bef7a2819ea..ab77aee440ef 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -18,9 +18,8 @@ 6156 Sources/TabManager.swift 6153 CLI/cmux_open.swift 6074 Sources/TextBoxInput.swift -5987 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +5989 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 5925 cmuxTests/TerminalAndGhosttyTests.swift -5553 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 5526 cmuxTests/BrowserConfigTests.swift 4920 Sources/cmuxApp.swift 4467 Sources/Panels/FilePreviewPanel.swift @@ -183,7 +182,6 @@ 562 cmuxTests/AgentExecutableResolverTests.swift 561 cmuxTests/GhosttyConfigPathResolverTests.swift 558 Packages/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+Config.swift -557 Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift 554 Sources/Panels/BrowserAutomation.swift 551 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift 547 Packages/CmuxSocketControl/Sources/CmuxSocketControl/SocketControlSettings.swift From 4425260112074f9e7d5f28e250d0d4534e908f96 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:44:15 -0700 Subject: [PATCH 6/6] iOS tests: stop route-fallback test helper from aborting the whole xctest process waitForWorkspaceListRequestCount could time out and return a short array, which callers then index directly (workspaceLists[0]/[1]). A short array made that subscript trap with `Index out of range` (ContiguousArrayBuffer:691), crashing the entire xctest process and failing every concurrently-running Swift Testing test, including attachTicketFallsBackToNextRouteWhenPreferredRouteFails. Require the count so a timed-out wait fails just this test with a readable message instead of crashing the process. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Tests/cmuxFeatureTests/cmuxFeatureTests.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index 488b68708212..675fce3ee17e 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -2676,7 +2676,8 @@ private func scriptedWorkspaceListResponses( private func waitForWorkspaceListRequestCount( _ count: Int, - responses: ScriptedTransportResponses + responses: ScriptedTransportResponses, + sourceLocation: SourceLocation = #_sourceLocation ) async throws -> [RecordedRPCRequest] { var workspaceLists: [RecordedRPCRequest] = [] for _ in 0..<200 { @@ -2686,6 +2687,16 @@ private func waitForWorkspaceListRequestCount( } try await Task.sleep(nanoseconds: 10_000_000) } + // Never return a short array: callers index `workspaceLists[0]`/`[1]` + // directly, so a timed-out wait that returned fewer than `count` elements + // would trap with `Index out of range` and abort the whole xctest process + // (taking every concurrently-running test down with it) instead of failing + // just this test. Require the count so the failure stays local and readable. + try #require( + workspaceLists.count >= count, + "expected at least \(count) workspace.list request(s), saw \(workspaceLists.count)", + sourceLocation: sourceLocation + ) return workspaceLists }