Skip to content

iOS: harden multi-Mac host switcher (session-drop, scoping, layering) - #5545

Closed
lawrencecchen wants to merge 4 commits into
mainfrom
feat-ios-multi-mac-land
Closed

lawrencecchen wants to merge 4 commits into
mainfrom
feat-ios-multi-mac-land

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 6, 2026 •

Copy link
Copy Markdown
Contributor

Focused hardening follow-up to #5513 ("iOS: multi-Mac host switcher", @lawrencecchen), which has now merged to main. This branch is rebased onto current main, so the diff is only the three fixes below (the switcher itself is already on main).

All three came out of an autoreview pass over the switcher code:

  1. P1, session not dropped on a failed "Pair Another Mac". The host picker called connectPairingURL directly and dismissed unconditionally; that connect path is destructive, so scanning a stale/expired/offline QR could tear down a working session with no fallback. New MobileShellComposite.pairAdditionalMac mirrors switchToMac's safety: attempt the connect, and on failure reconnect the still-active stored Mac. persistPairedMacFromTicket runs only on the success path, so the store's active row still points at the previous Mac on failure and the reconnect targets it. The picker now dismisses only on a successful connect.

  2. P1, scoped the failed-pair reconnect fallback. The fallback passed identityProvider?.currentUserID straight in; when nil, activeMac(stackUserID: nil) is the store's all-users query, so on a shared device a failed scan could reconnect to another Stack user's active Mac. The fallback now only runs with a non-nil scoped user id, matching loadPairedMacs's discipline.

  3. P2, dropped the direct UI→service package edge. CmuxMobileShellUI depended directly on the CmuxMobilePairedMac service package only to name MobilePairedMac. CmuxMobileShell (the domain package the UI already depends on) public imports it and exposes pairedMacs, so the type is reached through the allowed domain→service edge instead.

The switchToMac route-equality active-row concern raised in review is documented as reject-with-evidence: connectManualHost mints a synthetic manual-<host>:<port> ticket id, so there is no real Mac id to reconcile against and route-equality is the best available signal.

Both testWorkspaceToolbarCreatesWorkspaceAndTerminal and testTUITerminalUsesAvailableViewportAndResizes pass on this branch HEAD (verified via test-e2e at SHA 946c699, which also proves the package change compiles on iOS).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Manage multiple paired Macs and switch between them.
    • View all previously paired Macs in a single list.
    • Forget/remove individual paired Macs.
    • Pairing via scan only dismisses the picker on successful connection.
  • Bug Fixes / Behavior

    • Paired Macs list is cleared on sign-out.
    • Switches persist only after a confirmed live connection; failed attempts restore prior session.

Note

Medium Risk
Changes live connection teardown and reconnect fallback during pairing; scoped user id avoids cross-account reconnect on shared devices but session handling must stay correct on superseded attempts.

Overview
Pair Another Mac no longer calls the destructive connectPairingURL path and dismisses unconditionally. MobileShellComposite.pairAdditionalMac runs pairing via connectPairingURLResult, returns success only when the new Mac connects, and on failure reconnects the prior session with reconnectActiveMacIfAvailable(stackUserID:) only when a scoped Stack user id was captured before connect—avoiding an unscoped store lookup on shared devices. MobileHostPickerView dismisses the picker only on true; otherwise it refreshes paired Macs and leaves the sheet open.

Adds documentation in switchToMac that persisting the active paired row must use live host:port route equality, because manual reconnect uses synthetic manual-<host>:<port> ticket ids that cannot match stored Mac device ids.

Reviewed by Cursor Bugbot for commit bf35664. Bugbot is set up for automated code reviews on this repo. Configure here.

@vercel

vercel Bot commented Jun 6, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 7, 2026 11:03am
cmux-staging Building Building Preview, Comment Jun 7, 2026 11:03am

@coderabbitai

coderabbitai Bot commented Jun 6, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds per-user pairedMacs state and four async operations (load, switch, forget, pair additional) to MobileShellComposite, centralizes connection teardown, and updates the host picker UI to dismiss the pairing sheet only after a confirmed pairing.

Changes

Paired Mac Switching Operations

Layer / File(s) Summary
Paired Mac state and loading
Packages/CmuxMobileShell/.../MobileShellComposite.swift
pairedMacs property holds the current user's paired Macs; loadPairedMacs() fetches from pairedMacStore and discards results if the signed-in user changes mid-load.
Switch, forget, disconnect, and sign-out
Packages/CmuxMobileShell/.../MobileShellComposite.swift
switchToMac() connects via a reconnectable route and persists the active row only after verifying the live route; falls back to the previous active Mac if the switch drops it. forgetMac() disconnects and removes the stored row; disconnectLiveConnection() centralizes teardown; signOut() clears cached pairedMacs; disconnectAndForgetActiveMac() now uses the helper.
Additional Mac pairing (QR/link)
Packages/CmuxMobileShell/.../MobileShellComposite.swift
pairAdditionalMac(_:) attempts a destructive connect from a scanned URL, returns true only when connected, returns false on supersession/failure, reloads pairings on success, and may reconnect to the previously-active scoped Mac on failure.
Host picker UI pairing flow
Packages/CmuxMobileShellUI/.../MobileHostPickerView.swift
Scan-completion now calls store.pairAdditionalMac(code) and dismisses the "Pair Another Mac" sheet only when that call returns true; on failure it reloads paired Macs without dismissing.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • manaflow-ai/cmux#5513: Overlaps with initial multi-Mac host switcher and picker UI changes; similar paired Mac operations are present there.
  • manaflow-ai/cmux#5543: Modifies session lifecycle and reconnect handling in MobileShellComposite, likely touching related teardown and restore logic.

