Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
// 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.
//
// Route equality is the only reliable signal here: `connectManualHost`
// mints a synthetic `manual-<host>:<port>` ticket id (see
// `manualHostTicket`), so `activeTicket?.macDeviceID` cannot reconcile
// against the real stored Mac id. A host:port that has been reassigned to
// a different Mac is an unhandleable manual-reconnect limitation shared
// with `reconnectActiveMacIfAvailable`, not specific to switching.
if connectionState == .connected,
case let .hostPort(liveHost, livePort)? = activeRoute?.endpoint,
liveHost == normalizedHost, livePort == port {
Expand Down Expand Up @@ -673,6 +680,50 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
await loadPairedMacs()
}

/// Pair another Mac from a scanned QR/link without stranding the current
/// session, for the "Pair Another Mac" action in the host switcher.
///
/// ``connectPairingURL(_:)`` is destructive: it begins a fresh pairing
/// attempt that replaces/clears the live remote client during connect, so a
/// stale, expired, or offline code would otherwise tear down a working
/// session. Mirroring ``switchToMac(macDeviceID:)``, if there was a live
/// connection that failed to move to the new Mac, the previously-active Mac
/// is reconnected so the user is not dropped on a bad scan. The host picker
/// should dismiss only when this returns `true`.
/// - Parameter rawValue: The scanned pairing URL/code.
/// - Returns: `true` only when the new Mac connected; `false` on a failed or
/// superseded attempt (the picker stays open).
@discardableResult
public func pairAdditionalMac(_ rawValue: String) async -> Bool {
// The session to fall back to if the new pairing fails to connect.
let hadLiveConnection = connectionState == .connected
// Capture the scoped Stack user id *before* the destructive connect. The
// failure fallback must reconnect only within this user's pairings, never
// via `activeMac(stackUserID: nil)`, which is the store's all-users query
// and could reconnect another Stack user's Mac on a shared device.
let fallbackStackUserID = identityProvider?.currentUserID
let result = await connectPairingURLResult(rawValue)
switch result {
case .connected:
await loadPairedMacs()
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.

case .failed:
// The destructive connect path dropped the previous session; if we
// had one and still have a scoped identity, reconnect the still-active
// stored Mac so the user is not left disconnected after a bad scan. No
// scoped identity means no safe reconnect target, so we skip it rather
// than fall into the unscoped all-users lookup.
if hadLiveConnection, let fallbackStackUserID {
_ = await reconnectActiveMacIfAvailable(stackUserID: fallbackStackUserID)
}
await loadPairedMacs()
return false
}
}

static func firstReconnectHostPortRoute(
_ routes: [CmxAttachRoute],
supportedKinds: [CmxAttachTransportKind]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,13 @@ struct MobileHostPickerView: View {
MobilePairingScannerSheet { code in
showingScanner = false
Task {
_ = await store.connectPairingURL(code)
await store.loadPairedMacs()
dismiss()
// Pair without dropping the current session on a bad scan:
// dismiss only when the new Mac actually connected.
if await store.pairAdditionalMac(code) {
dismiss()
} else {
await store.loadPairedMacs()
}
}
}
Comment on lines +61 to +72

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.

}
Expand Down
Loading