iOS: registry-driven auto-attach (sign in → connected)#6044
iOS: registry-driven auto-attach (sign in → connected)#6044lawrencecchen wants to merge 1 commit into
Conversation
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 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThis PR implements registry-driven auto-attach for iOS: when a user signs in without a previously stored paired Mac, the system automatically selects and connects to a reachable Mac from the device registry using deterministic rules (presence-based filtering, recency-based selection, ambiguity detection) protected by generation-based cancellation to prevent stale attempts from interfering with user pairing actions. ChangesiOS Registry-Driven Auto-Attach Feature
🎯 4 (Complex) | ⏱️ ~75 minutes Possibly Related PRs
Important Pre-merge checks failedPlease resolve all errors before merging. Addressing warnings is optional. ❌ Failed checks (6 errors, 1 warning)
✅ Passed checks (14 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryThis PR adds registry-driven auto-attach to the iOS app: a signed-in phone with no stored pairing now queries the team-scoped device registry, picks the single unambiguous Mac (presence-online first, else the strictly most-recently-seen with a reachable route), and connects via the existing
Confidence Score: 4/5Safe to merge; the new auto-attach path defaults off in Release builds and all concurrency, cross-account, and loopback-rejection scenarios are covered by tests. The orchestration is thoughtfully designed with generation-based supersession, explicit account guards after every suspension, and a comprehensive test suite. The three issues found are all non-blocking: a dead
Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[reconnectActiveMacIfAvailable] --> B{Stored paired Mac?}
B -- yes --> C[reconnect stored Mac path]
B -- no --> D{autoAttachEnabled?}
D -- no --> E[setHasKnownPairedMac false\nfinishStoredMacReconnect\n→ pair screen]
D -- yes --> F{autoAttachInFlight?}
F -- yes --> G[return false\nsecond trigger deduped]
F -- no --> H[runAutoAttachOwningRestoringGate\nholds RestoringSessionView up]
H --> I[beginAutoAttachGeneration\nclaim generation token]
I --> J[deviceRegistry.listDevices\nfresh authoritative query]
J --> K{outcome == .ok?}
K -- no --> L[loadRegistryDevices for UI\nfall through → pair screen]
K -- yes --> M[loadRegistryDevices for UI\ncheck stillCurrent]
M --> N[autoAttachPresence.onlineDeviceIDs]
N --> O[MobileAutoAttachTargetSelector.selectTarget\npure, deterministic]
O --> P{single obvious Mac?}
P -- no --> Q[fall through → pair screen]
P -- yes --> R{stillCurrent?}
R -- no --> S[discard: account switch\nor user pairing]
R -- yes --> T[connectToRegistryInstance\nsupersedeAutoAttach=false]
T --> U{connected?}
U -- yes --> V[persist to MobilePairedMacStore\nnext launch = stored-mac path]
U -- no --> Q
V --> W[return true\nRestoringSessionView → connected]
Reviews (1): Last reviewed commit: "iOS: registry-driven auto-attach (sign i..." | Re-trigger Greptile |
| public static func selectTarget( | ||
| devices: [RegistryDevice], | ||
| supportedRouteKinds: [CmxAttachTransportKind], | ||
| presenceOnlineDeviceIDs: Set<String> = [], | ||
| presenceAvailable: Bool = false, | ||
| rejectLoopback: Bool = false, | ||
| now: Date = Date() | ||
| ) -> Candidate? { |
There was a problem hiding this comment.
The
now parameter is declared in the public signature and passed by every call site (including performAutoAttach, which supplies runtime?.now() ?? Date()), but it is never forwarded to candidate(for:supportedRouteKinds:rejectLoopback:) or mostRecentUnambiguous(_:). Callers therefore believe they are influencing recency-based selection — e.g. an age cutoff for stale devices — when the value has no effect at all. If absolute-recency filtering is deferred, the parameter should either be removed now or annotated clearly so future implementors know it is intentionally a no-op.
| public static func selectTarget( | |
| devices: [RegistryDevice], | |
| supportedRouteKinds: [CmxAttachTransportKind], | |
| presenceOnlineDeviceIDs: Set<String> = [], | |
| presenceAvailable: Bool = false, | |
| rejectLoopback: Bool = false, | |
| now: Date = Date() | |
| ) -> Candidate? { | |
| public static func selectTarget( | |
| devices: [RegistryDevice], | |
| supportedRouteKinds: [CmxAttachTransportKind], | |
| presenceOnlineDeviceIDs: Set<String> = [], | |
| presenceAvailable: Bool = false, | |
| rejectLoopback: Bool = false | |
| ) -> Candidate? { |
| analytics.capture("ios_auto_attach_attempt", [ | ||
| "candidate_device_count": .int(registryDevices.count), | ||
| "presence_available": .bool(presenceOnline != nil), | ||
| ]) |
There was a problem hiding this comment.
The analytics event fires before
loadRegistryDevices() has been called, so registryDevices.count reflects the UI cache from the previous load, not the freshDevices list that selectTarget just used to find a candidate. On a cold-start with no prior cache this reports 0; after a prior refresh it may report a count that doesn't match the set actually evaluated. Using freshDevices.count gives the accurate cardinality for this event.
| analytics.capture("ios_auto_attach_attempt", [ | |
| "candidate_device_count": .int(registryDevices.count), | |
| "presence_available": .bool(presenceOnline != nil), | |
| ]) | |
| analytics.capture("ios_auto_attach_attempt", [ | |
| "candidate_device_count": .int(freshDevices.count), | |
| "presence_available": .bool(presenceOnline != nil), | |
| ]) |
| @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. |
There was a problem hiding this comment.
The block comment says "First call: empty registry → no candidate → false" and "Then the same store, given a candidate registry, connects on retry", but the
FakeRegistry is constructed with mac-A from the start and #expect(first) asserts true — the first call connects successfully. The comment appears to be left over from an earlier test design and now contradicts both the fixture and the assertion.
| @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. | |
| @Test func sequentialRetryAfterFailureIsAllowed() async throws { | |
| // After an attempt finishes (with or 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: registry has mac-A → connects | |
| // → true. Second call: already connected → short-circuits via the top | |
| // guard → false, proving the flag did not latch permanently. |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 59bc671610
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // 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() |
There was a problem hiding this comment.
Fall through immediately after registry failure
In the .authRejected / .transientFailure branch this awaits loadRegistryDevices(), which calls deviceRegistry.listDevices() again. When /api/devices is down or timing out, a fresh-install auto-attach pays the failed registry request twice before returning to the pair flow, keeping the auto-attach attempt in-flight and deduping other triggers after this code has already decided it will not connect. Refresh the device tree asynchronously or reuse the failed outcome instead of blocking the fallback path.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 1448-1711: Extract the entire auto-attach state machine (the logic
and state around
autoAttachGeneration/autoAttachRunningGeneration/autoAttachOwnsRestoringGate/autoAttachInFlight,
and the methods supersedeInFlightAutoAttach, cancelAutoAttach,
runAutoAttachOwningRestoringGate, attemptAutoAttachIfEligible,
beginAutoAttachGeneration, endAutoAttachGeneration, and performAutoAttach) into
a new helper class/struct AutoAttachCoordinator inside Packages/CmuxMobileShell;
move all generation/gate/deadline/connect orchestration there and keep
MobileShellComposite as a thin delegate that forwards calls and exposes only
simple lifecycle APIs (e.g. coordinator.supersedeInFlightAutoAttach(),
coordinator.attemptAutoAttachIfEligible(stackUserID:),
coordinator.runOwningRestoringGate(stackUserID:)). Ensure the new coordinator
receives required dependencies via initializer (deviceRegistry, analytics,
identityProvider, runtime, autoAttachPresence, functions or closures for
connectToRegistryInstance, cancelRemoteOperationTasks, loadRegistryDevices, and
accessors/mutators for
connectionState/connectionGeneration/hasKnownPairedMac/isSignedIn/isReconnectingStoredMac/didFinishStoredMacReconnectAttempt)
and preserves main-actor guarantees, generation guards, task cancellation, and
behavior (including restoringDeadline, presence handling, rejectLoopback logic,
and analytics events); update MobileShellComposite to call into the coordinator
and remove the moved state and methods from the composite.
- Around line 1592-1640: The auto-attach flow can start while a user-initiated
pairing/connect is already in flight, which lets the background attach overwrite
pairingAttemptID/connectionGeneration; fix by refusing to start auto-attach when
a user pairing is active: add a guard in attemptAutoAttachIfEligible (and/or at
the top of performAutoAttach's stillCurrent) that returns false if a user
pairing is in progress (check the existing pairingAttemptID or the
pairing-in-flight boolean used by connect/connectManualHost), so auto-attach
only proceeds when pairingAttemptID == nil (or pairing-in-flight == false) and
thus cannot supersede a user pairing attempt.
In
`@Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileAutoAttachTests.swift`:
- Around line 290-314: The test comment is incorrect about the first attempt:
the registry is initialized with a device (registry = FakeRegistry(devices:
[device(id: "mac-A", ...)])), so the first call to
sequentialRetryAfterFailureIsAllowed's store.attemptAutoAttachIfEligible(...)
actually succeeds (first == true). Update the comment above the test (the
paragraph starting "After an attempt finishes..." / the line "First call: ...")
to state that the registry contains a candidate so the first attempt connects
(expect true), and that the second sequential call is a no-op when already
connected (expect false); no code logic changes needed.
- Line 79: The count() method accesses the actor-isolated property macs but
isn’t marked async; change the signature func count() -> Int to func count()
async -> Int (or func count() async -> Int { macs.count }) and then update all
call sites (e.g., tests or helpers that invoke count()) to await the call and
make those callers async or wrap the call in Task/await, ensuring actor
isolation is preserved; reference: count() and the actor-isolated macs property.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 8cafceaf-8d85-43d4-bb59-4f8a38eddcc0
📒 Files selected for processing (9)
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileAutoAttachPresenceProviding.swiftPackages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swiftPackages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileAutoAttachTests.swiftPackages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swiftPackages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAttachRoutePriority.swiftPackages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachFlag.swiftPackages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachTargetSelector.swiftPackages/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileAutoAttachTargetSelectorTests.swiftplans/feat-ios-auto-attach/DESIGN.md
| // 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 | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift
Extract the new auto-attach state machine out of this store before it grows further.
This PR adds another large orchestration block to a production Swift file that is already far past the repo’s size threshold and already mixes connection lifecycle, registry caching, pairing, drafts, notifications, and terminal liveness. Please move the auto-attach generation/gate/connect flow into a dedicated helper/coordinator inside Packages/CmuxMobileShell and keep MobileShellComposite as the composition surface.
As per coding guidelines, production Swift files over 800 lines should be flagged when a PR adds more than 250 lines without extracting mixed responsibilities.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 1448 - 1711, Extract the entire auto-attach state machine (the
logic and state around
autoAttachGeneration/autoAttachRunningGeneration/autoAttachOwnsRestoringGate/autoAttachInFlight,
and the methods supersedeInFlightAutoAttach, cancelAutoAttach,
runAutoAttachOwningRestoringGate, attemptAutoAttachIfEligible,
beginAutoAttachGeneration, endAutoAttachGeneration, and performAutoAttach) into
a new helper class/struct AutoAttachCoordinator inside Packages/CmuxMobileShell;
move all generation/gate/deadline/connect orchestration there and keep
MobileShellComposite as a thin delegate that forwards calls and exposes only
simple lifecycle APIs (e.g. coordinator.supersedeInFlightAutoAttach(),
coordinator.attemptAutoAttachIfEligible(stackUserID:),
coordinator.runOwningRestoringGate(stackUserID:)). Ensure the new coordinator
receives required dependencies via initializer (deviceRegistry, analytics,
identityProvider, runtime, autoAttachPresence, functions or closures for
connectToRegistryInstance, cancelRemoteOperationTasks, loadRegistryDevices, and
accessors/mutators for
connectionState/connectionGeneration/hasKnownPairedMac/isSignedIn/isReconnectingStoredMac/didFinishStoredMacReconnectAttempt)
and preserves main-actor guarantees, generation guards, task cancellation, and
behavior (including restoringDeadline, presence handling, rejectLoopback logic,
and analytics events); update MobileShellComposite to call into the coordinator
and remove the moved state and methods from the composite.
Source: Coding guidelines
| 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 } |
There was a problem hiding this comment.
Block auto-attach while a user pairing attempt is already in flight.
This entrypoint only gates on sign-in/connection/auto-attach state. If a QR/manual pairing is already awaiting connect(), a later reconnect/foreground trigger can still enter auto-attach. Once that background flow reaches its own connectManualHost(... supersedeAutoAttach: false), it overwrites pairingAttemptID and connectionGeneration, so the user-started pairing is deterministically superseded by the background attach.
Suggested fix
`@discardableResult`
func attemptAutoAttachIfEligible(stackUserID: String?) async -> Bool {
- guard autoAttachEnabled, isSignedIn, connectionState != .connected else { return false }
+ guard autoAttachEnabled,
+ isSignedIn,
+ connectionState != .connected,
+ pairingAttemptMethod == nil else { return false }
guard !autoAttachInFlight else { return false }
let generation = beginAutoAttachGeneration()
defer { endAutoAttachGeneration(generation) }
return await performAutoAttach(stackUserID: stackUserID, generation: generation)
}
@@
func stillCurrent() -> Bool {
generation == autoAttachGeneration
&& isSignedIn
&& connectionState != .connected
+ && pairingAttemptMethod == nil
&& identityProvider?.currentUserID == requestingUserID
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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 } | |
| func attemptAutoAttachIfEligible(stackUserID: String?) async -> Bool { | |
| guard autoAttachEnabled, | |
| isSignedIn, | |
| connectionState != .connected, | |
| pairingAttemptMethod == nil 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 | |
| && pairingAttemptMethod == nil | |
| && identityProvider?.currentUserID == requestingUserID | |
| } | |
| guard autoAttachEnabled, stillCurrent(), let deviceRegistry else { return false } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 1592 - 1640, The auto-attach flow can start while a user-initiated
pairing/connect is already in flight, which lets the background attach overwrite
pairingAttemptID/connectionGeneration; fix by refusing to start auto-attach when
a user pairing is active: add a guard in attemptAutoAttachIfEligible (and/or at
the top of performAutoAttach's stillCurrent) that returns false if a user
pairing is in progress (check the existing pairingAttemptID or the
pairing-in-flight boolean used by connect/connectManualHost), so auto-attach
only proceeds when pairingAttemptID == nil (or pairing-in-flight == false) and
thus cannot supersede a user pairing attempt.
|
|
||
| func removeAll() async throws { macs.removeAll() } | ||
|
|
||
| func count() -> Int { macs.count } |
There was a problem hiding this comment.
Make count() async to match actor isolation.
The count() method accesses the actor-isolated macs property but is not marked async. Actor-isolated methods must be async to maintain thread safety.
🔧 Proposed fix
- func count() -> Int { macs.count }
+ func count() async -> Int { macs.count }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileAutoAttachTests.swift`
at line 79, The count() method accesses the actor-isolated property macs but
isn’t marked async; change the signature func count() -> Int to func count()
async -> Int (or func count() async -> Int { macs.count }) and then update all
call sites (e.g., tests or helpers that invoke count()) to await the call and
make those callers async or wrap the call in Task/await, ensuring actor
isolation is preserved; reference: count() and the actor-isolated macs property.
| @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) | ||
| } |
There was a problem hiding this comment.
Comment doesn't match test logic.
The comment at Line 293 states "First call: empty registry → no candidate → false", but the registry is initialized with mac-A at Line 298, so the first attempt succeeds (Line 309 expects first == true).
The test appears to validate that sequential calls are allowed when already connected (not retry-after-failure). Either update the comment to match the actual behavior, or change the test to match the comment's intent (create registry with empty devices, call once expecting false, then populate it and retry expecting true).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileAutoAttachTests.swift`
around lines 290 - 314, The test comment is incorrect about the first attempt:
the registry is initialized with a device (registry = FakeRegistry(devices:
[device(id: "mac-A", ...)])), so the first call to
sequentialRetryAfterFailureIsAllowed's store.attemptAutoAttachIfEligible(...)
actually succeeds (first == true). Update the comment above the test (the
paragraph starting "After an attempt finishes..." / the line "First call: ...")
to state that the registry contains a candidate so the first attempt connects
(expect true), and that the second sequential call is a no-op when already
connected (expect false); no code logic changes needed.
…d mobileAutoAttach flag, #6044)
The gap
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 whenMobilePairedMacStorealready has an active row, which only exists after a prior QR/manual pairing. The team-scoped device registry 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".This adds registry-driven auto-attach: a signed-in phone whose team has one reachable Mac connects with no QR scan or manual host entry. Onboarding becomes "sign in → connected".
Key finding: can the phone mint from a registry route without prior QR pairing?
Yes. 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 (
MobileHostAuthorizationPolicy.authorizeStackUserinSources/Mobile/MobileHostService.swift:guard localUserID == remoteUserID else { throw .accountMismatch }), not on any pairing record.mobile.attach_ticket.createmints from routes + a bearer token with no prior pairing. So a signed-in phone can mint a valid ticket from a registry route purely because it owns the same Stack account that owns the Mac. The registry is team-scoped, so a different-account Mac never appears, and a stray cross-account route would be rejected at mint byaccount_mismatch. Auto-attach therefore reuses the existingconnectToRegistryInstancepath (pick route, mint Stack-authenticated, persist intoMobilePairedMacStore).Mechanism
Registry + presence + same-account mint. On the no-stored-Mac branch of
reconnectActiveMacIfAvailable, auto-attach loads a freshly-confirmed.okregistry list, picks the single obvious Mac via the pureMobileAutoAttachTargetSelector(presence-online preferred, else the single most-recently-seen with a reachable route; any ambiguity → fall through), and connects viaconnectToRegistryInstance, persisting the pairing so the next launch takes the normal reconnect path.A presence seam (
MobileAutoAttachPresenceProviding) is optional and degrades to recency until presence (#5792) lands, which is correct for the dominant single-Mac team.Flag + default
mobileAutoAttachviaMobileAutoAttachFlag(keycmux.mobile.autoAttach.enabled): DEBUG on, Release off until dogfooded. Read once at the composition root and injected as aBool, so the shell stays testable. An explicitUserDefaults/settings override wins over the build default.Fallback / multi-Mac
No candidate, registry/presence outage (transient failure degrades to manual rather than connecting off a stale cache), connect failure, or ambiguous multi-online → fall through to today's pair screen. Bounded and cancellable: the gate-owning runner caps the restoring window with the same 6s deadline the stored-Mac path uses, and one attempt runs at a time. Never auto-attaches to a different-account Mac.
Concurrency is handled with a per-attempt generation (not a shared boolean) plus explicit per-call supersede intent: every user-initiated pairing entry point (manual host top before validation, QR/code, device-tree tap top before the no-route guard) supersedes any in-flight auto-attach before its own early-returns, invalidating the pairing attempt and bumping the connection generation so an auto-attach already inside its own
connect()discards its result. Auto-attach's own connect passessupersedeAutoAttach: falseso it never cancels itself. Sign-out supersedes the in-flight attempt before resetting the restoring-gate flags so the next account's sign-in starts clean.On a physical phone, loopback routes are rejected (a
127.0.0.1route names the phone itself, not the Mac, and loopback is Stack-auth-trusted):selectTargetandfirstReachableHostPortskip them, andconnectToRegistryInstancestrips loopback from the routes it persists, so the next stored-Mac reconnect can never pick localhost. The simulator (where127.0.0.1is the host Mac) keeps loopback.Tests
MobileAutoAttachTargetSelectorTests, 13): online > recency, no-candidate, ambiguity (multi-online / equally-recent), per-device instance tie, loopback rejection for physical phones.MobileAutoAttachTests, 23 via injected registry/paired-store/identity doubles + scripted transport): connects + persists, fall-through on no candidate / transient registry failure, one-attempt dedupe, flag off, already-connected, same-account guard (stale attempt after account switch), manual/QR/no-route-tap/invalid-host supersession (including auto-attach mid-connect), sign-out-during-gate clean handoff, loopback not persisted.swift testgreen forCmuxMobileShellModel(60) andCmuxMobileShell(138); iOS arm64 simulator build (cmux-ios) succeeds. Structured autoreview clean.Localization audit
No new user-facing strings. Auto-attach reuses the existing
RestoringSessionViewcopy and pairing error strings; noResources/Localizable.xcstringschanges. The onlyL10n.stringlines touched are pre-existing validation strings I reordered.🤖 Generated with Claude Code
Need help on this PR? Tag
/codesmithwith what you need. Autofix is disabled.Note
Medium Risk
Touches core mobile connection, pairing persistence, and restoring UI gates with many async race paths; mitigated by conservative target selection, fresh registry reads, and extensive tests, but onboarding/connect regressions are still plausible.
Overview
Registry-driven auto-attach lets a signed-in phone with no stored paired Mac try to connect automatically: load a fresh
.okdevice registry list (not the UI cache), pick one Mac viaMobileAutoAttachTargetSelector(optional presence, else recency; ambiguous multi-Mac → manual pair), then reuseconnectToRegistryInstanceand persist pairing on success.New pieces:
MobileAutoAttachFlag(DEBUG on / Release off), sharedMobileAttachRoutePriority, optionalMobileAutoAttachPresenceProviding, and orchestration inMobileShellCompositechained from the no-stored-Mac branch ofreconnectActiveMacIfAvailablewith a restoring-gate runner and bounded deadline.Safety/concurrency: per-attempt
autoAttachGeneration, one in-flight attempt,cancelAutoAttachon sign-out, and user-initiated manual/QR/device-tree flows superseding in-flight auto-attach (including mid-connect()viaconnectionGeneration). Physical devices reject and do not persist loopback routes.Broad unit/composite test coverage plus a design doc under
plans/feat-ios-auto-attach/.Reviewed by Cursor Bugbot for commit 59bc671. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by cubic
Adds registry-driven auto-attach on iOS so a signed-in phone auto-connects to its team’s single reachable Mac without QR or manual entry; if ambiguous or unreachable, it falls back to the pair screen.
New Features
connectToRegistryInstanceand persists toMobilePairedMacStore.MobileAutoAttachTargetSelector: prefers online; else the single most-recent reachable; ambiguity → no attach.MobileAttachRoutePriority: shared route-ordering used by reconnect, switcher, device-tree tap, and auto-attach.MobileAutoAttachPresenceProviding; degrades to recency until presence lands.Migration
mobileAutoAttach(MobileAutoAttachFlag): DEBUG on, Release off; override withUserDefaultskeycmux.mobile.autoAttach.enabled.cmux.mobile.autoAttach.enabled = true.Written for commit 59bc671. Summary will update on new commits.
Summary by CodeRabbit