Skip to content

Commit 790571e

Browse files
lawrencecchenclaude
andcommitted
Only auto-restore the previous Mac after a cancelled add-device attempt
Review follow-up: restoring via switchToMac begins a fresh pairing attempt, which clears connectionError and erased the failure reason the auto-presented pairing sheet was showing. Restore now requires the attempt to have ended disconnected with no connection error, which is exactly the store's cancellation path (every real failure sets one). Failed attempts keep the error visible for an in-place retry; reconnecting the previous Mac without losing the error needs a store-owned error channel that survives reconnects (follow-up). Local input validation in PairingView and the scanner's pairing-link filter keep malformed input from reaching the destructive path at all. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 21efbf9 commit 790571e

3 files changed

Lines changed: 55 additions & 24 deletions

File tree

Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/DeviceTreeAddDevicePolicy.swift

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,28 @@ public struct DeviceTreeAddDevicePolicy: Sendable {
3535
/// Whether a completed add-device attempt should reconnect the Mac that
3636
/// was live when the attempt started.
3737
///
38-
/// The underlying pairing path is destructive: a failed (or cancelled)
39-
/// attempt leaves the shell `.disconnected` even when a healthy connection
40-
/// existed before it began. When the attempt started over a live
41-
/// connection (`previousMacDeviceID` non-nil) and ended disconnected,
42-
/// restore that Mac via `switchToMac` instead of stranding the user —
43-
/// the same "never strand the user" contract `switchToMac` documents for
44-
/// its own failure path. A successful attempt is connected (to the new
45-
/// device), so no restore happens.
38+
/// The underlying pairing path is destructive: a cancelled attempt leaves
39+
/// the shell `.disconnected` even when a healthy connection existed before
40+
/// it began. When the attempt started over a live connection
41+
/// (`previousMacDeviceID` non-nil), ended disconnected, and reported no
42+
/// connection error (the store's cancellation path sets none, while every
43+
/// real failure does), restore that Mac via `switchToMac` instead of
44+
/// stranding the user — the same "never strand the user" contract
45+
/// `switchToMac` documents for its own failure path.
46+
///
47+
/// A failed attempt (`hasConnectionError`) deliberately does NOT restore:
48+
/// the reconnect path begins a fresh pairing attempt, which clears
49+
/// `connectionError` and would erase the actionable failure reason while
50+
/// the user is reading it. The disconnected shell auto-presents the
51+
/// pairing sheet with that error for an in-place retry; reconnecting the
52+
/// previous Mac without losing the error needs a store-owned error
53+
/// channel that survives reconnects (follow-up). A successful attempt is
54+
/// connected (to the new device), so no restore happens either.
4655
public func restoresPreviousConnection(
4756
connectionState: MobileConnectionState,
48-
previousMacDeviceID: String?
57+
previousMacDeviceID: String?,
58+
hasConnectionError: Bool
4959
) -> Bool {
50-
connectionState != .connected && previousMacDeviceID != nil
60+
connectionState != .connected && previousMacDeviceID != nil && !hasConnectionError
5161
}
5262
}

Packages/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/DeviceTreeAddDevicePolicyTests.swift

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,27 @@ import Testing
3232
#expect(!policy.dismissesAfterPairingAttempt(connectionState: .disconnected))
3333
}
3434

35-
/// A failed or cancelled attempt that started over a live connection
35+
/// A cancelled attempt (disconnected, no connection error — the store's
36+
/// cancellation path sets none) that started over a live connection
3637
/// reconnects that Mac: the pairing path is destructive, and the user must
37-
/// not lose a working session to a bad QR code or a cancelled attempt.
38-
@Test func failedAttemptOverLiveConnectionRestoresIt() {
38+
/// not lose a working session just by backing out of the sheet.
39+
@Test func cancelledAttemptOverLiveConnectionRestoresIt() {
3940
#expect(policy.restoresPreviousConnection(
4041
connectionState: .disconnected,
41-
previousMacDeviceID: "mac-1"
42+
previousMacDeviceID: "mac-1",
43+
hasConnectionError: false
44+
))
45+
}
46+
47+
/// A failed attempt (connection error set) must NOT auto-restore: the
48+
/// reconnect path begins a fresh pairing attempt, which clears
49+
/// `connectionError` and would erase the failure reason while the user is
50+
/// reading it on the auto-presented pairing sheet.
51+
@Test func failedAttemptKeepsErrorInsteadOfRestoring() {
52+
#expect(!policy.restoresPreviousConnection(
53+
connectionState: .disconnected,
54+
previousMacDeviceID: "mac-1",
55+
hasConnectionError: true
4256
))
4357
}
4458

@@ -47,16 +61,18 @@ import Testing
4761
@Test func successfulAttemptDoesNotRestorePreviousMac() {
4862
#expect(!policy.restoresPreviousConnection(
4963
connectionState: .connected,
50-
previousMacDeviceID: "mac-1"
64+
previousMacDeviceID: "mac-1",
65+
hasConnectionError: false
5166
))
5267
}
5368

5469
/// An attempt that started without a live connection (first pair from the
5570
/// tree's empty state) has nothing to restore.
56-
@Test func failedAttemptWithNoPreviousMacDoesNotRestore() {
71+
@Test func cancelledAttemptWithNoPreviousMacDoesNotRestore() {
5772
#expect(!policy.restoresPreviousConnection(
5873
connectionState: .disconnected,
59-
previousMacDeviceID: nil
74+
previousMacDeviceID: nil,
75+
hasConnectionError: false
6076
))
6177
}
6278
}

Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -166,12 +166,16 @@ struct DeviceTreeView: View {
166166
///
167167
/// Success (the shell is connected, to the added device): dismiss the
168168
/// sheet and refresh both device sources so the new device appears in the
169-
/// tree. Failure or cancellation over what was a live connection: the
170-
/// pairing path has already torn that connection down, so reconnect the
171-
/// previous Mac via ``CMUXMobileShellStore/switchToMac(macDeviceID:)``
172-
/// instead of stranding the user disconnected. The restore runs in a
173-
/// fresh unstructured `Task` because a cancelled attempt's continuation
174-
/// executes in an already-cancelled task.
169+
/// tree. Cancellation over what was a live connection (disconnected, no
170+
/// connection error): the pairing path has already torn that connection
171+
/// down, so reconnect the previous Mac via
172+
/// ``CMUXMobileShellStore/switchToMac(macDeviceID:)`` instead of
173+
/// stranding the user. The restore runs in a fresh unstructured `Task`
174+
/// because a cancelled attempt's continuation executes in an
175+
/// already-cancelled task. A real failure (connection error set) does not
176+
/// restore — the reconnect would clear the error the user needs to read;
177+
/// the disconnected shell auto-presents the pairing sheet with that error
178+
/// for an in-place retry (see the policy's rationale).
175179
private func finishAddDevice(previousMacDeviceID: String?) {
176180
let state = store.connectionState
177181
if addDevicePolicy.dismissesAfterPairingAttempt(connectionState: state) {
@@ -184,7 +188,8 @@ struct DeviceTreeView: View {
184188
}
185189
if addDevicePolicy.restoresPreviousConnection(
186190
connectionState: state,
187-
previousMacDeviceID: previousMacDeviceID
191+
previousMacDeviceID: previousMacDeviceID,
192+
hasConnectionError: store.connectionError != nil
188193
), let previousMacDeviceID {
189194
let store = store
190195
Task {

0 commit comments

Comments
 (0)