Poem

🐰 I hop through pairings, one, two, three,
Scanning QR codes to set Macs free,
I switch and forget with a gentle tap,
Reconnect on fallback — no trap, no gap,
A tiny rabbit cheers this smooth new spree.


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (2 errors, 1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Cmux Swift Logging ❌ Error MobileShellComposite.swift is a new file with @MainActor isolation that declares a file-scoped Logger as 'private let' instead of 'nonisolated private let', violating swift-logging.md rules. Change 'private let mobileShellLog' to 'nonisolated private let mobileShellLog' in MobileShellComposite.swift line 12.
Cmux Source Artifacts ❌ Error Commit adds local tool artifacts without proper .gitignore entries: .claude/scheduled_tasks.lock (runtime lock with session/PID) and .cursor/rules/main.mdc (IDE configuration). Add .claude/scheduled_tasks.lock and .cursor/ to .gitignore, or remove these local artifact files from the commit.
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The PR description is detailed with clear explanations of all three fixes, rationale, and testing verification, but lacks the standardized template structure required by the repository. Restructure the description to follow the template: add explicit Summary and Testing sections, include a demo video link or note if not applicable, and use the Review Trigger and Checklist sections provided.
✅ Passed checks (15 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main hardening improvements: session-drop prevention, user scoping, and package layering fixes for the multi-Mac host switcher.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed PR adds 4 async methods and property to @MainActor MobileShellComposite using Sendable value types. SwiftUI view accesses store from MainActor context. Existing @MainActor protocol debt not worsened.
Cmux Swift Blocking Runtime ✅ Passed Code uses proper async/await patterns throughout. No blocking primitives (Task.sleep, semaphores, locks, main.sync) or polling loops detected in the new multi-Mac switching implementation.
Cmux No Hacky Sleeps ✅ Passed PR is primarily Swift changes (out of scope). No hacky sleeps (sleep, setTimeout, setInterval, polling) found in TypeScript, JavaScript, or shell script changes.
Cmux Algorithmic Complexity ✅ Passed Operations on tiny bounded collection (pairedMacs ~2-10 items). No nested scans, batch rescans, or hot-path filtering detected. Satisfies tiny fixed-size collections pass condition.
Cmux Swift Concurrency ✅ Passed PR adds modern async/await functions without fire-and-forget Tasks, Dispatch queues, or Combine patterns. Removes 291 lines of legacy code while adding 74 lines of modern concurrency.
Cmux Swift @Concurrent ✅ Passed New async methods properly coordinate UI state while performing explicit hops to background actors for I/O, matching the allowed case of intentional UI-bound async functions.
Cmux Swift File And Package Boundaries ✅ Passed ~51 lines added to 2555-line MobileShellComposite.swift, below 250-line threshold. Focused bug fixes in clearly-separated section without unrelated responsibility mixing.
Cmux User-Facing Error Privacy ✅ Passed PR adds no user-facing error messages or alerts. New methods only contain internal OSLog statements (not user-visible). No connectionError assignments or sensitive data exposure detected.
Cmux Full Internationalization ✅ Passed All 8 user-facing strings use L10n.string() wrapper with String(localized:); all localization keys exist in catalog with en and ja translations. MobileShellComposite adds no user-facing text.
Cmux Swiftui State Layout ✅ Passed MobileShellComposite uses @Observable (modern pattern) with no legacy patterns. MobileHostPickerView uses @Bindable and passes immutable value snapshots to list rows, not store references.
Cmux Architecture Rethink ✅ Passed PR removes split-owner state patterns and adds scoped async operations with documented invariants. No timing delays, mutable flags, duplicate entrypoints, or split UI lifecycle introduced.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR adds state management and view methods only. No NSWindow, NSPanel, NSWindowController, SwiftUI Window, or WindowGroup code changes; sheets are explicitly allowed.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-ios-multi-mac-land

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 09e33687bb

ℹ️ 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".

/// one the live connection targets, switches on tap, forgets on swipe, and pairs
/// a new Mac by scanning its QR code without dropping the others.
struct MobileHostPickerView: View {
@Bindable var store: CMUXMobileShellStore

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid binding the store inside the host list

AGENTS.md's “Snapshot boundary for list subtrees” rule says any SwiftUI subtree containing a List/ForEach of rows must not hold an @Observable store reference, including @Bindable; this new picker keeps @Bindable var store while rendering List { ForEach(store.pairedMacs) ... }. When this sheet is open during unrelated shell/terminal updates, it reintroduces the list-row invalidation pattern the guideline calls out as causing LazyLayoutViewCache CPU spins; split this into a container that snapshots pairedMacs and passes closure actions into a value-only list/row view.

Useful? React with 👍 / 👎.

var rescanQR: (() -> Void)?
var signOut: (() -> Void)?
/// The shell store, forwarded to Settings to drive the multi-Mac switcher.
/// `nil` in previews.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the workspace list subtree store-free

This adds a plain CMUXMobileShellStore reference to WorkspaceListView, whose body owns the workspace List/ForEach; AGENTS.md's snapshot-boundary rule explicitly bans even a plain store property below list boundaries because orthogonal observable changes can invalidate every row and revive the prior LazyLayoutViewCache spin-loop class. Pass a narrow settings/host-picker action or a value snapshot instead of threading the observable shell store through the list view.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Jun 6, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This is a focused hardening follow-up to the iOS multi-Mac host switcher, addressing three issues found during autoreview: session-drop on a failed "Pair Another Mac" scan, a cross-user reconnect hazard in the failure fallback, and a claimed package boundary cleanup.

  • Adds pairAdditionalMac(_:) to MobileShellComposite, mirroring switchToMac's safety contract: pre-captures the scoped fallbackStackUserID before the destructive connectPairingURLResult call, reconnects the previously-active Mac only on the .failed path and only when a non-nil identity is available, and returns a Bool so the picker dismisses only on success.
  • Fixes MobileHostPickerView to call pairAdditionalMac and dismiss conditionally, preventing a stale/offline QR from silently tearing down a working session.
  • Fix Release v1.13.0 #3 (dropping the direct CmuxMobileShellUI → CmuxMobilePairedMac package edge) is described in the PR but not reflected in the diff: import CmuxMobilePairedMac remains in MobileHostPickerView.swift and Package.swift still lists CmuxMobilePairedMac as a direct target dependency.

Confidence Score: 5/5

Safe to merge — the two functional fixes (session-drop guard and scoped-identity fallback) are correct and well-structured; the package boundary claim in the description is incomplete but does not affect runtime behavior.

The core logic in pairAdditionalMac is sound: identity is captured before the destructive connect, the reconnect is gated on a non-nil scoped user, and the picker dismisses only on confirmed success. The remaining issues are a redundant loadPairedMacs() call on the failure path and an unapplied package boundary change — neither affects correctness or user-visible behavior.

Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileHostPickerView.swift — the direct CmuxMobilePairedMac import and Package.swift dependency were not removed as described, and the else-branch causes a redundant reload on scan failures.

Important Files Changed

Filename Overview
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Adds pairAdditionalMac which safely wraps connectPairingURLResult with pre-captured fallbackStackUserID and session-drop guard; also adds explanatory comment to the switchToMac route-equality check. Logic is sound: success path persists and reloads, superseded path is a no-op, failed path reconnects to the previously-active Mac only when a scoped identity is available.
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileHostPickerView.swift Replaces the unconditional dismiss-and-discard pattern with a conditional dismiss on pairAdditionalMac returning true. Two issues: the import CmuxMobilePairedMac direct edge claimed to be dropped still remains (Package.swift dependency also unchanged), and the else branch causes a redundant loadPairedMacs() on the .failed path since that case already loads inside pairAdditionalMac.

Sequence Diagram

sequenceDiagram
    participant View as MobileHostPickerView
    participant Store as MobileShellComposite
    participant PairingURL as connectPairingURLResult
    participant MacStore as pairedMacStore

    View->>Store: pairAdditionalMac(code)
    Note over Store: capture hadLiveConnection<br/>capture fallbackStackUserID
    Store->>PairingURL: await connectPairingURLResult(rawValue)
    alt .connected
        PairingURL-->>Store: .connected
        Store->>MacStore: loadPairedMacs()
        Store-->>View: true
        View->>View: dismiss()
    else .superseded
        PairingURL-->>Store: .superseded
        Store-->>View: false
        View->>MacStore: loadPairedMacs()
    else .failed
        PairingURL-->>Store: .failed
        alt "hadLiveConnection && fallbackStackUserID != nil"
            Store->>MacStore: reconnectActiveMacIfAvailable(stackUserID:)
        end
        Store->>MacStore: loadPairedMacs()
        Store-->>View: false
        View->>MacStore: loadPairedMacs() ⚠️ redundant on .failed
    end
Loading

Reviews (5): Last reviewed commit: "Fix iOS host picker compile: restore Cmu..." | Re-trigger Greptile

Comment on lines +206 to +212
try exec("""
UPDATE paired_macs SET is_active = 0
WHERE stack_user_id IS (
SELECT stack_user_id FROM paired_macs WHERE mac_device_id = ?
);
""",
binding: [.text(macDeviceID)])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 NULL-subquery scope clears unrelated rows on deleted mac

When mac_device_id no longer exists in the table (e.g., the user swipes to forget that exact Mac while switchToMac is suspended in await connectManualHost, then the successful-connect branch calls setActive), the scalar subquery returns NULL and WHERE stack_user_id IS NULL silently wipes the is_active flag for every row whose stack_user_id is NULL — including rows that belong to completely unrelated NULL-scoped users. The subsequent SET is_active = 1 then also finds no rows, so no Mac ends up active. Because MobileShellComposite is @MainActor async, a forgetMac task from a concurrent swipe action CAN interleave at the await connectManualHost suspension point, making this sequence reachable in production. Adding an EXISTS guard (or pre-fetching the stack_user_id before entering the transaction) prevents the NULL propagation.

Comment on lines +61 to +68
MobilePairingScannerSheet { code in
showingScanner = false
Task {
_ = await store.connectPairingURL(code)
await store.loadPairedMacs()
dismiss()
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Pairing error silently discarded; view unconditionally dismisses

connectPairingURL is called and its result is discarded with _, then dismiss() is called regardless of success or failure. If the QR code is stale or the network is down, the user is returned to Settings with no error shown — the host picker and scanner are both gone, and any connectionError set on the store is only visible once the user navigates back to the workspace view. The existing direct-scan pairing flow surfaces errors inline; this secondary path in the picker breaks that expectation.

Comment on lines +559 to +593
public func switchToMac(macDeviceID: String) async {
guard let pairedMacStore,
let target = pairedMacs.first(where: { $0.macDeviceID == macDeviceID }) else { return }
if target.isActive, connectionState == .connected { return }
// The currently-active Mac to fall back to if the switch fails.
let previousActive = pairedMacs.first { $0.isActive && $0.macDeviceID != macDeviceID }
let supportedKinds = runtime?.supportedRouteKinds ?? []
guard let (host, port) = Self.firstReconnectHostPortRoute(
target.routes,
supportedKinds: supportedKinds
), let normalizedHost = MobileShellRouteAuthPolicy.normalizedManualHost(host) else {
mobileShellLog.error("switchToMac: no reconnectable route mac=\(macDeviceID, privacy: .public)")
return
}
await connectManualHost(name: target.displayName ?? host, host: host, port: port)
// Persist the active row only if the live connection is to THIS Mac's
// route. A different switch tapped while this connect was in flight
// supersedes it via `beginPairingAttempt`, leaving `connectionState`
// `.connected` for the other Mac; matching the live route prevents this
// superseded task from persisting a stale active target.
if connectionState == .connected,
case let .hostPort(liveHost, livePort)? = activeRoute?.endpoint,
liveHost == normalizedHost, livePort == port {
do {
try await pairedMacStore.setActive(macDeviceID: macDeviceID)
} catch {
mobileShellLog.error("paired mac store setActive failed mac=\(macDeviceID, privacy: .public) error=\(String(describing: error), privacy: .public)")
}
} else if previousActive != nil, connectionState != .connected {
// The switch did not connect and the destructive connect path dropped
// the previous session; reconnect to the still-active previous Mac so
// the user is not left stranded on a failed switch.
_ = await reconnectActiveMacIfAvailable(stackUserID: identityProvider?.currentUserID)
}
await loadPairedMacs()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 switchToMac provides no in-progress feedback to the view layer

connectManualHost is a destructive async connect that can take several seconds. During the whole window the view shows the old checkmark (the isActive flag on pairedMacs is stale in memory) and no spinner or disabled row state. A user who taps a Mac, waits, and sees nothing may tap it again, triggering a second superseding task. The existing connection-state properties (connectionState, macConnectionStatus) could be surfaced in the row to indicate a switch is in flight; alternatively exposing a Bool flag such as isSwitchingMac that is set before the connect and cleared in loadPairedMacs() would be enough for the view to disable the row or show a progress indicator.

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!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 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/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift`:
- Around line 206-214: The two UPDATEs mutate all rows with stack_user_id IS
NULL when macDeviceID is missing; fix by first resolving the target
stack_user_id and aborting if none found: run a SELECT stack_user_id FROM
paired_macs WHERE mac_device_id = ? (use the same macDeviceID) and if it returns
nil do not execute the UPDATEs; otherwise use that resolved stackUserID in the
exec calls (e.g. UPDATE paired_macs SET is_active = 0 WHERE stack_user_id = ?
and UPDATE paired_macs SET is_active = 1 WHERE mac_device_id = ?) to avoid the
NULL-scoped deactivation. Ensure you reference the same macDeviceID and the
exec(...) call sites when making this change.
🪄 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: 93d4c233-bb9f-4c3b-a7bd-e8030d7fbd80

📥 Commits

Reviewing files that changed from the base of the PR and between 81aa55a and 6dabe0f.

📒 Files selected for processing (9)
  • Packages/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift
  • Packages/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacStoreTests.swift
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShellUI/Package.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileHostPickerView.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift
  • ios/cmux/Resources/Localizable.xcstrings

Comment on lines +206 to 214
try exec("""
UPDATE paired_macs SET is_active = 0
WHERE stack_user_id IS (
SELECT stack_user_id FROM paired_macs WHERE mac_device_id = ?
);
""",
binding: [.text(macDeviceID)])
try exec("UPDATE paired_macs SET is_active = 1 WHERE mac_device_id = ?;",
binding: [.text(macDeviceID)])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent unintended NULL-scope deactivation when the target Mac row is missing.

If mac_device_id is absent, the subquery at Line 209 yields NULL, so Line 208 clears every stack_user_id IS NULL row, then Line 213 updates nothing. That mutates unrelated scope state on an invalid ID.

💡 Suggested fix
-            try exec("""
-                UPDATE paired_macs SET is_active = 0
-                WHERE stack_user_id IS (
-                    SELECT stack_user_id FROM paired_macs WHERE mac_device_id = ?
-                );
-                """,
-                binding: [.text(macDeviceID)])
+            try exec("""
+                UPDATE paired_macs
+                SET is_active = 0
+                WHERE EXISTS (
+                    SELECT 1 FROM paired_macs target WHERE target.mac_device_id = ?
+                )
+                AND stack_user_id IS (
+                    SELECT target.stack_user_id
+                    FROM paired_macs target
+                    WHERE target.mac_device_id = ?
+                );
+                """,
+                binding: [.text(macDeviceID), .text(macDeviceID)])
🤖 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/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift`
around lines 206 - 214, The two UPDATEs mutate all rows with stack_user_id IS
NULL when macDeviceID is missing; fix by first resolving the target
stack_user_id and aborting if none found: run a SELECT stack_user_id FROM
paired_macs WHERE mac_device_id = ? (use the same macDeviceID) and if it returns
nil do not execute the UPDATEs; otherwise use that resolved stackUserID in the
exec calls (e.g. UPDATE paired_macs SET is_active = 0 WHERE stack_user_id = ?
and UPDATE paired_macs SET is_active = 1 WHERE mac_device_id = ?) to avoid the
NULL-scoped deactivation. Ensure you reference the same macDeviceID and the
exec(...) call sites when making this change.

Comment on lines +564 to +565
let previousActive = pairedMacs.first { $0.isActive && $0.macDeviceID != macDeviceID }
let supportedKinds = runtime?.supportedRouteKinds ?? []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 switchToMac reads identityProvider?.currentUserID after the await connectManualHost suspension point, unlike pairAdditionalMac which explicitly pre-captures fallbackStackUserID for the same reason. If a sign-out races during connectManualHost, currentUserID can be nil or belong to a different user, causing reconnectActiveMacIfAvailable(stackUserID: nil) to use the all-users store query — the exact cross-user reconnect hazard the pairAdditionalMac comment describes as unsafe on shared devices.

Suggested change
let previousActive = pairedMacs.first { $0.isActive && $0.macDeviceID != macDeviceID }
let supportedKinds = runtime?.supportedRouteKinds ?? []
let previousActive = pairedMacs.first { $0.isActive && $0.macDeviceID != macDeviceID }
// Capture before the destructive connect for the same reason pairAdditionalMac
// does: if a sign-out races during the await, currentUserID becomes nil and
// reconnectActiveMacIfAvailable(stackUserID: nil) would use the all-users query,
// potentially reconnecting another Stack user's Mac on a shared device.
let fallbackStackUserID = identityProvider?.currentUserID
let supportedKinds = runtime?.supportedRouteKinds ?? []

lawrencecchen and others added 3 commits June 6, 2026 05:43
… Mac

The host picker's "Pair Another Mac" called connectPairingURL directly and
ignored the result, dismissing unconditionally. That connect path is
destructive (it begins a fresh pairing attempt and replaces the live remote
client), so scanning a stale/expired/offline QR could tear down a working
session with no fallback.

Add MobileShellComposite.pairAdditionalMac, mirroring switchToMac's safety:
capture whether a live connection exists, attempt the pairing connect, and on
failure reconnect the still-active stored Mac so the user is not stranded.
persistPairedMacFromTicket runs only on the success path, so the store's active
row still points at the previous Mac on failure and the reconnect targets it,
not the broken new one. The picker now dismisses only on a successful connect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ent Stack user

pairAdditionalMac's failure fallback passed identityProvider?.currentUserID
straight into reconnectActiveMacIfAvailable. When that is nil, the store's
activeMac(stackUserID: nil) is an all-users query, so on a shared iOS device a
failed "Pair Another Mac" scan could reconnect to another Stack user's active
Mac, violating the same scoping guarantee loadPairedMacs enforces.

Capture the scoped Stack user id before the destructive connect and only
reconnect within that scope; skip the fallback entirely when there is no scoped
identity rather than fall into the unscoped lookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…route-equality

P2 (layering): CmuxMobileShellUI depended directly on the CmuxMobilePairedMac
service package only to name MobilePairedMac in the picker. CmuxMobileShell
(the domain package the UI already depends on) `public import`s
CmuxMobilePairedMac and exposes `pairedMacs: [MobilePairedMac]`, so the type is
re-exported through the allowed domain->service edge. Name it through
`import CmuxMobileShell` and drop the direct UI->service dependency + import to
keep the package DAG within the UI-depends-on-domain rule.

P1 (switchToMac active-row): documented as reject-with-evidence. connectManualHost
mints a synthetic `manual-<host>:<port>` ticket id and persist skips `manual-`
ids, so there is no real Mac id to reconcile against; route-equality is the best
available signal. A reassigned host:port is an unhandleable manual-reconnect
limitation shared with reconnectActiveMacIfAvailable, not a switch-specific
regression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@lawrencecchen
lawrencecchen force-pushed the feat-ios-multi-mac-land branch from 946c699 to fea2abc Compare June 6, 2026 12:43
@lawrencecchen lawrencecchen changed the title iOS: multi-Mac host switcher (landable) iOS: harden multi-Mac host switcher (session-drop, scoping, layering) Jun 6, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fea2abc928

ℹ️ 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".

"CmuxMobileCamera",
"CmuxMobileDiagnostics",
"CmuxMobilePairedMac",
"CmuxMobileShell",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the paired Mac module in scope

With CmuxMobilePairedMac removed from this target's dependencies/imports, MobileHostPickerView still names MobilePairedMac directly in macRow(_:), so the iOS UI target no longer has that type in scope. public import CmuxMobilePairedMac inside CmuxMobileShell is not enough to make the type name available to clients that only import CmuxMobileShell; this will fail to type-check when the iOS sources are built. Please either keep the direct package dependency/import or expose a type/adapter from CmuxMobileShell that the UI can name.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift (2)

237-259: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset and refresh the recovery user scope.

recoverMobileConnection() later reuses lastReconnectStackUserID, but signOut() leaves the old value behind and these successful switch/pair paths never overwrite it. On a shared device, a later drop can auto-reconnect the previous user's active Mac.

Suggested fix
 public func signOut() {
     pairingAttemptID = UUID()
     connectionGeneration = UUID()
+    lastReconnectStackUserID = nil
     isSignedIn = false
     connectionState = .disconnected
     macConnectionStatus = .unavailable
// In the shared "connect succeeded" path, refresh the recovery scope too.
lastReconnectStackUserID = identityProvider?.currentUserID

Also applies to: 648-662, 707-709

🤖 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 237 - 259, signOut() currently resets many session fields but does
not clear/update lastReconnectStackUserID, which allows
recoverMobileConnection() to reuse the previous user's recovery scope; update
signOut() to set lastReconnectStackUserID = identityProvider?.currentUserID (or
nil if appropriate) and also ensure the shared "connect succeeded" path
refreshes lastReconnectStackUserID after a successful connection (where connect
success logic runs) so the recovery scope always reflects the
currently-signed-in user; reference the signOut() method,
lastReconnectStackUserID, recoverMobileConnection(), and
identityProvider?.currentUserID when making these changes.

845-855: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't forget the active Mac by transient ticket id.

After connectManualHost/switchToMac, activeTicket.macDeviceID is often the synthetic manual-<host>:<port> value, not the persisted paired-Mac row. This removal can miss the real active record, so “Rescan QR” leaves that Mac active in storage and it can reconnect on the next launch.

Suggested fix
 public func disconnectAndForgetActiveMac() {
-    let staleMacID = activeTicket?.macDeviceID
+    let stackUserID = identityProvider?.currentUserID
     disconnectLiveConnection()
-    if let pairedMacStore, let macID = staleMacID {
+    if let pairedMacStore {
         // Fire-and-forget: forgetting the persisted mac is cleanup that must
         // not block the synchronous disconnect UI state update above.
         Task {
             do {
-                try await pairedMacStore.remove(macDeviceID: macID)
+                if let macID = try await pairedMacStore.activeMac(stackUserID: stackUserID)?.macDeviceID {
+                    try await pairedMacStore.remove(macDeviceID: macID)
+                }
             } catch {
                 mobileShellLog.error("forgetActiveMac removal failed: \(String(describing: error), privacy: .private)")
             }
         }
     }
🤖 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 845 - 855, disconnectAndForgetActiveMac currently removes by
activeTicket.macDeviceID which may be a synthetic "manual-<host>:<port>" id and
thus won't delete the persisted paired row; instead, detect synthetic manual ids
(e.g. prefix "manual-") or when pairedMacStore.remove by that id fails, resolve
the real persisted mac id from the store (lookup by host/port or other matching
fields on activeTicket) and remove that real id; update
disconnectAndForgetActiveMac to: (1) extract staleMacID =
activeTicket?.macDeviceID, (2) if staleMacID starts with "manual-" (or removal
returns not found), call pairedMacStore to find the persisted paired record that
matches activeTicket's host/port/unique connection info, get its macDeviceID,
and call pairedMacStore.remove(macDeviceID: realMacID); otherwise fall back to
the existing remove(staleMacID) behavior.
🤖 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 596-600: The scoped loadAll error path leaves stale pairedMacs in
memory; when pairedMacStore.loadAll(stackUserID: ...) throws, clear the cached
host list before returning by resetting the pairedMacs collection (or equivalent
storage used by MobileShellComposite) to an empty state so the UI won't show
another user's Macs; update the catch block around pairedMacStore.loadAll to set
pairedMacs = [] (or call the existing clear method) then log the error and
return.

In
`@Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileHostPickerView.swift`:
- Around line 65-69: The else branch unconditionally calls await
store.loadPairedMacs(), which duplicates reload logic and breaks
pairAdditionalMac(code)'s supersession semantics; remove the else { await
store.loadPairedMacs() } block and let store.pairAdditionalMac(code) handle
reloads and supersession behavior (keep dismiss() on true as-is).

---

Outside diff comments:
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 237-259: signOut() currently resets many session fields but does
not clear/update lastReconnectStackUserID, which allows
recoverMobileConnection() to reuse the previous user's recovery scope; update
signOut() to set lastReconnectStackUserID = identityProvider?.currentUserID (or
nil if appropriate) and also ensure the shared "connect succeeded" path
refreshes lastReconnectStackUserID after a successful connection (where connect
success logic runs) so the recovery scope always reflects the
currently-signed-in user; reference the signOut() method,
lastReconnectStackUserID, recoverMobileConnection(), and
identityProvider?.currentUserID when making these changes.
- Around line 845-855: disconnectAndForgetActiveMac currently removes by
activeTicket.macDeviceID which may be a synthetic "manual-<host>:<port>" id and
thus won't delete the persisted paired row; instead, detect synthetic manual ids
(e.g. prefix "manual-") or when pairedMacStore.remove by that id fails, resolve
the real persisted mac id from the store (lookup by host/port or other matching
fields on activeTicket) and remove that real id; update
disconnectAndForgetActiveMac to: (1) extract staleMacID =
activeTicket?.macDeviceID, (2) if staleMacID starts with "manual-" (or removal
returns not found), call pairedMacStore to find the persisted paired record that
matches activeTicket's host/port/unique connection info, get its macDeviceID,
and call pairedMacStore.remove(macDeviceID: realMacID); otherwise fall back to
the existing remove(staleMacID) behavior.
🪄 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: 20977a72-e648-493c-9537-2755ebae35d5

📥 Commits

Reviewing files that changed from the base of the PR and between 946c699 and fea2abc.

📒 Files selected for processing (2)
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileHostPickerView.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift (2)

237-259: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset and refresh the recovery user scope.

recoverMobileConnection() later reuses lastReconnectStackUserID, but signOut() leaves the old value behind and these successful switch/pair paths never overwrite it. On a shared device, a later drop can auto-reconnect the previous user's active Mac.

Suggested fix
 public func signOut() {
     pairingAttemptID = UUID()
     connectionGeneration = UUID()
+    lastReconnectStackUserID = nil
     isSignedIn = false
     connectionState = .disconnected
     macConnectionStatus = .unavailable
// In the shared "connect succeeded" path, refresh the recovery scope too.
lastReconnectStackUserID = identityProvider?.currentUserID

Also applies to: 648-662, 707-709

🤖 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 237 - 259, signOut() currently resets many session fields but does
not clear/update lastReconnectStackUserID, which allows
recoverMobileConnection() to reuse the previous user's recovery scope; update
signOut() to set lastReconnectStackUserID = identityProvider?.currentUserID (or
nil if appropriate) and also ensure the shared "connect succeeded" path
refreshes lastReconnectStackUserID after a successful connection (where connect
success logic runs) so the recovery scope always reflects the
currently-signed-in user; reference the signOut() method,
lastReconnectStackUserID, recoverMobileConnection(), and
identityProvider?.currentUserID when making these changes.

845-855: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't forget the active Mac by transient ticket id.

After connectManualHost/switchToMac, activeTicket.macDeviceID is often the synthetic manual-<host>:<port> value, not the persisted paired-Mac row. This removal can miss the real active record, so “Rescan QR” leaves that Mac active in storage and it can reconnect on the next launch.

Suggested fix
 public func disconnectAndForgetActiveMac() {
-    let staleMacID = activeTicket?.macDeviceID
+    let stackUserID = identityProvider?.currentUserID
     disconnectLiveConnection()
-    if let pairedMacStore, let macID = staleMacID {
+    if let pairedMacStore {
         // Fire-and-forget: forgetting the persisted mac is cleanup that must
         // not block the synchronous disconnect UI state update above.
         Task {
             do {
-                try await pairedMacStore.remove(macDeviceID: macID)
+                if let macID = try await pairedMacStore.activeMac(stackUserID: stackUserID)?.macDeviceID {
+                    try await pairedMacStore.remove(macDeviceID: macID)
+                }
             } catch {
                 mobileShellLog.error("forgetActiveMac removal failed: \(String(describing: error), privacy: .private)")
             }
         }
     }
🤖 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 845 - 855, disconnectAndForgetActiveMac currently removes by
activeTicket.macDeviceID which may be a synthetic "manual-<host>:<port>" id and
thus won't delete the persisted paired row; instead, detect synthetic manual ids
(e.g. prefix "manual-") or when pairedMacStore.remove by that id fails, resolve
the real persisted mac id from the store (lookup by host/port or other matching
fields on activeTicket) and remove that real id; update
disconnectAndForgetActiveMac to: (1) extract staleMacID =
activeTicket?.macDeviceID, (2) if staleMacID starts with "manual-" (or removal
returns not found), call pairedMacStore to find the persisted paired record that
matches activeTicket's host/port/unique connection info, get its macDeviceID,
and call pairedMacStore.remove(macDeviceID: realMacID); otherwise fall back to
the existing remove(staleMacID) behavior.
🤖 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 596-600: The scoped loadAll error path leaves stale pairedMacs in
memory; when pairedMacStore.loadAll(stackUserID: ...) throws, clear the cached
host list before returning by resetting the pairedMacs collection (or equivalent
storage used by MobileShellComposite) to an empty state so the UI won't show
another user's Macs; update the catch block around pairedMacStore.loadAll to set
pairedMacs = [] (or call the existing clear method) then log the error and
return.

In
`@Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileHostPickerView.swift`:
- Around line 65-69: The else branch unconditionally calls await
store.loadPairedMacs(), which duplicates reload logic and breaks
pairAdditionalMac(code)'s supersession semantics; remove the else { await
store.loadPairedMacs() } block and let store.pairAdditionalMac(code) handle
reloads and supersession behavior (keep dismiss() on true as-is).

---

Outside diff comments:
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 237-259: signOut() currently resets many session fields but does
not clear/update lastReconnectStackUserID, which allows
recoverMobileConnection() to reuse the previous user's recovery scope; update
signOut() to set lastReconnectStackUserID = identityProvider?.currentUserID (or
nil if appropriate) and also ensure the shared "connect succeeded" path
refreshes lastReconnectStackUserID after a successful connection (where connect
success logic runs) so the recovery scope always reflects the
currently-signed-in user; reference the signOut() method,
lastReconnectStackUserID, recoverMobileConnection(), and
identityProvider?.currentUserID when making these changes.
- Around line 845-855: disconnectAndForgetActiveMac currently removes by
activeTicket.macDeviceID which may be a synthetic "manual-<host>:<port>" id and
thus won't delete the persisted paired row; instead, detect synthetic manual ids
(e.g. prefix "manual-") or when pairedMacStore.remove by that id fails, resolve
the real persisted mac id from the store (lookup by host/port or other matching
fields on activeTicket) and remove that real id; update
disconnectAndForgetActiveMac to: (1) extract staleMacID =
activeTicket?.macDeviceID, (2) if staleMacID starts with "manual-" (or removal
returns not found), call pairedMacStore to find the persisted paired record that
matches activeTicket's host/port/unique connection info, get its macDeviceID,
and call pairedMacStore.remove(macDeviceID: realMacID); otherwise fall back to
the existing remove(staleMacID) behavior.
🪄 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: 20977a72-e648-493c-9537-2755ebae35d5

📥 Commits

Reviewing files that changed from the base of the PR and between 946c699 and fea2abc.

📒 Files selected for processing (2)
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileHostPickerView.swift
🛑 Comments failed to post (2)
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift (1)

596-600: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear the cached host list when the scoped load fails.

This error path leaves the previous in-memory pairedMacs intact. If user A's list is still cached and the scoped load for user B fails, the picker keeps rendering A's Macs on a shared device.

Suggested fix
         do {
             loaded = try await pairedMacStore.loadAll(stackUserID: stackUserID)
         } catch {
             mobileShellLog.error("paired mac store loadAll failed: \(String(describing: error), privacy: .public)")
+            pairedMacs = []
             return
         }
📝 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.

        do {
            loaded = try await pairedMacStore.loadAll(stackUserID: stackUserID)
        } catch {
            mobileShellLog.error("paired mac store loadAll failed: \(String(describing: error), privacy: .public)")
            pairedMacs = []
            return
        }
🤖 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 596 - 600, The scoped loadAll error path leaves stale pairedMacs in
memory; when pairedMacStore.loadAll(stackUserID: ...) throws, clear the cached
host list before returning by resetting the pairedMacs collection (or equivalent
storage used by MobileShellComposite) to an empty state so the UI won't show
another user's Macs; update the catch block around pairedMacStore.loadAll to set
pairedMacs = [] (or call the existing clear method) then log the error and
return.
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileHostPickerView.swift (1)

65-69: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Respect pairAdditionalMac supersession semantics in the false branch.

pairAdditionalMac already reloads pairings on failure and intentionally avoids reloading when superseded. The unconditional await store.loadPairedMacs() on false reintroduces redundant refreshes and can interfere with the takeover path that superseded this attempt.

Suggested fix
-                        if await store.pairAdditionalMac(code) {
-                            dismiss()
-                        } else {
-                            await store.loadPairedMacs()
-                        }
+                        if await store.pairAdditionalMac(code) {
+                            dismiss()
+                        }
🤖 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/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileHostPickerView.swift`
around lines 65 - 69, The else branch unconditionally calls await
store.loadPairedMacs(), which duplicates reload logic and breaks
pairAdditionalMac(code)'s supersession semantics; remove the else { await
store.loadPairedMacs() } block and let store.pairAdditionalMac(code) handle
reloads and supersession behavior (keep dismiss() on true as-is).

…edge

fea2abc dropped both `import CmuxMobilePairedMac` from MobileHostPickerView
and the CmuxMobilePairedMac product dependency from CmuxMobileShellUI's
Package.swift, betting that CmuxMobileShell's `public import CmuxMobilePairedMac`
re-export would make the bare `MobilePairedMac` type resolvable through
`import CmuxMobileShell`. It does not: Swift does not transitively re-export a
named type into a module that only `import`s the re-exporter, so the iOS build
failed with `cannot find type 'MobilePairedMac' in scope` at
MobileHostPickerView.swift:78 (red ios-simulator CI).

Revert only the package-edge part of fea2abc: re-add the
`CmuxMobilePairedMac` package reference + target dependency and the
`import CmuxMobilePairedMac`. The P1 session-drop/scoping fixes
(pairAdditionalMac, switchToMac route-equality docs) are untouched.

Verified by building the CmuxMobileShellUI scheme for the iOS Simulator
(arm64+x86_64): BUILD SUCCEEDED, so MobilePairedMac resolves at line 78.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit bf35664. Configure here.

return true
case .superseded:
// Another pairing/switch attempt took over; leave its state intact.
return false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Superseded pairing skips session reconnect

Medium Severity

A second “Pair Another Mac” attempt that supersedes an in-flight first scan can leave the user disconnected. hadLiveConnection is captured per call, so after the first attempt’s destructive connect clears the session the second may record no live session and skip reconnect on failure, while the superseded first attempt returns without running its .failed reconnect path.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bf35664. Configure here.

lawrencecchen added a commit that referenced this pull request Jun 11, 2026
Reset to origin/main (composer #5876 landed), then merge old dog HEAD
a300868 to preserve every feature not yet on main: notifications
dismiss-sync (#5568), multi-Mac switcher hardening (#5545), foreground
repaint (#5571), image paste too-large toast (#5572), hidden native
input (#5596), workspace groups (#5625), wslist round-10 snapshot,
scroll-to-bottom hysteresis, DEV dogfood pane, attachments button,
arrow toolbar keys, terminal.paste capability gating.

Conflict policy: main's reviewed composer-land form wins for composer
core (keyed focus handshake, draft FIFO coalescing, paste submit
partial-success), dog wins for unlanded feature surface. ghostty pinned
to dog 34cbf18 (descendant of main's e5c962a).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jun 12, 2026
Carries unlanded: multi-Mac #5545, notif-sync #5568, foreground-repaint #5571,
image-paste toast #5572, hidden-input #5596, groups (iOS side) #5625,
scroll-hysteresis, dogfood pane, capabilities superset.
Main's reviewed forms win: TerminalController decomposition (Control*Context),
notif-tap-deeplink #5927, mobile.terminal.* routing.
lawrencecchen added a commit that referenced this pull request Jun 13, 2026
…#5596/#5625/#5628) over current main

Beta queue (#5876/#5872/#5869/#5875/#5927/#5912/#5726/#5776/#5916) is now on
main; conflicts resolved by taking main as authoritative for the merged
workspace-list/notifications/read-state/close surface, while preserving the
carry-set: terminal.paste capability (#5572), hidden-input strings (#5596),
smooth-scroll/scroll-to-bottom (#5628), and the live notifications feed
(notificationsStore + mobile.notifications.list/mark_read dispatch). Dropped
the superseded mute design. Capability flags unified onto main's computed
supportedHostCapabilities set (added computed supportsTerminalPaste +
DEBUG supportsDogfoodChecklist). xcstrings merged (HEAD-precedence union,
mute keys dropped). pbxproj took HEAD consistently; budget regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jun 15, 2026
Reconcile pairAdditionalMac with main's .needsUserApproval pairing path:
host picker scan now uses the session-preserving pairAdditionalMac and
defers version-skew approval to the compatibility alert.
@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026

This branch was successfully deployed

1 active deployment
Preview – cmux — bf356643 Deployed Jun 7, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants