Repository navigation
iOS: multi-Mac host switcher #5513
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0ad3b78
853b512
4a3441d
662ec39
861ac47
499b673
27b7020
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -246,6 +246,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { | |||||||||||||||||||||||
| connectionError = nil | ||||||||||||||||||||||||
| activeTicket = nil | ||||||||||||||||||||||||
| activeRoute = nil | ||||||||||||||||||||||||
| // Drop the cached paired Macs so the next signed-in user never sees the | ||||||||||||||||||||||||
| // previous user's hosts in the switcher. | ||||||||||||||||||||||||
| pairedMacs = [] | ||||||||||||||||||||||||
| replaceRemoteClient(with: nil) | ||||||||||||||||||||||||
| cancelRemoteOperationTasks() | ||||||||||||||||||||||||
| rawTerminalInputBuffer.clear() | ||||||||||||||||||||||||
|
|
@@ -568,6 +571,108 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { | |||||||||||||||||||||||
| return connectionState == .connected | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // MARK: - Paired Mac switching | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /// Every Mac paired with this device, for the host switcher. Refreshed via | ||||||||||||||||||||||||
| /// ``loadPairedMacs()`` and after switch/forget. Cleared on sign-out so a | ||||||||||||||||||||||||
| /// shared device never shows the previous user's Macs. The active row is | ||||||||||||||||||||||||
| /// marked by each ``MobilePairedMac/isActive`` flag (the live connection's | ||||||||||||||||||||||||
| /// attach ticket carries a transient manual id, so it is not a reliable | ||||||||||||||||||||||||
| /// active marker on its own). | ||||||||||||||||||||||||
| public private(set) var pairedMacs: [MobilePairedMac] = [] | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /// Reload ``pairedMacs`` from the store, scoped to the signed-in Stack user. | ||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||
| /// A missing current Stack user id yields no pairings rather than falling | ||||||||||||||||||||||||
| /// back to the unscoped all-users query, so a shared device never exposes | ||||||||||||||||||||||||
| /// another user's Macs in the switcher. | ||||||||||||||||||||||||
| public func loadPairedMacs() async { | ||||||||||||||||||||||||
| guard let pairedMacStore, isSignedIn, | ||||||||||||||||||||||||
| let stackUserID = identityProvider?.currentUserID else { | ||||||||||||||||||||||||
| pairedMacs = [] | ||||||||||||||||||||||||
| return | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| let loaded: [MobilePairedMac] | ||||||||||||||||||||||||
| do { | ||||||||||||||||||||||||
| loaded = try await pairedMacStore.loadAll(stackUserID: stackUserID) | ||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||
| mobileShellLog.error("paired mac store loadAll failed: \(String(describing: error), privacy: .public)") | ||||||||||||||||||||||||
| return | ||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Failed reload keeps stale Mac listLow Severity When Reviewed by Cursor Bugbot for commit 861ac47. Configure here. |
||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| // The await above suspended the main actor; a sign-out or user switch may | ||||||||||||||||||||||||
| // have run meanwhile. Discard the result unless we are still the same | ||||||||||||||||||||||||
| // signed-in user, so a slow load can never repopulate another user's hosts. | ||||||||||||||||||||||||
| guard isSignedIn, identityProvider?.currentUserID == stackUserID else { | ||||||||||||||||||||||||
| pairedMacs = [] | ||||||||||||||||||||||||
| return | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
Comment on lines
+596
to
+608
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Clear If Suggested fixpublic func loadPairedMacs() async {
guard let pairedMacStore else {
pairedMacs = []
return
}
do {
pairedMacs = try await pairedMacStore.loadAll(stackUserID: identityProvider?.currentUserID)
} catch {
+ pairedMacs = []
mobileShellLog.error("paired mac store loadAll failed: \(String(describing: error), privacy: .public)")
}
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||
| pairedMacs = loaded | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /// Switch the live connection to `macDeviceID`, persisting it as the active | ||||||||||||||||||||||||
| /// pairing only on a successful connect. | ||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||
| /// The underlying connect path is destructive (it replaces the live client), | ||||||||||||||||||||||||
| /// so a failed switch to an offline/stale Mac would drop the working session. | ||||||||||||||||||||||||
| /// To avoid stranding the user, the store's active row is only updated on a | ||||||||||||||||||||||||
| /// successful connect, and on failure the previously-active Mac (still the | ||||||||||||||||||||||||
| /// active row) is reconnected. A no-op when already connected to that Mac. | ||||||||||||||||||||||||
| /// - Parameter macDeviceID: The stored Mac to switch to. | ||||||||||||||||||||||||
| 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 } | ||||||||||||||||||||||||
|
cursor[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||
| // 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() | ||||||||||||||||||||||||
|
cursor[bot] marked this conversation as resolved.
Comment on lines
+621
to
+655
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The doc comment directly above promises the opposite: "a failed switch…leaves the previously-working Mac active and reachable." That is true of the SQLite record (
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /// Forget `macDeviceID`. Always removes the selected stored row by its real | ||||||||||||||||||||||||
| /// id, and additionally tears down the live connection when that row is the | ||||||||||||||||||||||||
| /// active one (the live attach ticket can carry a transient manual id, so we | ||||||||||||||||||||||||
| /// must not rely on it to identify the row being forgotten). | ||||||||||||||||||||||||
| /// - Parameter macDeviceID: The stored Mac to forget. | ||||||||||||||||||||||||
| public func forgetMac(macDeviceID: String) async { | ||||||||||||||||||||||||
| let isActiveMac = pairedMacs.first(where: { $0.macDeviceID == macDeviceID })?.isActive ?? false | ||||||||||||||||||||||||
| if isActiveMac, connectionState == .connected { | ||||||||||||||||||||||||
| disconnectLiveConnection() | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
cursor[bot] marked this conversation as resolved.
Comment on lines
+665
to
+667
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Forget skips live host disconnectMedium Severity
Reviewed by Cursor Bugbot for commit 27b7020. Configure here. |
||||||||||||||||||||||||
| do { | ||||||||||||||||||||||||
| try await pairedMacStore?.remove(macDeviceID: macDeviceID) | ||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||
| mobileShellLog.error("paired mac store remove failed mac=\(macDeviceID, privacy: .public) error=\(String(describing: error), privacy: .public)") | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| await loadPairedMacs() | ||||||||||||||||||||||||
|
cursor[bot] marked this conversation as resolved.
cursor[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
Comment on lines
+663
to
+674
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When The fix is to
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| static func firstReconnectHostPortRoute( | ||||||||||||||||||||||||
| _ routes: [CmxAttachRoute], | ||||||||||||||||||||||||
| supportedKinds: [CmxAttachTransportKind] | ||||||||||||||||||||||||
|
|
@@ -672,14 +777,23 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { | |||||||||||||||||||||||
| /// session starts from a fresh QR scan. Clears in-memory state and the | ||||||||||||||||||||||||
| /// persisted active flag (other macs in SQLite stay, but none are marked | ||||||||||||||||||||||||
| /// active so reconnect-on-launch is a no-op until the user pairs again). | ||||||||||||||||||||||||
| public func disconnectAndForgetActiveMac() { | ||||||||||||||||||||||||
| let staleMacID = activeTicket?.macDeviceID | ||||||||||||||||||||||||
| /// Tear down the live connection and reset connection UI state, without | ||||||||||||||||||||||||
| /// touching the paired-Mac store. | ||||||||||||||||||||||||
| private func disconnectLiveConnection() { | ||||||||||||||||||||||||
| pairingAttemptID = UUID() | ||||||||||||||||||||||||
| connectionError = nil | ||||||||||||||||||||||||
| connectionRequiresReauth = false | ||||||||||||||||||||||||
| connectionState = .disconnected | ||||||||||||||||||||||||
| macConnectionStatus = .unavailable | ||||||||||||||||||||||||
| clearRemoteConnectionContext() | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
Comment on lines
+782
to
+789
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cancel recovery when the user explicitly disconnects.
Suggested fix private func disconnectLiveConnection() {
+ recoveryTask?.cancel()
+ recoveryTask = nil
+ recoveryInFlight = false
+ isRecoveringConnection = false
+ connectionRecoveryFailed = false
pairingAttemptID = UUID()
connectionError = nil
connectionRequiresReauth = false
connectionState = .disconnected
macConnectionStatus = .unavailable
clearRemoteConnectionContext()
}🤖 Prompt for AI Agents |
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /// Disconnect the live connection and forget the currently-active paired Mac | ||||||||||||||||||||||||
| /// (drops it from the store), returning the UI to the pairing flow. Backs the | ||||||||||||||||||||||||
| /// "Rescan QR" action. | ||||||||||||||||||||||||
| public func disconnectAndForgetActiveMac() { | ||||||||||||||||||||||||
| let staleMacID = activeTicket?.macDeviceID | ||||||||||||||||||||||||
| disconnectLiveConnection() | ||||||||||||||||||||||||
| if let pairedMacStore, let macID = staleMacID { | ||||||||||||||||||||||||
| // Fire-and-forget: forgetting the persisted mac is cleanup that must | ||||||||||||||||||||||||
| // not block the synchronous disconnect UI state update above. | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| #if os(iOS) | ||
| import CmuxMobilePairedMac | ||
| import CmuxMobileShell | ||
| import CmuxMobileSupport | ||
| import SwiftUI | ||
|
|
||
| /// Lets the user switch which paired Mac this device controls, and pair another. | ||
| /// | ||
| /// Lists every Mac paired with this device (from the on-device store), marks the | ||
| /// 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 | ||
| @Environment(\.dismiss) private var dismiss | ||
| @State private var showingScanner = false | ||
|
|
||
| var body: some View { | ||
| NavigationStack { | ||
| List { | ||
| Section { | ||
| if store.pairedMacs.isEmpty { | ||
| Text(L10n.string("mobile.hostPicker.empty", defaultValue: "No paired Macs yet.")) | ||
| .foregroundStyle(.secondary) | ||
| } | ||
| ForEach(store.pairedMacs) { mac in | ||
| macRow(mac) | ||
| } | ||
| } header: { | ||
| Text(L10n.string("mobile.hostPicker.header", defaultValue: "Paired Macs")) | ||
| } footer: { | ||
| Text(L10n.string( | ||
| "mobile.hostPicker.footer", | ||
| defaultValue: "Switch which Mac this device controls. Pairing another Mac keeps the others, so you can hop between them." | ||
| )) | ||
| } | ||
|
|
||
| Section { | ||
| Button { | ||
| showingScanner = true | ||
| } label: { | ||
| Label( | ||
| L10n.string("mobile.hostPicker.addMac", defaultValue: "Pair Another Mac"), | ||
| systemImage: "plus" | ||
| ) | ||
| } | ||
| .accessibilityIdentifier("MobileHostPickerAddMac") | ||
| } | ||
| } | ||
| .navigationTitle(L10n.string("mobile.hostPicker.title", defaultValue: "Switch Mac")) | ||
| .navigationBarTitleDisplayMode(.inline) | ||
| .toolbar { | ||
| ToolbarItem(placement: .confirmationAction) { | ||
| Button(L10n.string("mobile.common.done", defaultValue: "Done")) { | ||
| dismiss() | ||
| } | ||
| .accessibilityIdentifier("MobileHostPickerDone") | ||
| } | ||
| } | ||
| .task { await store.loadPairedMacs() } | ||
| .sheet(isPresented: $showingScanner) { | ||
| MobilePairingScannerSheet { code in | ||
| showingScanner = false | ||
| Task { | ||
| _ = await store.connectPairingURL(code) | ||
| await store.loadPairedMacs() | ||
| dismiss() | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
|
Comment on lines
+61
to
+68
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The fix is to check the return value of |
||
| } | ||
| } | ||
| .accessibilityIdentifier("MobileHostPicker") | ||
| } | ||
|
|
||
| @ViewBuilder | ||
| private func macRow(_ mac: MobilePairedMac) -> some View { | ||
| let isActive = mac.isActive | ||
| Button { | ||
| Task { await store.switchToMac(macDeviceID: mac.macDeviceID) } | ||
| } label: { | ||
| HStack(spacing: 12) { | ||
| Image(systemName: "desktopcomputer") | ||
| .foregroundStyle(.secondary) | ||
| VStack(alignment: .leading, spacing: 2) { | ||
| Text(mac.displayName ?? mac.macDeviceID) | ||
| .foregroundStyle(.primary) | ||
| Text(mac.lastSeenAt, format: .relative(presentation: .named)) | ||
| .font(.caption) | ||
| .foregroundStyle(.secondary) | ||
| } | ||
| Spacer(minLength: 8) | ||
| if isActive { | ||
| Image(systemName: "checkmark") | ||
| .foregroundStyle(Color.accentColor) | ||
| .accessibilityLabel(L10n.string("mobile.hostPicker.active", defaultValue: "Active")) | ||
| } | ||
| } | ||
| .contentShape(Rectangle()) | ||
| } | ||
| .buttonStyle(.plain) | ||
| .accessibilityIdentifier("MobileHostPickerRow-\(mac.macDeviceID)") | ||
| .swipeActions(edge: .trailing) { | ||
| Button(role: .destructive) { | ||
| Task { await store.forgetMac(macDeviceID: mac.macDeviceID) } | ||
| } label: { | ||
| Label(L10n.string("mobile.hostPicker.forget", defaultValue: "Forget"), systemImage: "trash") | ||
| } | ||
| .accessibilityIdentifier("MobileHostPickerForget-\(mac.macDeviceID)") | ||
| } | ||
|
Comment on lines
+77
to
+108
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Both Rule Used: Flag new legacy async patterns in cmux-owned Swift... (source) |
||
| } | ||
| } | ||
| #endif | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
macDeviceIDis not in the table. The inner SELECT is a scalar subquery; when no row matches, SQLite producesNULL, soWHERE stack_user_id IS NULLfires and clears every null-scoped row's active flag — then the second UPDATE matches nothing, leaving no Mac active for that scope. The untracked Tasks already flagged onMobileHostPickerViewmake this reachable via a race between a swipe-to-forget and a tap-to-switch on the same non-active row. Adding aWHERE EXISTSguard or checking the row count before committing prevents silent corruption.