Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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
@@ -0,0 +1,126 @@
internal import CMUXMobileCore
internal import CmuxMobileDiagnostics
public import CmuxMobileRPC
public import Foundation
internal import OSLog

private let mobileShellLog = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "dev.cmux.ios",
category: "mobile-shell"
)

// MARK: - Terminal replay (cold attach + deeper-scrollback fetch)
extension MobileShellComposite {
/// Request a single deeper-scrollback replay for local (primary-screen)
/// scroll: when the phone scrolls to the top of locally-held history, this
/// re-requests the render-grid with a larger `scrollback_lines` budget so the
/// full-snapshot reflow grows the local surface's history. One RPC, not
/// per-frame; shares the in-flight guard with the cold-attach replay so it
/// can't pile up. The Mac clamps the budget to its own maximum.
/// - Parameters:
/// - surfaceID: The terminal surface identifier.
/// - scrollbackLines: How many scrollback rows to request.
public func requestDeeperScrollback(surfaceID: String, scrollbackLines: Int) {
requestTerminalReplay(surfaceID: surfaceID, scrollbackLines: max(0, scrollbackLines))
}

/// Cold-attach/self-heal replay. Prefer the Mac's bounded render-grid
/// snapshot, replacing the local iOS terminal state before live bytes
/// resume. The VT snapshot and raw byte ring remain fallbacks, but neither
/// is the target architecture: a byte tail is not a complete screen state
/// for TUIs, and a VT export is still a replay stream rather than state.
/// `scrollbackLines` (when set) requests a deeper-history snapshot for local
/// scroll; nil uses the Mac's default attach-time budget.
func requestTerminalReplay(surfaceID: String, scrollbackLines: Int? = nil) {
guard let client = remoteClient else {
#if DEBUG
mobileShellLog.error("CMUX_REPLAY skip surface=\(surfaceID, privacy: .public) reason=no_remote_client")
#endif
return
}
guard let workspaceID = workspaceID(forTerminalID: surfaceID) else {
#if DEBUG
mobileShellLog.error("CMUX_REPLAY skip surface=\(surfaceID, privacy: .public) reason=workspace_not_found")
#endif
return
}
guard !terminalReplaySurfaceIDsInFlight.contains(surfaceID) else {
#if DEBUG
mobileShellLog.info("CMUX_REPLAY skip surface=\(surfaceID, privacy: .public) reason=in_flight")
#endif
return
}
terminalReplaySurfaceIDsInFlight.insert(surfaceID)
Task { @MainActor [weak self] in
guard let self else { return }
defer { self.terminalReplaySurfaceIDsInFlight.remove(surfaceID) }
do {
var replayParams: [String: Any] = [
"workspace_id": workspaceID.rawValue,
"surface_id": surfaceID,
]
if let scrollbackLines {
replayParams["scrollback_lines"] = scrollbackLines
}
let request = try MobileCoreRPCClient.requestData(
method: "mobile.terminal.replay",
params: replayParams
)
let data = try await client.sendRequest(request)
guard self.remoteClient === client else { return }
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
mobileShellLog.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,
let deliveredSeq = self.deliveredTerminalByteEndSeqBySurfaceID[surfaceID],
deliveredSeq > replaySeq {
MobileDebugLog.anchormux("CMUX_REPLAY stale surface=\(surfaceID) delivered=\(deliveredSeq) replay=\(replaySeq)")
return
}
let deliverBytes: Data?
if let renderGrid {
deliverBytes = renderGrid.vtPatchBytes()
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 replaySeq {
self.markTerminalBytesDelivered(surfaceID: surfaceID, endSeq: replaySeq)
}
// A render-grid replay (cold attach OR deeper-scrollback fetch) is
// a full snapshot that re-flows scrollback into the local surface;
// surface its scrollback depth + active screen so the view knows
// how much history it now holds (and can classify this snapshot
// as a deeper-fetch result or a cold attach).
if let renderGrid {
self.frameMetaHub.deliver(from: renderGrid)
}
guard let deliverBytes, !deliverBytes.isEmpty else {
return
}
self.deliverTerminalBytes(deliverBytes, surfaceID: surfaceID)
} catch {
mobileShellLog.error("CMUX_REPLAY failed surface=\(surfaceID, privacy: .public) error=\(String(describing: error), privacy: .public)")
// The replay request is the view-only/foreground-resume path. A
// definitive auth failure here (after the RPC layer's
// force-refresh-and-retry already gave up) must drive the re-auth
// prompt instead of silently leaving a stale frame.
guard self.remoteClient === client else { return }
_ = self.disconnectForAuthorizationFailureIfNeeded(error)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
/// `public` so the DEV feedback-submit affordance can ``DiagnosticLog/export()``
/// it.
public let diagnosticLog: DiagnosticLog?
private var remoteClient: MobileCoreRPCClient? {
var remoteClient: MobileCoreRPCClient? {
didSet {
if remoteClient == nil {
stopTerminalRefreshPolling()
Expand Down Expand Up @@ -272,9 +272,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
private var createTerminalTaskID: UUID?
private var connectionGeneration: UUID
private var reportedViewportSizesByTerminalKey: [MobileTerminalViewportKey: MobileTerminalViewportSize]
private var deliveredTerminalByteEndSeqBySurfaceID: [String: UInt64]
var deliveredTerminalByteEndSeqBySurfaceID: [String: UInt64]
private var pendingTerminalByteEndSeqBySurfaceID: [String: UInt64]
private var terminalReplaySurfaceIDsInFlight: Set<String>
var terminalReplaySurfaceIDsInFlight: Set<String>
private var terminalOutputTransport: TerminalOutputTransport
private var rawTerminalInputBuffer: MobileTerminalInputSendBuffer
private var pairingAttemptID: UUID
Expand Down Expand Up @@ -2602,7 +2602,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
)
}

private func markTerminalBytesDelivered(surfaceID: String, endSeq: UInt64) {
func markTerminalBytesDelivered(surfaceID: String, endSeq: UInt64) {
let current = deliveredTerminalByteEndSeqBySurfaceID[surfaceID] ?? 0
deliveredTerminalByteEndSeqBySurfaceID[surfaceID] = max(current, endSeq)
if let pendingSeq = pendingTerminalByteEndSeqBySurfaceID[surfaceID],
Expand All @@ -2612,7 +2612,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
}

private static func terminalSnapshotReplacementBytes(_ snapshotBytes: Data) -> Data {
static func terminalSnapshotReplacementBytes(_ snapshotBytes: Data) -> Data {
var bytes = Data("\u{1B}c\u{1B}[H\u{1B}[2J\u{1B}[3J".utf8)
bytes.append(snapshotBytes)
return bytes
Expand All @@ -2625,13 +2625,18 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
/// Mac hosts.
private var terminalByteContinuationsBySurfaceID: [String: AsyncStream<Data>.Continuation] = [:]

/// Per-frame metadata the byte stream cannot carry (it is opaque VT bytes),
/// surfaced to the terminal view for Stage 1 local (primary-screen) scroll.
/// See ``MobileTerminalFrameMetaHub``.
let frameMetaHub = MobileTerminalFrameMetaHub()

/// Yield a chunk of output bytes to the surface's stream, if one is attached.
private func deliverTerminalBytes(_ bytes: Data, surfaceID: String) {
func deliverTerminalBytes(_ bytes: Data, surfaceID: String) {
terminalByteContinuationsBySurfaceID[surfaceID]?.yield(bytes)
}

/// Whether a surface currently has an attached output stream consumer.
private func hasTerminalOutputSink(surfaceID: String) -> Bool {
func hasTerminalOutputSink(surfaceID: String) -> Bool {
terminalByteContinuationsBySurfaceID[surfaceID] != nil
}

Expand All @@ -2652,11 +2657,23 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
terminalByteContinuationsBySurfaceID.removeValue(forKey: surfaceID)
deliveredTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID)
pendingTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID)
// Finish the frame-meta stream here too so byte-stream teardown cannot
// leave a dangling meta continuation (see ``MobileTerminalFrameMetaHub``).
frameMetaHub.finish(surfaceID: surfaceID)
// Tell the Mac this device is no longer viewing the surface so it stops
// pinning the shared grid to our viewport and clears the macOS border.
clearTerminalViewport(surfaceID: surfaceID)
}

/// The per-frame metadata stream for a terminal surface (active screen +
/// full-snapshot scrollback depth), consumed alongside
/// ``terminalOutputStream(surfaceID:)`` by the terminal view for local scroll.
/// - Parameter surfaceID: The terminal surface identifier.
/// - Returns: An `AsyncStream` of frame metadata.
public func terminalFrameMetaStream(surfaceID: String) -> AsyncStream<MobileTerminalFrameMeta> {
frameMetaHub.stream(surfaceID: surfaceID)
}

/// The output byte stream for a terminal surface.
///
/// Obtaining the stream arms a cold-attach replay so the surface catches up
Expand Down Expand Up @@ -2737,93 +2754,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
}

/// Cold-attach/self-heal replay. Prefer the Mac's bounded render-grid
/// snapshot, replacing the local iOS terminal state before live bytes
/// resume. The VT snapshot and raw byte ring remain fallbacks, but neither
/// is the target architecture: a byte tail is not a complete screen state
/// for TUIs, and a VT export is still a replay stream rather than state.
private func requestTerminalReplay(surfaceID: String) {
guard let client = remoteClient else {
#if DEBUG
mobileShellLog.error("CMUX_REPLAY skip surface=\(surfaceID, privacy: .public) reason=no_remote_client")
#endif
return
}
guard let workspaceID = workspaceID(forTerminalID: surfaceID) else {
#if DEBUG
mobileShellLog.error("CMUX_REPLAY skip surface=\(surfaceID, privacy: .public) reason=workspace_not_found")
#endif
return
}
guard !terminalReplaySurfaceIDsInFlight.contains(surfaceID) else {
#if DEBUG
mobileShellLog.info("CMUX_REPLAY skip surface=\(surfaceID, privacy: .public) reason=in_flight")
#endif
return
}
terminalReplaySurfaceIDsInFlight.insert(surfaceID)
Task { @MainActor [weak self] in
guard let self else { return }
defer { self.terminalReplaySurfaceIDsInFlight.remove(surfaceID) }
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 self.remoteClient === client else { return }
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
mobileShellLog.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,
let deliveredSeq = self.deliveredTerminalByteEndSeqBySurfaceID[surfaceID],
deliveredSeq > replaySeq {
MobileDebugLog.anchormux("CMUX_REPLAY stale surface=\(surfaceID) delivered=\(deliveredSeq) replay=\(replaySeq)")
return
}
let deliverBytes: Data?
if let renderGrid {
deliverBytes = renderGrid.vtPatchBytes()
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 replaySeq {
self.markTerminalBytesDelivered(surfaceID: surfaceID, endSeq: replaySeq)
}
guard let deliverBytes, !deliverBytes.isEmpty else {
return
}
self.deliverTerminalBytes(deliverBytes, surfaceID: surfaceID)
} catch {
mobileShellLog.error("CMUX_REPLAY failed surface=\(surfaceID, privacy: .public) error=\(String(describing: error), privacy: .public)")
// The replay request is the view-only/foreground-resume path. A
// definitive auth failure here (after the RPC layer's
// force-refresh-and-retry already gave up) must drive the re-auth
// prompt instead of silently leaving a stale frame.
guard self.remoteClient === client else { return }
_ = self.disconnectForAuthorizationFailureIfNeeded(error)
}
}
}

private func workspaceID(forTerminalID terminalID: String) -> MobileWorkspacePreview.ID? {
func workspaceID(forTerminalID terminalID: String) -> MobileWorkspacePreview.ID? {
for workspace in workspaces {
if workspace.terminals.contains(where: { $0.id.rawValue == terminalID }) {
return workspace.id
Expand Down Expand Up @@ -2855,6 +2786,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
#if DEBUG
mobileShellLog.info("CMUX_REPLAY live render_grid surface=\(renderGrid.surfaceID, privacy: .public) full=\(renderGrid.full, privacy: .public) spans=\(renderGrid.rowSpans.count, privacy: .public) cleared=\(renderGrid.clearedRows.count, privacy: .public) seq=\(renderGrid.stateSeq, privacy: .public) hasSink=true")
#endif
// Surface the active screen so the view gates local scroll (see
// ``MobileTerminalFrameMetaHub`` for the cross-stream ordering contract).
frameMetaHub.deliver(from: renderGrid)
guard !bytes.isEmpty else { return }
deliverTerminalBytes(bytes, surfaceID: renderGrid.surfaceID)
}
Expand Down Expand Up @@ -3010,7 +2944,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
return true
}

private func disconnectForAuthorizationFailureIfNeeded(_ error: any Error) -> Bool {
func disconnectForAuthorizationFailureIfNeeded(_ error: any Error) -> Bool {
guard Self.shouldDisconnectForAuthorizationFailure(error) else {
return false
}
Expand Down
Loading
Loading