diff --git a/Packages/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalScrollbackBudget.swift b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalScrollbackBudget.swift new file mode 100644 index 00000000000..a6ebaabfc44 --- /dev/null +++ b/Packages/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalScrollbackBudget.swift @@ -0,0 +1,15 @@ +public enum MobileTerminalScrollbackBudget { + /// Small fallback used when an older client asks for replay without an + /// explicit scrollback window. + public static let defaultReplayRows = 240 + + /// Bounded repair window for host-coupled scroll responses. The decoupled + /// primary-screen path should not depend on this during normal iPhone + /// scrolling. + public static let scrollPrefetchRows = 600 +} + +public enum MobileTerminalScrollbackReplayRequest { + public static let scopeParameter = "scrollback_scope" + public static let fullScope = "full" +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+Debug.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+Debug.swift index b0859df6c9b..6c8029fc932 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+Debug.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+Debug.swift @@ -74,6 +74,8 @@ extension ControlCommandCoordinator { return debugSimulateTerminalFileDrop(request.params) case "debug.terminal.read_text": return debugReadTerminalText(request.params) + case "debug.terminal.scroll_to_row_offset": + return debugTerminalScrollToRowOffset(request.params) case "debug.terminal.render_stats": return debugRenderStats(request.params) case "debug.layout": diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+Debug2.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+Debug2.swift index cc301b8a04d..aed74a9c06b 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+Debug2.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+Debug2.swift @@ -232,6 +232,28 @@ extension ControlCommandCoordinator { return .ok(.object(["base64": .string(b64)])) } + /// `debug.terminal.scroll_to_row_offset` — drive Ghostty's fractional + /// row-offset scroll API for renderer verification. + func debugTerminalScrollToRowOffset(_ params: [String: JSONValue]) -> ControlCallResult { + let surfaceArg = string(params, "surface_id") ?? "" + guard let rowOffset = double(params, "row_offset"), rowOffset.isFinite else { + return .err(code: "invalid_params", message: "Missing or invalid row_offset", data: nil) + } + let didScroll = debugContext?.controlDebugTerminalScrollToRowOffset( + surfaceArgument: surfaceArg, + rowOffset: rowOffset + ) ?? false + guard didScroll else { + return .err(code: "not_found", message: "Terminal surface not found", data: .object([ + "surface_id": .string(surfaceArg) + ])) + } + return .ok(.object([ + "surface_id": .string(surfaceArg), + "row_offset": .double(rowOffset) + ])) + } + /// `debug.terminal.render_stats` — renderer stats (shared v1 body's JSON, /// decoded exactly as the legacy wrapper did). func debugRenderStats(_ params: [String: JSONValue]) -> ControlCallResult { diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugContext.swift index 0b6c5bf8ceb..26554079580 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugContext.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugContext.swift @@ -71,6 +71,16 @@ public protocol ControlDebugContext: AnyObject { /// - Returns: The raw v1 response (`"OK "` or an `ERROR:` line). func controlDebugReadTerminalText(surfaceArgument: String) -> String + /// Drives a terminal's Ghostty scrollback to a fractional row offset for + /// renderer verification. + /// + /// - Parameters: + /// - surfaceArgument: The surface id/index argument (may be empty for the + /// focused surface). + /// - rowOffset: Fractional row offset from the top of primary scrollback. + /// - Returns: `true` when the target terminal exists and has a live surface. + func controlDebugTerminalScrollToRowOffset(surfaceArgument: String, rowOffset: Double) -> Bool + /// Runs the shared v1 `render_stats` body for `debug.terminal.render_stats`. /// /// - Parameter surfaceArgument: The surface id/index argument. diff --git a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+Debug.swift b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+Debug.swift index 81db886574b..c19758736f1 100644 --- a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+Debug.swift +++ b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+Debug.swift @@ -16,6 +16,7 @@ extension ControlDebugContext { func controlDebugActivateApp() -> String { "ERROR: not implemented" } func controlDebugIsTerminalFocused(surfaceArgument: String) -> String { "ERROR: not implemented" } func controlDebugReadTerminalText(surfaceArgument: String) -> String { "ERROR: not implemented" } + func controlDebugTerminalScrollToRowOffset(surfaceArgument: String, rowOffset: Double) -> Bool { false } func controlDebugRenderStats(surfaceArgument: String) -> String { "ERROR: not implemented" } func controlDebugLayout() -> String { "ERROR: not implemented" } func controlDebugBonsplitUnderflowCount() -> String { "ERROR: not implemented" } diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift index 2abb1c60a5d..b5d9ebb8162 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift @@ -29,7 +29,14 @@ extension MobileShellComposite { terminalOutputQueuesBySurfaceID[surfaceID] = queue if let immediate { continuation.yield( - MobileTerminalOutputChunk(data: immediate.bytes, streamToken: streamToken) + MobileTerminalOutputChunk( + data: immediate.bytes, + streamToken: streamToken, + activeScreen: immediate.activeScreen, + scrollbackRows: immediate.scrollbackRows, + replayColumns: immediate.replayColumns, + replayRows: immediate.replayRows + ) ) } } @@ -45,6 +52,13 @@ extension MobileShellComposite { terminalOutputStreamTokensBySurfaceID[surfaceID] == streamToken else { return } - continuation.yield(MobileTerminalOutputChunk(data: next.bytes, streamToken: streamToken)) + continuation.yield(MobileTerminalOutputChunk( + data: next.bytes, + streamToken: streamToken, + activeScreen: next.activeScreen, + scrollbackRows: next.scrollbackRows, + replayColumns: next.replayColumns, + replayRows: next.replayRows + )) } } diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 6bca45881ad..54480fc8b90 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -591,6 +591,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { private var deliveredTerminalByteEndSeqBySurfaceID: [String: UInt64] private var pendingTerminalByteEndSeqBySurfaceID: [String: UInt64] private var terminalReplaySurfaceIDsInFlight: Set + private var terminalReplaySurfaceIDsPendingWorkspaceMapping: Set private var terminalOutputTransport: TerminalOutputTransport var terminalByteContinuationsBySurfaceID: [String: AsyncStream.Continuation] var terminalOutputStreamTokensBySurfaceID: [String: UUID] @@ -720,6 +721,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.deliveredTerminalByteEndSeqBySurfaceID = [:] self.pendingTerminalByteEndSeqBySurfaceID = [:] self.terminalReplaySurfaceIDsInFlight = [] + self.terminalReplaySurfaceIDsPendingWorkspaceMapping = [] self.terminalOutputTransport = .rawBytes self.terminalByteContinuationsBySurfaceID = [:] self.terminalOutputStreamTokensBySurfaceID = [:] @@ -3549,6 +3551,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { deliveredTerminalByteEndSeqBySurfaceID = [:] pendingTerminalByteEndSeqBySurfaceID = [:] terminalReplaySurfaceIDsInFlight = [] + terminalReplaySurfaceIDsPendingWorkspaceMapping = [] terminalOutputQueuesBySurfaceID = [:] terminalOutputStreamTokensBySurfaceID = terminalOutputStreamTokensBySurfaceID.mapValues { _ in UUID() } terminalScrollQueueTokensBySurfaceID = [:] @@ -4831,6 +4834,19 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } } + private func retryTerminalReplaysPendingWorkspaceMapping(reason: String) { + guard remoteClient != nil, connectionState == .connected else { return } + let surfaceIDs = terminalReplaySurfaceIDsPendingWorkspaceMapping + .filter { hasTerminalOutputSink(surfaceID: $0) && workspaceID(forTerminalID: $0) != nil } + guard !surfaceIDs.isEmpty else { return } + MobileDebugLog.anchormux( + "sync.replay_mapping_ready reason=\(reason) surfaces=\(surfaceIDs.count)" + ) + for surfaceID in surfaceIDs { + requestTerminalReplay(surfaceID: surfaceID) + } + } + private func handleTerminalInputResponse(_ data: Data, surfaceID: String) { guard hasTerminalOutputSink(surfaceID: surfaceID), let payload = try? MobileTerminalInputResponse.decode(data), @@ -4944,6 +4960,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { terminalScrollbackPrefetchStatesBySurfaceID.removeValue(forKey: surfaceID) deliveredTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID) pendingTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID) + terminalReplaySurfaceIDsPendingWorkspaceMapping.remove(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) @@ -5042,11 +5059,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return } guard let workspaceID = workspaceID(forTerminalID: surfaceID) else { + terminalReplaySurfaceIDsPendingWorkspaceMapping.insert(surfaceID) #if DEBUG mobileShellLog.error("CMUX_REPLAY skip surface=\(surfaceID, privacy: .public) reason=workspace_not_found") #endif return } + terminalReplaySurfaceIDsPendingWorkspaceMapping.remove(surfaceID) guard !terminalReplaySurfaceIDsInFlight.contains(surfaceID) else { #if DEBUG mobileShellLog.info("CMUX_REPLAY skip surface=\(surfaceID, privacy: .public) reason=in_flight") @@ -5063,6 +5082,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { params: [ "workspace_id": workspaceID.rawValue, "surface_id": surfaceID, + MobileTerminalScrollbackReplayRequest.scopeParameter: + MobileTerminalScrollbackReplayRequest.fullScope, ] ) let data = try await client.sendRequest(request) @@ -5088,7 +5109,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { let deliverBytes: Data? if let renderGrid { deliverBytes = nil - MobileDebugLog.anchormux("CMUX_REPLAY render_grid surface=\(surfaceID) spans=\(renderGrid.rowSpans.count) seq=\(renderGrid.stateSeq)") + MobileDebugLog.anchormux( + "CMUX_REPLAY render_grid surface=\(surfaceID) rows=\(renderGrid.rows) " + + "scrollbackRows=\(renderGrid.scrollbackRows) spans=\(renderGrid.rowSpans.count) " + + "scrollbackSpans=\(renderGrid.scrollbackSpans.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)") @@ -5316,11 +5341,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { workspaceGroups = response.groups.map { MobileWorkspaceGroupPreview(remote: $0) } } if preferActiveTicketTarget, selectActiveTicketTargetIfAvailable() { + retryTerminalReplaysPendingWorkspaceMapping(reason: "workspace_list") return } if let selectedWorkspaceID, workspaces.contains(where: { $0.id == selectedWorkspaceID }) { syncSelectedTerminalForWorkspace() + retryTerminalReplaysPendingWorkspaceMapping(reason: "workspace_list") return } setSelectedWorkspaceID( @@ -5329,6 +5356,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { ?? workspaces.first?.id ) syncSelectedTerminalForWorkspace() + retryTerminalReplaysPendingWorkspaceMapping(reason: "workspace_list") } private func remoteWorkspacesPreservingSnapshots( diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/TerminalOutputDelivery.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/TerminalOutputDelivery.swift index ad68dfa0360..674621dcbda 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/TerminalOutputDelivery.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/TerminalOutputDelivery.swift @@ -29,6 +29,42 @@ struct TerminalOutputDelivery: Equatable, Sendable { frame.vtPatchBytes() } } + + var activeScreen: MobileTerminalRenderGridFrame.Screen? { + switch payload { + case .bytes: + nil + case .renderGrid(let frame): + frame.activeScreen + } + } + + var scrollbackRows: Int? { + switch payload { + case .bytes: + nil + case .renderGrid(let frame): + frame.full ? frame.scrollbackRows : nil + } + } + + var replayColumns: Int? { + switch payload { + case .bytes: + nil + case .renderGrid(let frame): + frame.full ? frame.columns : nil + } + } + + var replayRows: Int? { + switch payload { + case .bytes: + nil + case .renderGrid(let frame): + frame.full ? frame.rows : nil + } + } } /// Backpressure queue for one mounted mobile terminal output stream. diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift index b11327969a3..7b10ab4b6b3 100644 --- a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift @@ -90,6 +90,44 @@ import Testing #expect(!vt.contains("old")) } +@Test func terminalOutputDeliveryCarriesRenderGridMetadata() throws { + let frame = try MobileTerminalRenderGridFrame( + surfaceID: "terminal", + stateSeq: 1, + columns: 12, + rows: 2, + full: true, + rowSpans: [], + activeScreen: .alternate, + scrollbackRows: 42 + ) + let deltaFrame = try MobileTerminalRenderGridFrame( + surfaceID: "terminal", + stateSeq: 2, + columns: 12, + rows: 2, + full: false, + rowSpans: [], + activeScreen: .primary, + scrollbackRows: 42 + ) + let delivery = TerminalOutputDelivery(renderGrid: frame, replaceable: false) + let deltaDelivery = TerminalOutputDelivery(renderGrid: deltaFrame, replaceable: false) + let rawDelivery = TerminalOutputDelivery(bytes: Data("raw".utf8), replaceable: false) + + #expect(delivery.activeScreen == MobileTerminalRenderGridFrame.Screen.alternate) + #expect(delivery.scrollbackRows == 42) + #expect(delivery.replayColumns == 12) + #expect(delivery.replayRows == 2) + #expect(deltaDelivery.scrollbackRows == nil) + #expect(deltaDelivery.replayColumns == nil) + #expect(deltaDelivery.replayRows == nil) + #expect(rawDelivery.activeScreen == nil) + #expect(rawDelivery.scrollbackRows == nil) + #expect(rawDelivery.replayColumns == nil) + #expect(rawDelivery.replayRows == nil) +} + @Test func terminalOutputQueuePreservesNonreplaceableBarriers() { var queue = TerminalOutputDeliveryQueue() let inFlight = TerminalOutputDelivery(bytes: Data("in-flight".utf8), replaceable: false) diff --git a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift index 7548c22dd02..48982675ac4 100644 --- a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift +++ b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift @@ -1,3 +1,4 @@ +public import CMUXMobileCore public import Foundation /// A seam exposing per-surface terminal output as an `AsyncStream`. @@ -14,10 +15,32 @@ public import Foundation /// propagation is a structured, cancellable `AsyncSequence` instead of a stored /// callback. public struct MobileTerminalOutputChunk: Sendable { + /// The active terminal screen captured by the render-grid frame that + /// produced ``data``. Raw byte fallback chunks carry `nil`. + public let activeScreen: MobileTerminalRenderGridFrame.Screen? + /// Number of scrollback rows included in a full render-grid snapshot. + /// Delta frames and raw byte fallback chunks carry `nil` because they do not + /// describe the local mirror's scrollback extent. + public let scrollbackRows: Int? + /// Viewport grid captured by a full render-grid snapshot. Used by the local + /// iOS Ghostty mirror to apply final geometry before replaying scrollback. + public let replayColumns: Int? + public let replayRows: Int? public let data: Data public let streamToken: UUID - public init(data: Data, streamToken: UUID) { + public init( + data: Data, + streamToken: UUID, + activeScreen: MobileTerminalRenderGridFrame.Screen? = nil, + scrollbackRows: Int? = nil, + replayColumns: Int? = nil, + replayRows: Int? = nil + ) { + self.activeScreen = activeScreen + self.scrollbackRows = scrollbackRows + self.replayColumns = replayColumns + self.replayRows = replayRows self.data = data self.streamToken = streamToken } diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift index 39a04e3b8e6..1c256aac677 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift @@ -78,7 +78,10 @@ struct CMUXMobileRootView: View { .animation(.snappy(duration: 0.18), value: isAuthenticated) .animation(.snappy(duration: 0.18), value: store.phase) .onAppear { - syncShellAuthentication(isAuthenticated) + let startedLaunchAttachURL = connectLaunchAttachURLIfNeeded() + if !startedLaunchAttachURL { + syncShellAuthentication(isAuthenticated) + } store.resumeForegroundRefresh() #if os(iOS) pushCoordinator.bind(store: store) @@ -88,7 +91,9 @@ struct CMUXMobileRootView: View { // so kick off the stored-Mac reconnect here too. Without this the // restoring gate could stay on RestoringSessionView forever because // nothing ever resolves `didFinishStoredMacReconnectAttempt`. - reconnectStoredMacIfNeeded() + if !startedLaunchAttachURL { + reconnectStoredMacIfNeeded() + } } #if os(iOS) // A notification tap can arrive before the workspace (or terminal) it @@ -128,6 +133,7 @@ struct CMUXMobileRootView: View { .onChange(of: isAuthenticated) { _, isAuthenticated in syncShellAuthentication(isAuthenticated) guard isAuthenticated else { + _ = connectLaunchAttachURLIfNeeded() return } if consumePendingURLIfReady() { @@ -332,10 +338,9 @@ struct CMUXMobileRootView: View { /// sign-in that completes after mount) so the restoring gate always resolves /// even when the auth state never transitions while this view is mounted. private func reconnectStoredMacIfNeeded() { + if connectLaunchAttachURLIfNeeded() { return } guard isAuthenticated else { return } - let startedUITestAttachURL = connectUITestAttachURLIfNeeded() - guard !startedUITestAttachURL, - MobileRootAuthGate.shouldReconnectStoredMac( + guard MobileRootAuthGate.shouldReconnectStoredMac( stackAuthenticated: authManager.isAuthenticated, attachTicketAuthenticated: hasActiveAttachTicketAuthentication, connectionState: store.connectionState @@ -450,7 +455,7 @@ struct CMUXMobileRootView: View { } @discardableResult - private func connectUITestAttachURLIfNeeded() -> Bool { + private func connectLaunchAttachURLIfNeeded() -> Bool { #if DEBUG // Auto-pair when an attach URL is supplied at launch. Two sources: // - CMUX_DOGFOOD_ATTACH_URL (UITestConfig.dogfoodAttachURL): NOT gated on @@ -465,14 +470,11 @@ struct CMUXMobileRootView: View { // No-op unless one of those env vars is set, so normal launches are // unaffected. guard !didConsumeUITestAttachURL, - isAuthenticated, let attachURL = UITestConfig.dogfoodAttachURL ?? UITestConfig.attachURL else { return false } didConsumeUITestAttachURL = true - Task { - await store.connectPairingURL(attachURL) - } + connectAttachURL(attachURL) return true #else return false diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift index 41525293e8a..846f0b4807f 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift @@ -36,6 +36,10 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { /// band and pins first responder so the keyboard hands over in place; when it /// flips off, the field is unmounted and the band collapses to zero height. var isComposerActive: Bool = false + /// Whether normal-screen scrollback should move on the phone without a Mac + /// round trip. Turning this off restores host-coupled scroll for dogfood + /// comparison. + var decouplePrimaryScreenScroll: Bool = true func makeCoordinator() -> Coordinator { Coordinator(surfaceID: surfaceID, store: store) @@ -59,6 +63,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { fontSize: fontSize ) view.autoFocusOnWindowAttach = autoFocusOnWindowAttach + view.decouplePrimaryScreenScroll = decouplePrimaryScreenScroll #if DEBUG // Hand the surface the structured diagnostic log so the composer-dock // probes land in the blob the "Send to agent" feedback pane exports. @@ -88,6 +93,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { // state write, so it is safe in `updateUIView`. guard let surfaceView = uiView as? GhosttySurfaceView else { return } surfaceView.autoFocusOnWindowAttach = autoFocusOnWindowAttach + surfaceView.decouplePrimaryScreenScroll = decouplePrimaryScreenScroll surfaceView.setComposerActive(isComposerActive) context.coordinator.setComposerMounted(isComposerActive) // A width change (rotation) is not a text change, so the field-content trigger @@ -137,6 +143,14 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { for await chunk in store.terminalOutputStream(surfaceID: surfaceID) { guard !Task.isCancelled else { return } guard let surfaceView else { return } + surfaceView.applyTerminalOutputMetadata( + activeScreen: chunk.activeScreen, + scrollbackRows: chunk.scrollbackRows + ) + await surfaceView.prepareForReplayViewport( + columns: chunk.replayColumns, + rows: chunk.replayRows + ) await surfaceView.processOutputAndWait(chunk.data) store.terminalOutputDidProcess( surfaceID: surfaceID, diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift index 2c91f6a924a..275e639d326 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift @@ -22,12 +22,16 @@ public final class MobileDisplaySettings { private nonisolated(unsafe) let defaults: UserDefaults private static let wrapWorkspaceTitlesKey = "cmux.mobile.wrapWorkspaceTitles" private static let workspacePreviewLineCountKey = "cmux.mobile.workspacePreviewLineCount" + private static let decouplePrimaryScreenScrollKey = "cmux.mobile.decouplePrimaryScreenScroll" /// The preview line counts the "Preview Lines" setting offers. public static let workspacePreviewLineCountRange = 1...2 /// Default preview line count when nothing is stored (iMessage-style two /// lines). public static let defaultWorkspacePreviewLineCount = 2 + /// Default scroll mode: primary scrollback stays on the phone and only + /// alternate-screen wheel input is sent to the Mac. + public static let defaultDecouplePrimaryScreenScroll = true /// Whether workspace-list row titles wrap onto multiple lines instead of /// truncating to a single line. Defaults to `false` (single-line). Mutating @@ -48,6 +52,13 @@ public final class MobileDisplaySettings { } } + /// Whether normal-screen terminal scrollback moves locally on the phone. + /// Turning this off restores the older host round-trip path, useful for + /// comparing latency while dogfooding. + public var decouplePrimaryScreenScroll: Bool { + didSet { defaults.set(decouplePrimaryScreenScroll, forKey: Self.decouplePrimaryScreenScrollKey) } + } + /// Creates the display settings, seeding stored values from `defaults`. /// - Parameter defaults: The store backing the persisted preferences. /// Defaults to `.standard`; tests pass a scoped suite. Stored properties @@ -60,6 +71,11 @@ public final class MobileDisplaySettings { self.workspacePreviewLineCount = Self.clampedWorkspacePreviewLineCount( storedPreviewLines ?? Self.defaultWorkspacePreviewLineCount ) + if let storedDecoupledScroll = defaults.object(forKey: Self.decouplePrimaryScreenScrollKey) as? Bool { + self.decouplePrimaryScreenScroll = storedDecoupledScroll + } else { + self.decouplePrimaryScreenScroll = Self.defaultDecouplePrimaryScreenScroll + } } /// Clamps a stored or assigned preview line count to the supported range. diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 97159c61239..5b918821b99 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -126,6 +126,11 @@ struct MobileSettingsView: View { } Section(L10n.string("mobile.settings.terminal", defaultValue: "Terminal")) { + Toggle(isOn: $displaySettings.decouplePrimaryScreenScroll) { + Text(L10n.string("mobile.settings.instantTerminalScroll", defaultValue: "Instant Terminal Scroll")) + } + .accessibilityIdentifier("MobileSettingsInstantTerminalScroll") + Button { showingShortcuts = true } label: { diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index e69b2184a67..576cff5ea40 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -31,6 +31,7 @@ struct WorkspaceDetailView: View { /// workspace has an active browser surface the detail view presents a /// browser pane in place of the terminal; otherwise it shows the terminal. @Environment(BrowserSurfaceStore.self) private var browserStore + @Environment(MobileDisplaySettings.self) private var displaySettings /// Drives the destructive close-workspace confirmation dialog launched from /// the top-bar menu. Owned here (not in the menu builder) so the dialog stays /// attached to the detail view across menu open/close cycles. @@ -286,7 +287,8 @@ struct WorkspaceDetailView: View { // field). autoFocusOnWindowAttach: store.shouldAutoFocusTerminalSurface(terminalID) && !store.isComposerPresented, - isComposerActive: store.isComposerPresented + isComposerActive: store.isComposerPresented, + decouplePrimaryScreenScroll: displaySettings.decouplePrimaryScreenScroll ) // Identity must track the selected terminal. The representable's // coordinator binds its byte sink to the surfaceID at make time and diff --git a/Packages/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift b/Packages/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift index 9659d07c007..e7f7a94722f 100644 --- a/Packages/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift +++ b/Packages/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift @@ -20,6 +20,20 @@ import Testing #expect(defaults.object(forKey: "cmux.mobile.workspacePreviewLineCount") == nil) } + @Test func primaryScreenScrollDecouplingDefaultsOnWithoutAWrite() throws { + let defaults = try makeDefaults("scrollDefault") + let settings = MobileDisplaySettings(defaults: defaults) + #expect(settings.decouplePrimaryScreenScroll) + #expect(defaults.object(forKey: "cmux.mobile.decouplePrimaryScreenScroll") == nil) + } + + @Test func primaryScreenScrollDecouplingPersistsAcrossInstances() throws { + let defaults = try makeDefaults("scrollPersists") + let settings = MobileDisplaySettings(defaults: defaults) + settings.decouplePrimaryScreenScroll = false + #expect(MobileDisplaySettings(defaults: defaults).decouplePrimaryScreenScroll == false) + } + @Test func previewLineCountPersistsAcrossInstances() throws { let defaults = try makeDefaults("persists") let settings = MobileDisplaySettings(defaults: defaults) diff --git a/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift b/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift index 146b8771cea..becb6459fa6 100644 --- a/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift +++ b/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift @@ -183,12 +183,10 @@ public final class GhosttyRuntime { private static func applyiOSDefaults(_ config: ghostty_config_t) { // scrollback-limit: bound the mirror surface's local scrollback page - // memory (ghostty defaults to 10MB per surface). On iOS the user-facing - // scroll path forwards to the Mac's real surface, so local scrollback - // exists only to feed local reads (the "View as Text" copy sheet's - // GHOSTTY_POINT_SCREEN read). 2MB comfortably covers that sheet's - // 5000-line budget while keeping the worst-case read (which runs on - // the serial output queue) and per-surface memory phone-sized. + // memory (ghostty defaults to 10MB per surface). On iOS the primary + // screen scroll path is local and decoupled from the Mac after the + // replay snapshot hydrates this mirror, so the local limit is the final + // on-device cap after the Mac sends its retained scrollback. let monokai = """ scrollback-limit = 2000000 font-family = Menlo diff --git a/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+LocalScrollbackScroll.swift b/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+LocalScrollbackScroll.swift index b425f293aa8..83e11bbd852 100644 --- a/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+LocalScrollbackScroll.swift +++ b/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+LocalScrollbackScroll.swift @@ -3,23 +3,19 @@ import GhosttyKit import UIKit extension GhosttySurfaceView { - /// Apply the scroll to the phone's local Ghostty mirror immediately. On the - /// primary screen this consumes the preloaded local scrollback window, so a - /// drag/deceleration feels native while the Mac catches up. On alternate - /// screens libghostty turns this into mouse-wheel bytes; the mirror is - /// display-only and drops those bytes, so the authoritative Mac response - /// remains the visible update for TUIs. - func applyLocalScrollbackScroll(lines: Double, col: Int, row: Int) { - guard lines != 0, let surface else { return } - let displayScale = window?.windowScene?.screen.scale ?? traitCollection.displayScale - let scale = max(Double(displayScale), 1) + /// Apply a primary-screen scrollback gesture to the phone's local Ghostty + /// mirror immediately. This consumes the preloaded local scrollback window, + /// so a drag/deceleration feels native without waiting for the Mac. + func applyLocalScrollbackScroll(pixelDeltaY: Double, col: Int, row: Int) { + guard pixelDeltaY != 0, let surface else { return } let size = ghostty_surface_size(surface) - let cellWidthPt = max(Double(size.cell_width_px) / scale, 1) - let cellHeightPt = max(Double(size.cell_height_px) / scale, 1) - let posX = (Double(max(0, col)) + 0.5) * cellWidthPt - let posY = (Double(max(0, row)) + 0.5) * cellHeightPt - ghostty_surface_mouse_pos(surface, posX, posY, GHOSTTY_MODS_NONE) - ghostty_surface_mouse_scroll(surface, 0, lines, 0) + let cellHeightPx = max(Double(size.cell_height_px), 1) + let rowDelta = pixelDeltaY / cellHeightPx + localScrollRowOffset = min( + max(localScrollRowOffset - rowDelta, 0), + localScrollbackMaxRowOffset + ) + ghostty_surface_scroll_to_offset(surface, localScrollRowOffset) drawForWakeup() } } diff --git a/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index 2ee6c4f0885..d4a4063f381 100644 --- a/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -8,37 +8,6 @@ import UIKit private let log = Logger(subsystem: "ai.manaflow.cmux.ios", category: "ghostty.surface") -// lint:allow namespace-enum — file-local DEBUG input-trace logger on the off-limits typing-latency render path; type reshape deferred to the GhosttySurfaceView UI-god-object split wave. -enum TerminalInputDebugLog { - private static let isEnabled = ProcessInfo.processInfo.environment["CMUX_INPUT_DEBUG"] == "1" - private static let logger = Logger(subsystem: "ai.manaflow.cmux.ios", category: "ghostty.input") - - static func log(_ message: String) { - #if DEBUG - if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil { - return - } - #endif - guard isEnabled else { return } - logger.debug("input: \(message, privacy: .public)") - } - - static func textSummary(_ text: String) -> String { - let summary = String(reflecting: text) - guard summary.count > 96 else { return summary } - return "\(summary.prefix(96))..." - } - - static func dataSummary(_ data: Data) -> String { - let prefix = data.prefix(32) - let prefixData = Data(prefix) - let hex = prefix.map { String(format: "%02X", $0) }.joined(separator: " ") - let utf8 = String(data: prefixData, encoding: .utf8) ?? "" - let suffix = data.count > prefix.count ? " ..." : "" - return "len=\(data.count) hex=\(hex)\(suffix) utf8=\(textSummary(utf8))" - } -} - @MainActor public protocol GhosttySurfaceViewDelegate: AnyObject { func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didProduceInput data: Data) @@ -633,6 +602,12 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { private var lastAppliedContentScale: CGFloat = 0 private var surfaceHasReceivedOutput: Bool = false private var shouldScrollInitialOutputToBottom = true + private var activeScreen: MobileTerminalRenderGridFrame.Screen = .primary + var localScrollRowOffset: Double = 0 + var localScrollbackMaxRowOffset: Double = 0 + private var localScrollbackBoundsInitialized = false + private let scrollForwardingPolicy = MobileTerminalScrollForwardingPolicy() + public var decouplePrimaryScreenScroll: Bool = true /// Serial background queue for `ghostty_surface_process_output`, which /// blocks on libghostty's internal renderer/IO futex. Running it on the /// main thread hangs the app until the scene-update watchdog kills it. @@ -647,10 +622,14 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { private var scrollMechanicsIsRecentering = false private var lastScrollMechanicsOffsetY: CGFloat? private var lastScrollMechanicsTouchPoint: CGPoint = .zero + private var scrollPanLastTranslationY: CGFloat? + private var scrollInertiaVelocityY: CGFloat = 0 + private var scrollInertiaLastTimestamp: CFTimeInterval? private lazy var scrollMechanicsView: UIScrollView = { let view = UIScrollView() view.backgroundColor = .clear view.isOpaque = false + view.isScrollEnabled = false view.showsVerticalScrollIndicator = false view.showsHorizontalScrollIndicator = false view.alwaysBounceVertical = true @@ -665,9 +644,16 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { view.delegate = self return view }() + private lazy var scrollPanGesture: UIPanGestureRecognizer = { + let gesture = UIPanGestureRecognizer(target: self, action: #selector(handleScrollPan(_:))) + gesture.cancelsTouchesInView = false + gesture.delegate = self + return gesture + }() #if DEBUG private var lastInputTimestamp: CFTimeInterval = 0 private var latencySamples: [Double] = [] + private var lastScrollDecisionLogTime: CFTimeInterval = 0 var onOutputProcessedForTesting: (() -> Void)? /// DEBUG/UI-test accessibility carrier for the rendered terminal text. /// @@ -795,6 +781,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// so a transient drop self-heals; a confirmed result resets the count. private var viewportReportRetries = 0 private static let maxViewportReportRetries = 3 + private var geometryWaiters: [UUID: CheckedContinuation] = [:] /// Frames of "no zoom in progress" required before the natural grid is /// reported to the Mac. Active zoom is already gated separately /// (`zoomSettleFrames != nil` holds the report during a pinch), so this is @@ -995,6 +982,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { #endif addSubview(snapshotFallbackView) addSubview(scrollMechanicsView) + scrollMechanicsView.addGestureRecognizer(scrollPanGesture) addSubview(inputProxy) #if DEBUG addSubview(debugAccessibilityProxy) @@ -1830,19 +1818,82 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { scrollMechanicsIsRecentering = false } + @objc private func handleScrollPan(_ gesture: UIPanGestureRecognizer) { + let translationY = gesture.translation(in: self).y + switch gesture.state { + case .began: + scrollInertiaVelocityY = 0 + scrollInertiaLastTimestamp = nil + scrollPanLastTranslationY = translationY + let point = gesture.location(in: self) + if bounds.contains(point) { + lastScrollMechanicsTouchPoint = point + } + case .changed: + let previous = scrollPanLastTranslationY ?? translationY + scrollPanLastTranslationY = translationY + let fingerDeltaY = translationY - previous + let point = gesture.location(in: self) + if bounds.contains(point) { + lastScrollMechanicsTouchPoint = point + } + enqueueScrollMechanicsDelta(-fingerDeltaY, touchPoint: point) + case .ended, .cancelled, .failed: + scrollPanLastTranslationY = nil + let velocityY = gesture.velocity(in: self).y + if abs(velocityY) >= 20 { + scrollInertiaVelocityY = velocityY + scrollInertiaLastTimestamp = CACurrentMediaTime() + } else { + scrollInertiaVelocityY = 0 + scrollInertiaLastTimestamp = nil + } + default: + break + } + } + + private func applyScrollInertia(now: CFTimeInterval) { + guard scrollInertiaVelocityY != 0 else { return } + let previous = scrollInertiaLastTimestamp ?? now + let dt = max(0, min(now - previous, 1.0 / 20.0)) + scrollInertiaLastTimestamp = now + guard dt > 0 else { return } + + let fingerDeltaY = scrollInertiaVelocityY * CGFloat(dt) + let fallbackPoint = CGPoint(x: bounds.midX, y: bounds.midY) + let touchPoint = bounds.contains(lastScrollMechanicsTouchPoint) + ? lastScrollMechanicsTouchPoint + : fallbackPoint + enqueueScrollMechanicsDelta(-fingerDeltaY, touchPoint: touchPoint) + + // Matches UIScrollView.DecelerationRate.normal: approximately 0.998 + // velocity retention per millisecond, integrated on the display link. + let retention = pow(0.998, dt * 1000) + scrollInertiaVelocityY *= CGFloat(retention) + if abs(scrollInertiaVelocityY) < 8 { + scrollInertiaVelocityY = 0 + scrollInertiaLastTimestamp = nil + } + } + private func enqueueScrollMechanicsDelta(_ deltaY: CGFloat, touchPoint: CGPoint) { - // The transparent UIScrollView supplies native iOS tracking, - // deceleration, and momentum. The Mac still owns terminal semantics: - // normal-screen scrollback and alt-screen mouse-wheel delivery. + // The transparent gesture layer supplies unbounded pan deltas plus + // display-link deceleration. Primary scrollback can be consumed by the + // local Ghostty mirror; alt-screen mouse-wheel delivery still belongs + // to the Mac. guard deltaY != 0 else { return } + let scale = max(preferredScreenScale, 1) let cellHeightPt = cellPixelSize.height / max(preferredScreenScale, 1) let divisor = cellHeightPt > 1 ? Double(cellHeightPt) * 3 : 42 pendingScrollLines += -Double(deltaY) / divisor + pendingLocalScrollPixels += -Double(deltaY) * Double(scale) pendingScrollCell = scrollCell(at: touchPoint) } /// Coalesced native scroll forwarded to the Mac once per display-link frame. private var pendingScrollLines: Double = 0 + private var pendingLocalScrollPixels: Double = 0 private var pendingScrollCell: (col: Int, row: Int) = (0, 0) /// Map a touch point to a grid cell (shared effective grid with the Mac), so @@ -1857,11 +1908,39 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } private func flushPendingScrollIfNeeded() { - guard pendingScrollLines != 0 else { return } + guard pendingScrollLines != 0 || pendingLocalScrollPixels != 0 else { return } let lines = pendingScrollLines + let pixelDeltaY = pendingLocalScrollPixels let cell = pendingScrollCell pendingScrollLines = 0 - applyLocalScrollbackScroll(lines: lines, col: cell.col, row: cell.row) + pendingLocalScrollPixels = 0 + let appliesLocally = scrollForwardingPolicy.shouldApplyLocally( + activeScreen: activeScreen, + decouplePrimaryScreenScroll: decouplePrimaryScreenScroll + ) + let forwardsToHost = scrollForwardingPolicy.shouldForwardToHost( + activeScreen: activeScreen, + decouplePrimaryScreenScroll: decouplePrimaryScreenScroll + ) + #if DEBUG + let now = CACurrentMediaTime() + if now - lastScrollDecisionLogTime >= 0.12 { + lastScrollDecisionLogTime = now + MobileDebugLog.anchormux( + "mobile.scroll.decision screen=\(activeScreen.rawValue) decouple=\(decouplePrimaryScreenScroll) " + + "local=\(appliesLocally) host=\(forwardsToHost) " + + "px=\(String(format: "%.1f", pixelDeltaY)) lines=\(String(format: "%.2f", lines)) " + + "cell=\(cell.col),\(cell.row)" + ) + } + #endif + if appliesLocally { + applyLocalScrollbackScroll(pixelDeltaY: pixelDeltaY, col: cell.col, row: cell.row) + } + guard forwardsToHost else { + return + } + guard lines != 0 else { return } delegate?.ghosttySurfaceView(self, didScrollLines: lines, atCol: cell.col, row: cell.row) } @@ -2160,6 +2239,66 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { processOutput(data, completion: nil) } + /// Applies metadata attached to the next terminal output chunk. + /// + /// Render-grid output carries the authoritative active screen, which lets + /// local scrollback stay phone-local on the primary screen while alternate + /// screen TUIs still receive host mouse-wheel events. + /// - Parameter activeScreen: The active screen from the render-grid frame, + /// or `nil` for raw byte fallback chunks. + public func applyTerminalOutputMetadata( + activeScreen: MobileTerminalRenderGridFrame.Screen?, + scrollbackRows: Int? = nil + ) { + if let activeScreen { + self.activeScreen = activeScreen + } + guard let scrollbackRows else { return } + let previousMax = localScrollbackMaxRowOffset + let wasAtBottom = !localScrollbackBoundsInitialized + || abs(localScrollRowOffset - previousMax) < 0.5 + localScrollbackMaxRowOffset = Double(max(0, scrollbackRows)) + localScrollbackBoundsInitialized = true + if wasAtBottom || localScrollRowOffset > localScrollbackMaxRowOffset { + localScrollRowOffset = localScrollbackMaxRowOffset + } + } + + /// Ensure the local Ghostty mirror is sized to the replay grid before a full + /// render-grid snapshot is fed into it. Full replay constructs scrollback by + /// flowing lines through Ghostty; applying it before the final effective grid + /// lets the later resize collapse the mirror back to a viewport-only screen. + public func prepareForReplayViewport(columns: Int?, rows: Int?) async { + guard let columns, let rows, columns > 0, rows > 0 else { return } + if replayViewportReady(columns: columns, rows: rows) { return } + applyViewSize(cols: columns, rows: rows) + if replayViewportReady(columns: columns, rows: rows) { return } + guard window != nil else { return } + let waiterID = UUID() + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + geometryWaiters[waiterID] = continuation + setNeedsGeometrySync(reassertNaturalSize: false) + } + } onCancel: { [weak self] in + Task { @MainActor [weak self] in + self?.cancelGeometryWaiter(id: waiterID) + } + } + } + + private func replayViewportReady(columns: Int, rows: Int) -> Bool { + effectiveGrid?.cols == columns && + effectiveGrid?.rows == rows && + cellPixelSize.width > 0 && + cellPixelSize.height > 0 && + !lastRenderRect.isEmpty + } + + private func cancelGeometryWaiter(id: UUID) { + geometryWaiters.removeValue(forKey: id)?.resume() + } + /// Process terminal output and return after the output has been applied. /// /// The call still performs libghostty output processing on the serial @@ -2668,7 +2807,8 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } } - // Flush coalesced scroll to the Mac at most once per frame. + // Flush coalesced scroll at most once per frame. + applyScrollInertia(now: now) flushPendingScrollIfNeeded() // Fade the zoom HUD once interaction has been quiet. Uses real elapsed @@ -3101,6 +3241,11 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // behind (see display link). pendingRenderFrames = 6 syncSnapshotFallback() + let waiters = Array(geometryWaiters.values) + geometryWaiters.removeAll(keepingCapacity: true) + for waiter in waiters { + waiter.resume() + } let naturalSize = result.naturalSize let effectiveMatchesNatural = effectiveGrid.map { grid in @@ -3505,9 +3650,8 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } extension GhosttySurfaceView: UIGestureRecognizerDelegate { - /// Keep a tap that lands on the visible zoom HUD from also focusing the - /// terminal (which would pop the keyboard). Only the focus tap carries this - /// delegate, so scroll/pinch are unaffected. + /// Keep gestures that land on surface-owned controls from also focusing or + /// scrolling the terminal. public func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch @@ -3526,6 +3670,13 @@ extension GhosttySurfaceView: UIGestureRecognizerDelegate { } return true } + + public func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + true + } } extension GhosttySurfaceView: UIScrollViewDelegate { diff --git a/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputDebugLog.swift b/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputDebugLog.swift new file mode 100644 index 00000000000..aa067adb429 --- /dev/null +++ b/Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputDebugLog.swift @@ -0,0 +1,35 @@ +#if canImport(UIKit) +import Foundation +import OSLog + +// lint:allow namespace-enum — DEBUG input-trace logger on the off-limits typing-latency render path; type reshape deferred to the GhosttySurfaceView UI-god-object split wave. +enum TerminalInputDebugLog { + private static let isEnabled = ProcessInfo.processInfo.environment["CMUX_INPUT_DEBUG"] == "1" + private static let logger = Logger(subsystem: "ai.manaflow.cmux.ios", category: "ghostty.input") + + static func log(_ message: String) { + #if DEBUG + if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil { + return + } + #endif + guard isEnabled else { return } + logger.debug("input: \(message, privacy: .public)") + } + + static func textSummary(_ text: String) -> String { + let summary = String(reflecting: text) + guard summary.count > 96 else { return summary } + return "\(summary.prefix(96))..." + } + + static func dataSummary(_ data: Data) -> String { + let prefix = data.prefix(32) + let prefixData = Data(prefix) + let hex = prefix.map { String(format: "%02X", $0) }.joined(separator: " ") + let utf8 = String(data: prefixData, encoding: .utf8) ?? "" + let suffix = data.count > prefix.count ? " ..." : "" + return "len=\(data.count) hex=\(hex)\(suffix) utf8=\(textSummary(utf8))" + } +} +#endif diff --git a/Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/MobileTerminalScrollForwardingPolicy.swift b/Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/MobileTerminalScrollForwardingPolicy.swift new file mode 100644 index 00000000000..074aca42043 --- /dev/null +++ b/Packages/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/MobileTerminalScrollForwardingPolicy.swift @@ -0,0 +1,30 @@ +public import CMUXMobileCore + +/// Decides whether a mobile terminal scroll gesture must be sent to the Mac. +public struct MobileTerminalScrollForwardingPolicy: Sendable { + /// Creates the forwarding policy. + public init() {} + + /// Returns whether a scroll should be forwarded to the host surface. + /// + /// Primary-screen scrollback is already mirrored into the phone's local + /// Ghostty surface, so forwarding would make scroll feel network-bound. + /// Alternate-screen scroll must still reach the host so TUIs with mouse + /// reporting receive wheel events. + /// - Parameter activeScreen: The screen currently rendered by the mobile + /// Ghostty mirror. + /// - Returns: `true` when the scroll should be sent to the Mac. + public func shouldApplyLocally( + activeScreen: MobileTerminalRenderGridFrame.Screen, + decouplePrimaryScreenScroll: Bool + ) -> Bool { + decouplePrimaryScreenScroll && activeScreen == .primary + } + + public func shouldForwardToHost( + activeScreen: MobileTerminalRenderGridFrame.Screen, + decouplePrimaryScreenScroll: Bool + ) -> Bool { + activeScreen == .alternate || !decouplePrimaryScreenScroll + } +} diff --git a/Packages/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/MobileTerminalScrollForwardingPolicyTests.swift b/Packages/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/MobileTerminalScrollForwardingPolicyTests.swift new file mode 100644 index 00000000000..63149cb420d --- /dev/null +++ b/Packages/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/MobileTerminalScrollForwardingPolicyTests.swift @@ -0,0 +1,26 @@ +import CMUXMobileCore +import CmuxMobileTerminalKit +import Testing + +@Suite struct MobileTerminalScrollForwardingPolicyTests { + @Test func primaryScreenScrollStaysLocal() { + let policy = MobileTerminalScrollForwardingPolicy() + + #expect(policy.shouldApplyLocally(activeScreen: .primary, decouplePrimaryScreenScroll: true)) + #expect(policy.shouldForwardToHost(activeScreen: .primary, decouplePrimaryScreenScroll: true) == false) + } + + @Test func primaryScreenScrollCanUseHostRoundTripForComparison() { + let policy = MobileTerminalScrollForwardingPolicy() + + #expect(policy.shouldApplyLocally(activeScreen: .primary, decouplePrimaryScreenScroll: false) == false) + #expect(policy.shouldForwardToHost(activeScreen: .primary, decouplePrimaryScreenScroll: false)) + } + + @Test func alternateScreenScrollForwardsToHost() { + let policy = MobileTerminalScrollForwardingPolicy() + + #expect(policy.shouldApplyLocally(activeScreen: .alternate, decouplePrimaryScreenScroll: true) == false) + #expect(policy.shouldForwardToHost(activeScreen: .alternate, decouplePrimaryScreenScroll: true)) + } +} diff --git a/Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swift b/Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swift index d3154048d85..f1888569092 100644 --- a/Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swift +++ b/Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swift @@ -69,6 +69,15 @@ extension TerminalSurface { additionalEnvironment } + /// Test-only helper to drive Ghostty scrollback by fractional row offset. + @MainActor + public func debugScrollToRowOffsetForTesting(_ rowOffset: Double) -> Bool { + guard let surface else { return false } + ghostty_surface_scroll_to_offset(surface, rowOffset) + forceRefresh(reason: "debugScrollToRowOffsetForTesting") + return true + } + /// How many force refreshes ran since the last reset. public func debugForceRefreshCount() -> Int { debugForceRefreshCountLock.lock() diff --git a/Sources/TerminalController+ControlDebugContext.swift b/Sources/TerminalController+ControlDebugContext.swift index b4d83a02c6e..aa285bd6484 100644 --- a/Sources/TerminalController+ControlDebugContext.swift +++ b/Sources/TerminalController+ControlDebugContext.swift @@ -69,6 +69,19 @@ extension TerminalController: ControlDebugContext { readTerminalText(surfaceArgument) } + func controlDebugTerminalScrollToRowOffset(surfaceArgument: String, rowOffset: Double) -> Bool { + guard let tabManager else { return false } + let trimmed = surfaceArgument.trimmingCharacters(in: .whitespacesAndNewlines) + let panel: TerminalPanel? + if trimmed.isEmpty { + panel = tabManager.selectedTerminalPanel + } else { + panel = resolveTerminalPanel(from: trimmed, tabManager: tabManager) + } + guard let panel else { return false } + return panel.surface.debugScrollToRowOffsetForTesting(rowOffset) + } + func controlDebugRenderStats(surfaceArgument: String) -> String { renderStats(surfaceArgument) } diff --git a/Sources/TerminalController+MobileScrollPrefetch.swift b/Sources/TerminalController+MobileScrollPrefetch.swift index c0b07f45dfe..b4afd320636 100644 --- a/Sources/TerminalController+MobileScrollPrefetch.swift +++ b/Sources/TerminalController+MobileScrollPrefetch.swift @@ -6,11 +6,11 @@ extension TerminalController { /// Live render-grid events carry no scrollback; the phone keeps its own /// bounded Ghostty scrollback mirror and scrolls that mirror locally while /// the Mac remains authoritative. - nonisolated static let mobileReplayScrollbackLineBudget = 240 + nonisolated static let mobileReplayScrollbackLineBudget = MobileTerminalScrollbackBudget.defaultReplayRows /// Larger history window returned only on explicit mobile scroll prefetch /// requests, keeping ordinary scroll RPCs small. - nonisolated static let mobileScrollPrefetchScrollbackLineBudget = 600 + nonisolated static let mobileScrollPrefetchScrollbackLineBudget = MobileTerminalScrollbackBudget.scrollPrefetchRows func mobileTerminalRenderGridFrame( terminalPanel: TerminalPanel, @@ -55,11 +55,30 @@ extension TerminalController { return payload } - private func mobileScrollPrefetchRows(params: [String: Any]) -> Int { - let requestedRows = (params["max_scrollback_rows"] as? NSNumber)?.intValue ?? 0 + func mobileScrollPrefetchRows(params: [String: Any]) -> Int { + let requestedRows = mobileRequestedScrollbackRows(params: params) return min( max(0, requestedRows), Self.mobileScrollPrefetchScrollbackLineBudget ) } + + func mobileReplayScrollbackRows(params: [String: Any]) -> Int { + if mobileRequestedScrollbackScope(params: params) == MobileTerminalScrollbackReplayRequest.fullScope { + return Int.max + } + let requestedRows = mobileRequestedScrollbackRows(params: params) + guard requestedRows > 0 else { + return Self.mobileReplayScrollbackLineBudget + } + return requestedRows + } + + private func mobileRequestedScrollbackRows(params: [String: Any]) -> Int { + (params["max_scrollback_rows"] as? NSNumber)?.intValue ?? 0 + } + + private func mobileRequestedScrollbackScope(params: [String: Any]) -> String? { + params[MobileTerminalScrollbackReplayRequest.scopeParameter] as? String + } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 6ea874349bb..158db281ee0 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -14254,13 +14254,20 @@ class TerminalController { } let state = MobileTerminalByteTee.shared.replayState(surfaceID: surfaceId) let seq = state?.seq ?? 0 + let requestedScrollbackRows = mobileReplayScrollbackRows(params: params) let renderGrid = mobileTerminalRenderGridFrame( terminalPanel: terminalPanel, surfaceID: surfaceId, - seq: seq + seq: seq, + scrollbackLines: requestedScrollbackRows ) #if DEBUG - cmuxDebugLog("mobile.terminal.replay surface=\(surfaceId.uuidString.prefix(8)) renderGrid=\(renderGrid != nil) seq=\(seq) hasState=\(state != nil)") + cmuxDebugLog( + "mobile.terminal.replay surface=\(surfaceId.uuidString.prefix(8)) renderGrid=\(renderGrid != nil) " + + "rows=\(renderGrid?.rows ?? -1) scrollbackRows=\(renderGrid?.scrollbackRows ?? -1) " + + "spans=\(renderGrid?.rowSpans.count ?? -1) scrollbackSpans=\(renderGrid?.scrollbackSpans.count ?? -1) " + + "seq=\(seq) hasState=\(state != nil)" + ) #endif var payload: [String: Any] = [ "workspace_id": resolved.workspace.id.uuidString, diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index e3598bd631e..4e7bfa34263 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -12,7 +12,27 @@ When we change the fork, update this document and the parent submodule SHA. ## Current fork changes -Current cmux pinned fork head: `5697db81`, which adds the Darwin-only +Current cmux pinned fork head: `eac310b72`, which adds precision pixel-scroll +rendering for primary-screen scrollback on top of `5697db81` and lets macOS +native live scroll submit a fractional row offset directly to Ghostty. +Precision scroll input now accumulates a fractional pixel offset, advances the +terminal viewport only when a full row boundary is crossed, and passes the +remainder through the renderer state to Metal/OpenGL shaders so backgrounds, +text, and images translate between rows. The render state preloads one +render-only guard row above and below the visible viewport when available, then +shifts the shader uniform by the guard-row origin so fractional motion reveals +real adjacent rows instead of empty edge pixels. Boundary clamping preserves +fractional motion that moves back into scrollback from top or bottom, and only +zeros the fractional remainder when it would overscroll past the boundary. cmux +iOS uses this for local scrollback on non-alt terminal content without waiting +for a host round trip, and macOS uses the same renderer path while AppKit +remains the native scroll gesture owner. The renderer-space pixel-scroll +invariant is that positive `pixel_scroll_offset_y` moves rendered cells upward, +matching positive fractional row offsets from the top of scrollback. The patch intentionally avoids the +unrelated Neovim GUI, cursor animation, and visual-effect changes in +parkers0405/ghostty-pixel-scroll. + +The previous head was `5697db81`, which adds the Darwin-only `ghostty_surface_set_renderer_realized` C API (a `display_realized` renderer-thread mailbox message that drives `displayUnrealized()`/`displayRealized()`) on top of `34cbf180d`. cmux uses it to release an occluded terminal's GPU renderer @@ -26,7 +46,7 @@ published at https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-5697db813b1b0fe14873093e9028f36513ddc187-crashsubdir-cmux-crash-v1 and pinned in `scripts/ghosttykit-checksums.txt`. -The prior head was refreshed from upstream `main` on May 1, 2026. +The `5697db81` head was refreshed from upstream `main` on May 1, 2026. Earlier cmux pinned fork head: `34cbf180d`, merging the surface registry serialization for https://github.com/manaflow-ai/cmux/issues/5458 (`e5c962a72`, landed on cmux `main`) into the iOS render bounded-acquire line (`f78189ac1`) @@ -47,7 +67,83 @@ The corresponding prebuilt archive is published at https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-34cbf180d8917b802d61d9929cfb493594f2ab52-crashsubdir-cmux-crash-v1 and pinned in `scripts/ghosttykit-checksums.txt`. -### 1) macOS display link restart on display changes +### 0) macOS fractional row-offset scroll forwarding + +- Commits: + - `b61a016d` (Forward macOS live scroll as precision input) + - `a0f40f77` (Forward macOS wheel events as precision input) + - `f644b4c10` (Drive macOS scrollback by fractional row offset) + - `55b399077` (Fix fractional scroll renderer direction) + - `5829274d4` (Preload guard rows for smooth pixel scrolling) + - `eac310b72` (Clamp pixel scroll only past viewport boundaries) +- Files: + - `include/ghostty.h` + - `src/Surface.zig` + - `src/apprt/embedded.zig` + - `macos/Sources/Ghostty/Ghostty.Surface.swift` + - `macos/Sources/Ghostty/Surface View/SurfaceScrollView.swift` +- Summary: + - Adds `ghostty_surface_scroll_to_offset`, a C API that takes a fractional + row offset from the top of primary-screen scrollback. + - Ghostty clamps the offset, scrolls the terminal viewport to the integer row, + and writes the fractional remainder into renderer state as a pixel offset. + - `SurfaceScrollView` keeps AppKit as the native gesture and scrollbar owner: + live-scroll notifications read the current `NSScrollView` position and call + `scroll(toRowOffset:)`. + - A reverse-engineered `Ghostty 1.3.1-scroll` DMG exposed the same ownership + shape through symbols such as `handleLiveScroll(force:)`, + `handleEndLiveScroll()`, `currentRowOffset()`, and + `ghostty_surface_scroll_to_offset`. + - The previous `a0f40f77` wheel-event forwarding approach is superseded. It + made AppKit and Ghostty both interpret the same scroll gesture, so desktop + still felt row-stepped instead of continuously position-driven. +- Conflict notes: + - Preserve `ghostty_surface_scroll_to_offset` and the invariant that terminal + viewport row and renderer pixel remainder are updated together. + - Preserve the renderer-space sign invariant: positive `pixel_scroll_offset_y` + moves rendered cells upward, so fractional offsets and wheel residuals keep + moving in the same direction as committed whole-row viewport changes. + - Preserve the row-based sync path for scrollbar state coming from the core; + only user live scrolling should submit fractional offsets from AppKit. + - Preserve render-state overscan for the fractional scroll path. `rows` stays + the visible terminal height, while `row_data` may include render-only guard + rows and `viewport_row` maps visible viewport y=0 into that render window. + - If upstream changes `SurfaceScrollView`, keep AppKit as the owner of gesture + position and Ghostty as the owner of terminal/render state. + +### 1) Precision pixel-scroll rendering + +- Commit: `5c89072b` (Add precision pixel scroll rendering) +- Files: + - `src/Surface.zig` + - `src/renderer/State.zig` + - `src/renderer/generic.zig` + - `src/renderer/metal/shaders.zig` + - `src/renderer/opengl/shaders.zig` + - `src/renderer/shaders/shaders.metal` + - `src/renderer/shaders/glsl/common.glsl` + - `src/renderer/shaders/glsl/cell_bg.f.glsl` + - `src/renderer/shaders/glsl/cell_text.v.glsl` + - `src/renderer/shaders/glsl/image.v.glsl` +- Summary: + - Adds a primary-screen precision scroll accumulator that stores sub-row + scrollback movement in pixels and advances the terminal viewport only after + crossing whole cell boundaries. + - Mirrors the fractional remainder into renderer state and a shader uniform. + - Moves cell text and images by the fractional offset while sampling + backgrounds from the shifted position, giving iOS-local scrollback continuous + movement instead of row-stepped movement. + - Keeps normal line scroll behavior as the fallback and resets the pixel + offset when falling back to line-based viewport scroll. +- Conflict notes: + - Inspired by `parkers0405/ghostty-pixel-scroll`, but limited to terminal + precision scrollback. Do not wholesale merge Parker's fork without separating + unrelated Neovim GUI, animation, cursor, and visual-effect changes. + - The renderer now requests one row of render-only overscan above and below + the visible viewport when available, preventing first/last row popping while + preserving the terminal's visible row count. + +### 2) macOS display link restart on display changes - Commit: `05cf31b38` (macos: restart display link after display ID change) - Files: @@ -56,7 +152,7 @@ and pinned in `scripts/ghosttykit-checksums.txt`. - Restarts the CVDisplayLink when `setMacOSDisplayID` updates the current CGDisplay. - Prevents a rare state where vsync is "running" but no callbacks arrive, which can look like a frozen surface until focus/occlusion changes. -### 2) macOS resize stale-frame mitigation +### 3) macOS resize stale-frame mitigation The resize commits are grouped by feature because they touch the same stale-frame replay path and tend to conflict together during rebases. @@ -75,7 +171,7 @@ tend to conflict together during rebases. - Replays the last rendered frame during resize and keeps its geometry anchored correctly. - Reduces transient blank or scaled frames while a macOS window is being resized. -### 3) OSC 99 (kitty) notification parser +### 4) OSC 99 (kitty) notification parser - Commits: - `2033ffebc` (Add OSC 99 notification parser) @@ -88,7 +184,7 @@ tend to conflict together during rebases. - Adds a parser for kitty OSC 99 notifications and wires it into the OSC dispatcher. - Adapts the parser to upstream's newer capture API so the cmux OSC 99 hook survives the March 30 upstream sync. -### 4) cmux theme picker helper hooks +### 5) cmux theme picker helper hooks - Commits: - `66ff6ec4d` (Add cmux theme picker helper hooks) @@ -113,7 +209,7 @@ tend to conflict together during rebases. - Applies the highlighted search result when Enter is pressed from search mode in cmux-managed picker sessions. - Supports Ctrl-N and Ctrl-P as one-row down/up navigation in cmux-managed picker sessions. -### 5) Color scheme mode 2031 reporting +### 6) Color scheme mode 2031 reporting - Commits: - `2be58ee0e` (Fix DECRPM mode 2031 reporting wrong color scheme) @@ -125,7 +221,7 @@ tend to conflict together during rebases. - Keeps Ghostty's mode 2031 color-scheme response aligned with the surface's actual conditional state after config reloads. - Sends the initial DSR 997 report as soon as mode 2031 is enabled, which cmux relies on for immediate color-scheme awareness. -### 6) Keyboard copy mode selection C API +### 7) Keyboard copy mode selection C API - Commit: `0b231db94` (Re-export cmux selection APIs removed from upstream) - Files: @@ -136,7 +232,7 @@ tend to conflict together during rebases. - Restores `ghostty_surface_select_cursor_cell` and `ghostty_surface_clear_selection`. - Keeps cmux keyboard copy mode working against the refreshed Ghostty base after upstream removed those exports. -### 7) macos-background-from-layer config flag +### 8) macos-background-from-layer config flag - Commits: - `ae3cc5d29` (Restore macOS layer background hook) @@ -153,7 +249,7 @@ tend to conflict together during rebases. - Allows the host app to provide the terminal background via `CALayer.backgroundColor` for instant coverage during view resizes, avoiding alpha double-stacking. - Replays the layer-background restore on top of the refreshed Ghostty base so cmux keeps the resize-coverage fix after the upstream sync. -### 8) TerminalStream kitty graphics APC handling +### 9) TerminalStream kitty graphics APC handling - Commit: `a8e92c9c5` (terminal: add APC handler to stream_terminal) - Files: @@ -162,7 +258,7 @@ tend to conflict together during rebases. - Wires `.apc_start`, `.apc_put`, and `.apc_end` through the shared APC parser in `TerminalStream`. - Restores kitty graphics execution and APC OK/error replies for the non-termio stream path used by cmux/libghostty integrations. -### 9) Config load string C API +### 10) Config load string C API - Commit: `f7880c473` (Add config load string C API) - Files: @@ -173,7 +269,7 @@ tend to conflict together during rebases. - Adds a C API for loading Ghostty config from an in-memory string. - Lets cmux parse generated or override config without materializing a separate config file first. -### 10) Manual embedded IO for libghostty iOS +### 11) Manual embedded IO for libghostty iOS - Commit: `22fa801f8` (Expose manual embedded IO for iOS) - PR: https://github.com/manaflow-ai/ghostty/pull/53 @@ -200,7 +296,7 @@ tend to conflict together during rebases. render-now C API, or output C API. Upstream already has internal `Termio.processOutput`, so prefer an upstream C bridge if one lands. -### 11) Metal renderer preedit row rebuild guard +### 12) Metal renderer preedit row rebuild guard - Commits: - `70b95dada` (Expose unsafe preedit catch-up in renderer rows) @@ -216,7 +312,7 @@ tend to conflict together during rebases. - The first commit intentionally preserves the panic so cmux can keep the required failing-test-then-fix history for https://github.com/manaflow-ai/cmux/issues/3369. -### 12) URL/path regex bounds for spaced file paths +### 13) URL/path regex bounds for spaced file paths - Commits: - `6e10706a7` (test: cover spaced file path link bounds) @@ -234,7 +330,7 @@ tend to conflict together during rebases. - Preserves versioned or dotted path components before the first space, such as `/tmp/v1.2 captures/video.mp4`. -### 13) Cmd-click opens links under mouse reporting (alt-screen TUIs) +### 14) Cmd-click opens links under mouse reporting (alt-screen TUIs) - Commits (manaflow-ai/ghostty#71, by @doronpr): - `1c7613c95` (fix: open terminal links on cmd-click even when mouse reporting is active) @@ -306,7 +402,7 @@ tend to conflict together during rebases. user who reconfigures `link.highlight.hover_mods` to a non-default chord would not get the under-mouse-reporting bypass. Out of scope for #5128. -### 14) Embedded surface registry serialization +### 15) Embedded surface registry serialization - Commits: - `c9b61a8af` (Add surface registry mutation serialization test) @@ -326,17 +422,14 @@ tend to conflict together during rebases. `App.focusedSurface`, or the embedded surface close path should preserve serialization of registry/focus mutation across create and free. -The current cmux pin is the merged head `34cbf180d`, which merges the surface -registry serialization (`e5c962a72`, section 14, landed on cmux `main` via -branch `issue-5458-surface-registry-lock`) into the Cmd-click link fix line -(`df789cd4b`, section 13) on top of the iOS render bounded-acquire pin +The old cmux pin `34cbf180d` merged the surface registry serialization +(`e5c962a72`, now section 15, landed on cmux `main` via branch +`issue-5458-surface-registry-lock`) into the Cmd-click link fix line +(`df789cd4b`, now section 14) on top of the iOS render bounded-acquire pin (`f78189ac1`). It is reachable from `manaflow-ai/ghostty` through branch `issue-5128-alt-screen-link-open`. Published `xcframework-34cbf180d8917b802d61d9929cfb493594f2ab52-crashsubdir-cmux-crash-v1` -and pinned its archive checksum in `scripts/ghosttykit-checksums.txt`. The -release and checksum pin must be regenerated whenever this commit changes, even -for comment-only amends, because the release tag is keyed by the Ghostty commit -SHA. +and pinned its archive checksum in `scripts/ghosttykit-checksums.txt`. ## Upstreamed fork changes diff --git a/ghostty b/ghostty index 05c3e2908f9..eac310b7276 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 05c3e2908f95892b180c3e5d770abe7a88b42c42 +Subproject commit eac310b72762529fd024594d85a899979aea3ad5 diff --git a/ios/Config/Info.plist b/ios/Config/Info.plist index 01de09a28e5..9152b353c18 100644 --- a/ios/Config/Info.plist +++ b/ios/Config/Info.plist @@ -16,6 +16,10 @@ $(CMUX_DEV_TAG) CMUXGitSHA $(CMUX_GIT_SHA) + CMUXApiBaseURL + $(CMUX_API_BASE_URL) + CMUXMobileDemoMode + $(CMUX_MOBILE_DEMO_MODE) CFBundleName $(PRODUCT_NAME) CFBundlePackageType @@ -69,8 +73,10 @@ ONLY WKWebView web content from ATS; the app's own API/auth/pairing traffic stays under ATS and still requires HTTPS. NSAllowsLocalNetworking alone would not cover routable private-LAN dev servers. --> - NSAllowsArbitraryLoadsInWebContent - + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsInWebContent + UIApplicationSceneManifest diff --git a/ios/Config/Shared.xcconfig b/ios/Config/Shared.xcconfig index 3c9b3238e84..879ea21b9ad 100644 --- a/ios/Config/Shared.xcconfig +++ b/ios/Config/Shared.xcconfig @@ -23,6 +23,7 @@ CURRENT_PROJECT_VERSION = 1 // CMUXGitSHA / CMUXDevTag Info.plist keys that AppVersionInfo reads. CMUX_GIT_SHA = CMUX_DEV_TAG = +CMUX_MOBILE_DEMO_MODE = // ========================================== // Platform Configuration diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index ba57b188daf..a9d9b826d43 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -1871,6 +1871,23 @@ } } }, + "mobile.settings.instantTerminalScroll": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Instant Terminal Scroll" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "即時ターミナルスクロール" + } + } + } + }, "mobile.settings.switchMac": { "extractionState": "manual", "localizations": { diff --git a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift index e81ead53a44..835bd547d80 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift @@ -189,7 +189,9 @@ public struct CMUXMobileRootScene: View { private var content: some View { #if os(iOS) #if DEBUG - if ProcessInfo.processInfo.environment["CMUX_ZOOM_STRESS"] == "1" { + if Self.mobileDemoMode == "science" { + ScienceDemoTerminalView() + } else if ProcessInfo.processInfo.environment["CMUX_ZOOM_STRESS"] == "1" { MobileZoomStressView() } else { CMUXMobileAppView(store: makeStore(), onboardingStore: onboardingStore) @@ -202,6 +204,16 @@ public struct CMUXMobileRootScene: View { #endif } + #if os(iOS) && DEBUG + private static var mobileDemoMode: String { + let infoValue = Bundle.main.object(forInfoDictionaryKey: "CMUXMobileDemoMode") as? String + let envValue = ProcessInfo.processInfo.environment["CMUX_MOBILE_DEMO_MODE"] + return (infoValue ?? envValue ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + } + #endif + @MainActor private func makeStore() -> CMUXMobileShellStore { let identityProvider = AuthCoordinatorIdentityProvider(coordinator: auth.coordinator) diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileAuthComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileAuthComposition.swift index bde5bc429e8..2b06fd11e31 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileAuthComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileAuthComposition.swift @@ -140,7 +140,7 @@ public struct MobileAuthComposition { private static func localConfigStringOverrides(in bundle: Bundle) -> [String: String] { guard let path = bundle.path(forResource: "LocalConfig", ofType: "plist"), let dict = NSDictionary(contentsOfFile: path) as? [String: Any] else { - return [:] + return infoPlistStringOverrides(in: bundle) } var overrides: [String: String] = [:] for (key, value) in dict { @@ -151,6 +151,18 @@ public struct MobileAuthComposition { } } } + for (key, value) in infoPlistStringOverrides(in: bundle) where overrides[key] == nil { + overrides[key] = value + } return overrides } + + private static func infoPlistStringOverrides(in bundle: Bundle) -> [String: String] { + guard let apiBaseURL = bundle.object(forInfoDictionaryKey: "CMUXApiBaseURL") as? String else { + return [:] + } + let trimmed = apiBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !trimmed.hasPrefix("$(") else { return [:] } + return ["ApiBaseURL": trimmed] + } } diff --git a/ios/cmuxPackage/Sources/cmuxFeature/ScienceDemoTerminalView.swift b/ios/cmuxPackage/Sources/cmuxFeature/ScienceDemoTerminalView.swift new file mode 100644 index 00000000000..408b513961c --- /dev/null +++ b/ios/cmuxPackage/Sources/cmuxFeature/ScienceDemoTerminalView.swift @@ -0,0 +1,124 @@ +#if canImport(UIKit) && DEBUG +import CMUXMobileCore +import CmuxMobileTerminal +import SwiftUI +import UIKit + +struct ScienceDemoTerminalView: View { + var body: some View { + ScienceDemoTerminalSurface() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background { + Color(red: 0x27 / 255.0, green: 0x28 / 255.0, blue: 0x22 / 255.0) + .ignoresSafeArea(.container, edges: [.horizontal, .bottom]) + } + .ignoresSafeArea(.container, edges: .bottom) + .ignoresSafeArea(.keyboard, edges: .bottom) + } +} + +private struct ScienceDemoTerminalSurface: UIViewRepresentable { + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeUIView(context: Context) -> UIView { + guard let runtime = try? GhosttyRuntime.shared() else { + let label = UILabel() + label.numberOfLines = 0 + label.textColor = .white + label.backgroundColor = UIColor(red: 0x27 / 255.0, green: 0x28 / 255.0, blue: 0x22 / 255.0, alpha: 1) + label.text = "Ghostty runtime failed to initialize." + return label + } + + let view = GhosttySurfaceView( + runtime: runtime, + delegate: context.coordinator, + fontSize: MobileTerminalFontPreference.defaultSize + ) + view.autoFocusOnWindowAttach = false + view.hostSurfaceID = "science-demo-terminal" + context.coordinator.surfaceView = view + context.coordinator.start() + return view + } + + func updateUIView(_ uiView: UIView, context: Context) {} + + static func dismantleUIView(_ uiView: UIView, coordinator: Coordinator) { + coordinator.stop() + (uiView as? GhosttySurfaceView)?.prepareForDismantle() + } + + @MainActor + final class Coordinator: NSObject, GhosttySurfaceViewDelegate { + weak var surfaceView: GhosttySurfaceView? + private var task: Task? + + func start() { + task?.cancel() + task = Task { @MainActor [weak self] in + guard let self, let surfaceView else { return } + await surfaceView.processOutputAndWait(Self.initialFrame) + await surfaceView.processOutputAndWait(Self.historyBlock(start: 1, count: 260)) + for index in 261...320 { + guard !Task.isCancelled else { return } + try? await Task.sleep(nanoseconds: 18_000_000) + await surfaceView.processOutputAndWait(Self.sampleLine(index)) + } + await surfaceView.processOutputAndWait(Self.footer) + } + } + + func stop() { + task?.cancel() + task = nil + } + + func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didProduceInput data: Data) {} + func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didResize size: TerminalGridSize) {} + + private static var initialFrame: Data { + var text = "\u{1b}[2J\u{1b}[H" + text += "\u{1b}[38;5;81mcmux mobile science demo\u{1b}[0m\r\n" + text += "\u{1b}[38;5;245mGhostty renderer, local demo feed, no auth, no pairing, no host round trip.\u{1b}[0m\r\n\r\n" + text += "\u{1b}[1mexperiment\u{1b}[0m smooth primary scrollback on iPhone\r\n" + text += "\u{1b}[1mobservation\u{1b}[0m touch scroll stays local until a real TUI alternate screen needs host wheel input\r\n" + text += "\u{1b}[1mtransport\u{1b}[0m canned PTY bytes into the same Ghostty surface used by the live app\r\n\r\n" + text += "index timestamp signal value note\r\n" + text += "----- ------------- ----------- ---------- -----------------------------\r\n" + return Data(text.utf8) + } + + private static func historyBlock(start: Int, count: Int) -> Data { + var data = Data() + data.reserveCapacity(count * 96) + for index in start..<(start + count) { + data.append(sampleLine(index)) + } + return data + } + + private static func sampleLine(_ index: Int) -> Data { + let signal = ["render", "scroll", "input", "latency", "viewport"][index % 5] + let paddedSignal = signal.padding(toLength: 11, withPad: " ", startingAt: 0) + let color = [82, 45, 214, 208, 141][index % 5] + let value = String(format: "%.3f", Double(index * 37 % 997) / 997.0) + let text = String( + format: "%5d T+%05dms \u{1b}[38;5;%dm%@\u{1b}[0m %@ primary scrollback sample\r\n", + index, + index * 70, + color, + paddedSignal, + value + ) + return Data(text.utf8) + } + + private static var footer: Data { + var text = "\r\n\u{1b}[38;5;118mready\u{1b}[0m 320 lines loaded. Drag upward to exercise local scrollback.\r\n" + text += " This screen intentionally bypasses Google sign-in for the demo build.\r\n" + return Data(text.utf8) + } + } +} +#endif diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index b3c2165da88..76d5590cdb8 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -2486,7 +2486,9 @@ final class TerminalOutputCollector { #expect(subscribeRequests.first?.topics == ["workspace.updated", "terminal.render_grid", "notification.dismissed", "notification.badge"]) collector.mount(store: store, surfaceID: "live-terminal") - _ = try await waitForRequestCount("mobile.terminal.replay", count: 1, router: router) + let replayRequests = try await waitForRequestCount("mobile.terminal.replay", count: 1, router: router) + #expect(replayRequests.first?.scrollbackScope == MobileTerminalScrollbackReplayRequest.fullScope) + #expect(replayRequests.first?.maxScrollbackRows == nil) for _ in 0..<200 where collector.lines.count < 2 { try await Task.sleep(nanoseconds: 1_000_000) } @@ -2498,6 +2500,56 @@ final class TerminalOutputCollector { collector.unmount() } +@MainActor +@Test func mountedTerminalReplayRetriesAfterWorkspaceMappingArrives() async throws { + let route = try CmxAttachRoute( + id: "debug_loopback", + kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 56584) + ) + let ticket = try CmxAttachTicket( + workspaceID: "live-workspace", + terminalID: "live-terminal", + macDeviceID: "test-mac", + macDisplayName: "Test Mac", + routes: [route], + expiresAt: Date().addingTimeInterval(60) + ) + let router = ReplayAfterWorkspaceMappingRouter() + let runtime = testRuntime( + supportedRouteKinds: [.debugLoopback], + transportFactory: RequestAwareTransportFactory(router: router), + supportsServerPushEvents: true + ) + let store = CMUXMobileShellStore.preview(runtime: runtime) + let collector = TerminalOutputCollector() + + store.signIn() + await store.connectPairingURL(try attachURL(for: ticket).absoluteString) + collector.mount(store: store, surfaceID: "late-terminal") + + for _ in 0..<50 { + let replayRequests = await router.sentRequests().filter { $0.method == "mobile.terminal.replay" } + #expect(replayRequests.isEmpty) + if !replayRequests.isEmpty { + break + } + try await Task.sleep(nanoseconds: 1_000_000) + } + + await store.refreshWorkspaces() + let replayRequests = try await waitForRequestCount("mobile.terminal.replay", count: 1, router: router) + let replay = try #require(replayRequests.first) + #expect(replay.workspaceID == "live-workspace") + #expect(replay.terminalID == "late-terminal") + #expect(replay.scrollbackScope == MobileTerminalScrollbackReplayRequest.fullScope) + for _ in 0..<200 where collector.lines.isEmpty { + try await Task.sleep(nanoseconds: 1_000_000) + } + #expect(collector.lines.first?.contains("late") == true) + collector.unmount() +} + @MainActor @Test func pullToRefreshAwaitsRealWorkspaceListRoundTrip() async throws { let route = try CmxAttachRoute( @@ -3364,6 +3416,47 @@ private actor TerminalRenderGridEventRouter: RequestAwareTransportRouter { } } +private actor ReplayAfterWorkspaceMappingRouter: RequestAwareTransportRouter { + private var requests: [RecordedRPCRequest] = [] + + func record(_ request: RecordedRPCRequest) { + requests.append(request) + } + + func sentRequests() -> [RecordedRPCRequest] { + requests + } + + func response(for request: RecordedRPCRequest) async throws -> Data? { + switch request.method { + case "workspace.list": + return try rpcWorkspaceListFrame( + workspaceID: "live-workspace", + title: "Live Workspace", + terminalID: "live-terminal" + ) + case "mobile.workspace.list": + return try rpcWorkspaceListFrame( + workspaceID: "live-workspace", + title: "Live Workspace", + terminalID: "late-terminal" + ) + case "mobile.host.status": + return try rpcHostStatusFrame(renderGrid: true) + case "mobile.events.subscribe": + return try rpcResultFrame(result: ["stream_id": "events"]) + case "mobile.terminal.replay": + return try rpcTerminalReplayFrame( + seq: 3, + rawText: "unused-tail", + renderGridText: "late" + ) + default: + return try rpcErrorFrame(message: "Unexpected method \(request.method ?? "nil")") + } + } +} + /// Router for the pull-to-refresh tests: the connect-time `workspace.list` /// returns `before-workspace`; the pull-driven `mobile.workspace.list` returns a /// different `after-workspace`, so the test can prove the pull re-fetched and @@ -3528,6 +3621,7 @@ private actor ScriptedTransportResponses { viewportColumns: params["viewport_columns"] as? Int, viewportRows: params["viewport_rows"] as? Int, maxScrollbackRows: params["max_scrollback_rows"] as? Int, + scrollbackScope: params[MobileTerminalScrollbackReplayRequest.scopeParameter] as? String, clientID: params["client_id"] as? String, text: params["text"] as? String, topics: params["topics"] as? [String], @@ -3547,6 +3641,7 @@ private struct RecordedRPCRequest: Sendable { var viewportColumns: Int? var viewportRows: Int? var maxScrollbackRows: Int? + var scrollbackScope: String? var clientID: String? var text: String? var topics: [String]? @@ -3567,6 +3662,7 @@ private func recordedRPCRequest(from payload: Data) throws -> RecordedRPCRequest viewportColumns: params["viewport_columns"] as? Int, viewportRows: params["viewport_rows"] as? Int, maxScrollbackRows: params["max_scrollback_rows"] as? Int, + scrollbackScope: params[MobileTerminalScrollbackReplayRequest.scopeParameter] as? String, clientID: params["client_id"] as? String, text: params["text"] as? String, topics: params["topics"] as? [String], diff --git a/ios/scripts/reload.sh b/ios/scripts/reload.sh index d057184965e..4cf26fe1d34 100755 --- a/ios/scripts/reload.sh +++ b/ios/scripts/reload.sh @@ -44,6 +44,8 @@ SIMULATOR_NAME="${IOS_SIMULATOR_NAME:-iPhone 17}" DEVICE_ID="${IOS_DEVICE_ID:-}" DEVICE_NAME="${IOS_DEVICE_NAME:-}" DEVELOPMENT_TEAM="${IOS_DEVELOPMENT_TEAM:-}" +CMUX_API_BASE_URL="${CMUX_API_BASE_URL:-}" +CMUX_MOBILE_DEMO_MODE="${CMUX_MOBILE_DEMO_MODE:-}" LAUNCH=1 RELOAD_SIMULATOR=1 RELOAD_DEVICE=0 @@ -408,6 +410,8 @@ reload_simulator() { PRODUCT_DISPLAY_NAME="$DISPLAY_NAME" \ CMUX_GIT_SHA="$GIT_SHA" \ CMUX_DEV_TAG="$TAG" \ + CMUX_API_BASE_URL="$CMUX_API_BASE_URL" \ + CMUX_MOBILE_DEMO_MODE="$CMUX_MOBILE_DEMO_MODE" \ EXCLUDED_SOURCE_FILE_NAMES=Info.plist \ CODE_SIGNING_ALLOWED=NO \ SWIFT_OPTIMIZATION_LEVEL=-O \ @@ -509,6 +513,8 @@ reload_device() { PRODUCT_DISPLAY_NAME="$DISPLAY_NAME" CMUX_GIT_SHA="$GIT_SHA" CMUX_DEV_TAG="$TAG" + CMUX_API_BASE_URL="$CMUX_API_BASE_URL" + CMUX_MOBILE_DEMO_MODE="$CMUX_MOBILE_DEMO_MODE" EXCLUDED_SOURCE_FILE_NAMES=Info.plist CODE_SIGNING_ALLOWED=YES CODE_SIGN_STYLE=Automatic diff --git a/web/app/handler/after-sign-in/route.ts b/web/app/handler/after-sign-in/route.ts index b9b1e222535..adcc973251b 100644 --- a/web/app/handler/after-sign-in/route.ts +++ b/web/app/handler/after-sign-in/route.ts @@ -2,13 +2,13 @@ import { cookies } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; import { stackServerApp } from "../../lib/stack"; import { env } from "../../env"; +import { isAllowedNativeReturnTo } from "../native-auth-helpers"; import type { Locale } from "../../../i18n/routing"; import { locales, routing } from "../../../i18n/routing"; export const dynamic = "force-dynamic"; const NATIVE_SCHEME = "cmux://"; -const NATIVE_SCHEMES = new Set(["cmux", "cmux-nightly"]); const NATIVE_HANDOFF_COOKIE = "cmux-native-auth-handoff"; const NATIVE_HANDOFF_PARAM = "cmux_auth_handoff"; @@ -23,42 +23,6 @@ type LocalizedAfterSignInMessages = { messages: AfterSignInMessages; }; -function isLocalRequest(request: NextRequest): boolean { - const hostHeader = request.headers.get("host"); - const host = (hostHeader?.split(":")[0] ?? request.nextUrl.hostname).toLowerCase(); - return host === "localhost" || host === "127.0.0.1" || host === "::1"; -} - -function localAllowedNativeSchemes(): Set { - const values = [ - process.env.CMUX_AUTH_CALLBACK_SCHEME, - process.env.CMUX_ALLOWED_NATIVE_CALLBACK_SCHEMES, - process.env.CMUX_DEV_NATIVE_CALLBACK_SCHEMES, - ]; - const schemes = new Set(); - for (const value of values) { - for (const raw of value?.split(/[\s,]+/) ?? []) { - const scheme = raw.trim().replace(/:\/\/.*$/, "").replace(/:$/, ""); - if (/^cmux-dev-[a-z0-9-]+$/.test(scheme)) schemes.add(scheme); - } - } - return schemes; -} - -function isAllowedNativeReturnTo(href: string, request: NextRequest): boolean { - try { - const url = new URL(href); - if (url.hostname !== "auth-callback") return false; - if (url.pathname !== "" && url.pathname !== "/") return false; - const scheme = url.protocol.replace(":", ""); - if (NATIVE_SCHEMES.has(scheme)) return true; - if (scheme === "cmux-dev") return isLocalRequest(request); - return isLocalRequest(request) && localAllowedNativeSchemes().has(scheme); - } catch { - return false; - } -} - function findStackCookie( cookieStore: { getAll: () => { name: string; value: string }[] }, baseName: string diff --git a/web/app/handler/native-auth-helpers.ts b/web/app/handler/native-auth-helpers.ts new file mode 100644 index 00000000000..cbfb030c62c --- /dev/null +++ b/web/app/handler/native-auth-helpers.ts @@ -0,0 +1,82 @@ +import { NextRequest } from "next/server"; + +const NATIVE_SCHEMES = new Set(["cmux", "cmux-nightly"]); + +function firstHeaderValue(value: string | null): string | null { + return value?.split(",")[0]?.trim() || null; +} + +function requestHostCandidates(request: NextRequest): Set { + const hosts = new Set(); + for (const value of [ + request.headers.get("host"), + request.headers.get("x-forwarded-host"), + request.nextUrl.host, + ]) { + const host = firstHeaderValue(value)?.split(":")[0]?.toLowerCase(); + if (host) hosts.add(host); + } + return hosts; +} + +function isLoopbackHost(host: string): boolean { + return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]"; +} + +function isPrivateIPv4Host(host: string): boolean { + const parts = host.split(".").map((part) => Number(part)); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { + return false; + } + const [first, second] = parts; + return first === 10 + || (first === 172 && second >= 16 && second <= 31) + || (first === 192 && second === 168) + || (first === 169 && second === 254); +} + +export function isLocalRequest(request: NextRequest): boolean { + for (const host of requestHostCandidates(request)) { + if (isLoopbackHost(host)) return true; + } + return false; +} + +function isTrustedDevRequest(request: NextRequest): boolean { + if (isLocalRequest(request)) return true; + if (process.env.NODE_ENV === "production") return false; + for (const host of requestHostCandidates(request)) { + if (isPrivateIPv4Host(host) || host.endsWith(".local")) return true; + } + return false; +} + +function localAllowedNativeSchemes(): Set { + const values = [ + process.env.CMUX_AUTH_CALLBACK_SCHEME, + process.env.CMUX_ALLOWED_NATIVE_CALLBACK_SCHEMES, + process.env.CMUX_DEV_NATIVE_CALLBACK_SCHEMES, + ]; + const schemes = new Set(); + for (const value of values) { + for (const raw of value?.split(/[\s,]+/) ?? []) { + const scheme = raw.trim().replace(/:\/\/.*$/, "").replace(/:$/, ""); + if (/^cmux-dev-[a-z0-9-]+$/.test(scheme)) schemes.add(scheme); + } + } + return schemes; +} + +export function isAllowedNativeReturnTo(href: string, request: NextRequest): boolean { + try { + const url = new URL(href); + if (url.hostname !== "auth-callback") return false; + if (url.pathname !== "" && url.pathname !== "/") return false; + const scheme = url.protocol.replace(":", ""); + if (NATIVE_SCHEMES.has(scheme)) return true; + if (scheme === "cmux-dev") return isLocalRequest(request); + return isTrustedDevRequest(request) && localAllowedNativeSchemes().has(scheme); + } catch { + return false; + } +} diff --git a/web/app/handler/native-sign-in/route.ts b/web/app/handler/native-sign-in/route.ts index 399a92e66d4..aa1f93dd506 100644 --- a/web/app/handler/native-sign-in/route.ts +++ b/web/app/handler/native-sign-in/route.ts @@ -11,10 +11,36 @@ function canSetAutoHandoff(request: NextRequest): boolean { return fetchSite === null || fetchSite === "none" || fetchSite === "same-origin" || fetchSite === "same-site"; } +function firstHeaderValue(value: string | null): string | null { + return value?.split(",")[0]?.trim() || null; +} + +function requestProtocol(request: NextRequest): string { + return firstHeaderValue(request.headers.get("x-forwarded-proto")) + ?? request.nextUrl.protocol.replace(/:$/, "") + ?? "http"; +} + +function requestOriginCandidates(request: NextRequest): Set { + const origins = new Set([request.nextUrl.origin]); + const protocol = requestProtocol(request); + const hostValues = [ + firstHeaderValue(request.headers.get("host")), + firstHeaderValue(request.headers.get("x-forwarded-host")), + ]; + for (const host of hostValues) { + if (!host) continue; + try { + origins.add(new URL(`${protocol}://${host}`).origin); + } catch {} + } + return origins; +} + function sameOriginURL(value: string, request: NextRequest): URL | null { try { const url = new URL(value, request.nextUrl.origin); - return url.origin === request.nextUrl.origin ? url : null; + return requestOriginCandidates(request).has(url.origin) ? url : null; } catch { return null; } @@ -37,7 +63,7 @@ export function GET(request: NextRequest) { afterSignInURL.searchParams.set(NATIVE_HANDOFF_PARAM, nonce); } - const stackSignInURL = new URL("/handler/sign-in", request.nextUrl.origin); + const stackSignInURL = new URL("/handler/sign-in", afterSignInURL.origin); stackSignInURL.searchParams.set("after_auth_return_to", afterSignInURL.toString()); const response = NextResponse.redirect(stackSignInURL); if (nonce) { diff --git a/web/tests/native-auth-routes.test.ts b/web/tests/native-auth-routes.test.ts new file mode 100644 index 00000000000..d019b9a5673 --- /dev/null +++ b/web/tests/native-auth-routes.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { NextRequest } from "next/server"; + +process.env.RESEND_API_KEY ??= "test"; +process.env.CMUX_FEEDBACK_FROM_EMAIL ??= "test@example.com"; +process.env.CMUX_FEEDBACK_RATE_LIMIT_ID ??= "test"; +process.env.STACK_SECRET_SERVER_KEY ??= "test"; +process.env.NEXT_PUBLIC_STACK_PROJECT_ID ??= "454ecd03-1db2-4050-845e-4ce5b0cd9895"; +process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY ??= "test"; + +const { GET: nativeSignInGET } = await import("../app/handler/native-sign-in/route"); +const { isAllowedNativeReturnTo } = await import("../app/handler/native-auth-helpers"); + +describe("native auth routes", () => { + test("preserves LAN origin when redirecting native sign-in to Stack", () => { + const nativeReturnTo = "cmux-dev-sc2://auth-callback?cmux_auth_state=state-1"; + const afterSignIn = new URL("http://172.20.21.125:4177/handler/after-sign-in"); + afterSignIn.searchParams.set("native_app_return_to", nativeReturnTo); + const requestURL = new URL("http://localhost:4177/handler/native-sign-in"); + requestURL.searchParams.set("after_auth_return_to", afterSignIn.toString()); + + const response = nativeSignInGET(new NextRequest(requestURL, { + headers: { + host: "172.20.21.125:4177", + }, + })); + + expect(response.status).toBe(307); + const location = response.headers.get("location"); + expect(location?.startsWith("http://172.20.21.125:4177/handler/sign-in?")).toBe(true); + expect(new URL(location!).searchParams.get("after_auth_return_to")?.startsWith( + "http://172.20.21.125:4177/handler/after-sign-in?" + )).toBe(true); + }); + + test("allows configured per-tag native callback schemes from a dev LAN host", () => { + const previousScheme = process.env.CMUX_AUTH_CALLBACK_SCHEME; + process.env.CMUX_AUTH_CALLBACK_SCHEME = "cmux-dev-sc2"; + try { + const request = new NextRequest("http://localhost:4177/handler/after-sign-in", { + headers: { + host: "172.20.21.125:4177", + }, + }); + + expect(isAllowedNativeReturnTo( + "cmux-dev-sc2://auth-callback?cmux_auth_state=state-1", + request + )).toBe(true); + } finally { + restoreEnv("CMUX_AUTH_CALLBACK_SCHEME", previousScheme); + } + }); +}); + +function restoreEnv(key: string, value: string | undefined) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +}