Repository navigation
Preserve iOS terminal shell while reconnecting #6647
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
c776f6a
307eaa1
92c33c5
be4c1db
9ce86de
cfd0477
42dcaff
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 |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import CMUXMobileCore | ||
| import CmuxMobilePairedMac | ||
| import CmuxMobileRPC | ||
| import CmuxMobileShellModel | ||
| import CmuxMobileTransport | ||
|
|
||
| extension MobileShellComposite { | ||
| /// Placeholder Mac name used until `mobile.host.status` reports the real one. | ||
| func placeholderHostName( | ||
| for ticket: CmxAttachTicket, | ||
| firstRoute: CmxAttachRoute | ||
| ) -> String { | ||
| if let name = ticket.macDisplayName, !name.isEmpty { | ||
| return name | ||
| } | ||
| if !ticket.macDeviceID.isEmpty { | ||
| return ticket.macDeviceID | ||
| } | ||
| if case let .hostPort(host, _) = firstRoute.endpoint { | ||
| return host | ||
| } | ||
| return "" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import Foundation | ||
|
|
||
| extension MobileShellComposite { | ||
| /// Whether the current cached workspace shell should remain visible while reconnecting. | ||
| public var shouldPreserveWorkspaceShellDuringReconnect: Bool { | ||
| connectionState != .connected | ||
| && hasCachedRemoteWorkspaceSnapshot | ||
| && !connectionRequiresReauth | ||
| && (isRecoveringConnection || isReconnectingStoredMac) | ||
| && workspaces.contains { !$0.terminals.isEmpty } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| import CMUXMobileCore | ||
| import CmuxMobileDiagnostics | ||
| import CmuxMobileRPC | ||
| import CmuxMobileShellModel | ||
| import Foundation | ||
| import OSLog | ||
|
|
||
| private let mobileShellReplayLog = Logger( | ||
| subsystem: Bundle.main.bundleIdentifier ?? "dev.cmux.ios", | ||
| category: "mobile-shell" | ||
| ) | ||
|
|
||
| extension MobileShellComposite { | ||
| /// Request and apply an authoritative terminal replay for one mounted surface. | ||
| /// | ||
| /// Concurrent callers for the same surface join the same in-flight replay, and | ||
| /// stale completions cannot clear a newer replay slot. | ||
| @discardableResult | ||
| public func performTerminalReplay(surfaceID: String) async -> Bool { | ||
| if let existingTask = terminalReplayRetryTasksBySurfaceID[surfaceID] { | ||
| #if DEBUG | ||
| mobileShellReplayLog.info("CMUX_REPLAY join surface=\(surfaceID, privacy: .public) reason=in_flight") | ||
| #endif | ||
| return await existingTask.value | ||
| } | ||
| guard let client = remoteClient, | ||
| let workspaceID = workspaceID(forTerminalID: surfaceID) else { | ||
| #if DEBUG | ||
| mobileShellReplayLog.error("CMUX_REPLAY skip surface=\(surfaceID, privacy: .public) reason=missing_context") | ||
| #endif | ||
| return false | ||
| } | ||
| let remoteWorkspaceID = remoteWorkspaceID(for: workspaceID) | ||
| let taskID = UUID() | ||
| let task = Task { @MainActor [weak self] in | ||
| guard let self else { return false } | ||
| return await self.executeTerminalReplay( | ||
| surfaceID: surfaceID, | ||
| client: client, | ||
| workspaceID: remoteWorkspaceID | ||
| ) | ||
| } | ||
| terminalReplayRetryTasksBySurfaceID[surfaceID] = task | ||
| terminalReplayRetryTaskIDsBySurfaceID[surfaceID] = taskID | ||
| let delivered = await task.value | ||
| if terminalReplayRetryTaskIDsBySurfaceID[surfaceID] == taskID { | ||
| terminalReplayRetryTasksBySurfaceID[surfaceID] = nil | ||
| terminalReplayRetryTaskIDsBySurfaceID[surfaceID] = nil | ||
| } | ||
| return delivered | ||
| } | ||
|
|
||
| func executeTerminalReplay( | ||
| surfaceID: String, | ||
| client: MobileCoreRPCClient, | ||
| workspaceID: MobileWorkspacePreview.ID | ||
| ) async -> Bool { | ||
| do { | ||
| let request = try MobileCoreRPCClient.requestData( | ||
| method: "mobile.terminal.replay", | ||
| params: [ | ||
| "workspace_id": workspaceID.rawValue, | ||
| "surface_id": surfaceID, | ||
| ] | ||
| ) | ||
| let data = try await client.sendRequest(request) | ||
| guard remoteClient === client else { return false } | ||
| let payload = try? MobileTerminalReplayResponse.decode(data) | ||
| let bytes = payload?.dataBase64.flatMap { Data(base64Encoded: $0) } | ||
| let snapshotBytes = payload?.snapshotBase64.flatMap { Data(base64Encoded: $0) } | ||
| let decodedRenderGrid = payload?.renderGrid | ||
| let renderGrid = decodedRenderGrid?.surfaceID == surfaceID ? decodedRenderGrid : nil | ||
| let replaySeq = renderGrid?.stateSeq ?? payload?.sequence | ||
| #if DEBUG | ||
| let seq = replaySeq ?? 0 | ||
| let cols = payload?.columns ?? -1 | ||
| let rows = payload?.rows ?? -1 | ||
| mobileShellReplayLog.info("CMUX_REPLAY response surface=\(surfaceID, privacy: .public) byteCount=\(bytes?.count ?? -1, privacy: .public) snapshotBytes=\(snapshotBytes?.count ?? -1, privacy: .public) renderGrid=\(renderGrid != nil, privacy: .public) seq=\(seq, privacy: .public) macGrid=\(cols, privacy: .public)x\(rows, privacy: .public) hasSink=\(self.hasTerminalOutputSink(surfaceID: surfaceID), privacy: .public)") | ||
| #endif | ||
| if let replaySeq, | ||
| terminalOutputAcceptedEndSeq(surfaceID: surfaceID) > replaySeq { | ||
| let acceptedSeq = terminalOutputAcceptedEndSeq(surfaceID: surfaceID) | ||
| MobileDebugLog.anchormux("CMUX_REPLAY stale surface=\(surfaceID) accepted=\(acceptedSeq) replay=\(replaySeq)") | ||
| return false | ||
| } | ||
| let deliverBytes: Data? | ||
| if let renderGrid { | ||
| deliverBytes = nil | ||
| MobileDebugLog.anchormux("CMUX_REPLAY render_grid surface=\(surfaceID) spans=\(renderGrid.rowSpans.count) seq=\(renderGrid.stateSeq)") | ||
| } else if let snapshotBytes, !snapshotBytes.isEmpty { | ||
| deliverBytes = Self.terminalSnapshotReplacementBytes(snapshotBytes) | ||
| MobileDebugLog.anchormux("CMUX_REPLAY snapshot surface=\(surfaceID) bytes=\(snapshotBytes.count) seq=\(replaySeq ?? 0)") | ||
| } else { | ||
| deliverBytes = bytes | ||
| MobileDebugLog.anchormux("CMUX_REPLAY raw_tail surface=\(surfaceID) bytes=\(bytes?.count ?? -1) seq=\(replaySeq ?? 0)") | ||
| } | ||
| if let renderGrid { | ||
| guard hasTerminalOutputSink(surfaceID: surfaceID) else { return false } | ||
| deliverTerminalRenderGrid(renderGrid, surfaceID: surfaceID) | ||
| return true | ||
| } | ||
| guard let deliverBytes, !deliverBytes.isEmpty, | ||
| hasTerminalOutputSink(surfaceID: surfaceID) else { | ||
| return false | ||
| } | ||
| deliverTerminalBytes(deliverBytes, surfaceID: surfaceID, endSeq: replaySeq) | ||
| return true | ||
| } catch { | ||
| mobileShellReplayLog.error("CMUX_REPLAY failed surface=\(surfaceID, privacy: .public) error=\(String(describing: error), privacy: .public)") | ||
| guard remoteClient === client else { return false } | ||
| _ = disconnectForAuthorizationFailureIfNeeded(error) | ||
| return false | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,8 @@ | ||||||||||||||||||
| #if DEBUG | ||||||||||||||||||
| extension MobileShellComposite { | ||||||||||||||||||
| /// Test-only: true while a mounted Ghostty surface still has an output consumer. | ||||||||||||||||||
| func debugHasTerminalOutputSinkForTesting(surfaceID: String) -> Bool { | ||||||||||||||||||
| hasTerminalOutputSink(surfaceID: surfaceID) | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| #endif | ||||||||||||||||||
|
Comment on lines
+1
to
+8
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. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Remove the test-only DEBUG seam from production Lines 1-8 add a Suggested change-#if DEBUG
-extension MobileShellComposite {
- /// Test-only: true while a mounted Ghostty surface still has an output consumer.
- func debugHasTerminalOutputSinkForTesting(surfaceID: String) -> Bool {
- hasTerminalOutputSink(surfaceID: surfaceID)
- }
-}
-#endifAs per path instructions, " 📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSources: Coding guidelines, Path instructions |
||||||||||||||||||
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.
Pairing errors still preserve shell
Medium Severity
shouldPreserveWorkspaceShellDuringReconnectstays true during stored-Mac reconnect whenconnectionErroris set, because it only excludesconnectionRequiresReauth. Root routing prefersWorkspaceShellViewover restoring and onboarding, so explicit pairing failures can be hidden behind a cached terminal and reconnecting banner.Additional Locations (1)
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift#L194-L196Reviewed by Cursor Bugbot for commit cfd0477. Configure here.