diff --git a/MirrorClient/Android/README.md b/MirrorClient/Android/README.md index 11483190..73f61e65 100644 --- a/MirrorClient/Android/README.md +++ b/MirrorClient/Android/README.md @@ -25,7 +25,7 @@ is no private AI console or dependency on personal shell wrappers. - Markdown emphasis/inline code, code fences and tables have readable previews; expanding code/table freezes the detail and supports copying the original text. - Multiline input sends only on Send or two physical Returns within 350 ms. - IME composition disables Send. Host checks Agent readiness through public dispatch; + IME composition disables Send. Host checks Agent readiness through shared interactive input; shell Send requires an explicit Host capability. Current Hosts refuse it because they cannot verify an empty command line; the draft remains available. - An unconfirmed delivery preserves the draft and blocks accidental resending. @@ -69,3 +69,7 @@ The shared [wire contract](../../docs/remote-mirror-wire.md) is owned by Prowl. protocol or 64-hex-code compatibility. Update both ends together. Source attribution and upstream terms: [ThirdPartyNotices](ThirdPartyNotices/README.md). + +Ordinary Send requires Host capability `agent-input`. It does not add a completion +prompt or create a pending dispatch; prior automation receipts remain unchanged. +Delivery confirmation and reconnect receipt lookup remain separate from task success. diff --git a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Session.kt b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Session.kt index 221d8675..1d792d6b 100644 --- a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Session.kt +++ b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Session.kt @@ -360,6 +360,9 @@ class Session( val listing = command(Commands.list()) if (lease != activeLease || submission != item) return@launch val input = Commands.input(listing, item.pane, item.text) + check(!input.has("agentsInput") || "agent-input" in state.value.capabilities) { + "Update Host to support interactive Agent input" + } check(!input.has("send") || "shell-send" in state.value.capabilities) { "Host cannot verify an empty command line for remote shell Send. Use an Agent Profile or control the shell on Host" } @@ -584,12 +587,11 @@ class Session( val data = response.getAsJsonObject("data") val accepted = response.flag("ok") && - (data?.getAsJsonObject("dispatch")?.optionalString("id") != null || - (response.optionalString("command") == "send" && - data?.getAsJsonObject("input")?.let { - it.integer("bytes") == s.text.toByteArray().size && - it.flag("trailing_enter_sent") - } == true)) + response.optionalString("command") in setOf("send", "agents.input") && + data?.getAsJsonObject("input")?.let { + it.integer("bytes") == s.text.toByteArray().size && + it.flag("trailing_enter_sent") + } == true val error = response.getAsJsonObject("error") val rejection = error?.optionalString("code")?.takeUnless { @@ -602,7 +604,7 @@ class Session( else if (rejection != null) Delivery.REJECTED else Delivery.UNKNOWN, draft = if (accepted && it.draftRevision == s.revision) "" else it.draft, hint = - if (accepted) "Dispatched. Agent completion is separate." + if (accepted) "Sent. Agent completion is separate." else error?.optionalString("message") ?: "Delivery unconfirmed. Check Host before sending again.", diff --git a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Wire.kt b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Wire.kt index c266348f..4bf47dd7 100644 --- a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Wire.kt +++ b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Wire.kt @@ -217,7 +217,7 @@ object Commands { .firstOrNull { canonical(it.record("pane").string("id")) == pane } ?: throw IOException("Pane is no longer available") if (item.record("pane").optionalString("agent") != null) - return obj("agentsDispatch" to obj("_0" to obj("pane" to pane, "prompt" to text))) + return obj("agentsInput" to obj("_0" to obj("pane" to pane, "prompt" to text))) if (item.record("task").optionalString("status") != "idle") throw IOException("Shell is busy or its state is unknown") return obj( diff --git a/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/CoreTest.kt b/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/CoreTest.kt index 932413c3..10acebb9 100644 --- a/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/CoreTest.kt +++ b/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/CoreTest.kt @@ -112,7 +112,7 @@ class CoreTest { ), ) assertTrue( - Commands.input(listing("claude", "running"), pane, "hello").has("agentsDispatch") + Commands.input(listing("claude", "running"), pane, "hello").has("agentsInput") ) assertTrue(Commands.input(listing(null, "idle"), pane, "hello").has("send")) assertThrows(Exception::class.java) { Commands.input(listing(null, "running"), pane, "x") } diff --git a/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/SessionTest.kt b/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/SessionTest.kt index 6fde43dc..1bd745ad 100644 --- a/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/SessionTest.kt +++ b/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/SessionTest.kt @@ -41,14 +41,14 @@ class SessionTest { val peer get() = peers.last() - fun ready() { + fun ready(interactive: Boolean = true) { peer.ready(host) peer.receive( control( "panes", obj( "panes" to listOf(pane), - "capabilities" to listOf("text-v1", "launch-profile", "history", "refresh"), + "capabilities" to (listOf("text-v1", "launch-profile", "history", "refresh") + if (interactive) listOf("agent-input") else emptyList()), "hostRunID" to run, ), ) @@ -209,7 +209,7 @@ class SessionTest { } @Test - fun agentDispatchDoesNotRequireShellCapability() = runTest { + fun agentInputDoesNotRequireShellCapability() = runTest { val f = Fixture(backgroundScope) f.live() f.session.setDraft("Review") @@ -219,11 +219,30 @@ class SessionTest { runCurrent() assertEquals(Delivery.PENDING, f.session.state.value.delivery) val request = f.peer.sent.last().payload().record("commandRequest") - assertTrue(request.record("request").record("command").has("agentsDispatch")) + assertTrue(request.record("request").record("command").has("agentsInput")) assertEquals(2, f.peer.sent.count { it.kind == "command" }) f.session.close() } + @Test + fun oldHostNeverFallsBackToDispatch() = runTest { + val f = Fixture(backgroundScope) + f.session.connect() + f.ready(interactive = false) + f.session.choose(f.pane) + f.subscribed() + f.session.setDraft("hello") + f.session.submit() + runCurrent() + f.listing() + runCurrent() + assertEquals(Delivery.REJECTED, f.session.state.value.delivery) + assertEquals("hello", f.session.state.value.draft) + assertTrue(f.session.state.value.hint.contains("Update Host")) + assertEquals(1, f.peer.sent.count { it.kind == "command" }) + f.session.close() + } + @Test fun oldReceiptDoesNotEraseNewDraft() = runTest { val f = Fixture(backgroundScope) @@ -235,7 +254,7 @@ class SessionTest { runCurrent() val id = f.peer.sent.last().payload().record("commandRequest").string("requestID") f.session.setDraft("new draft") - f.answer(id, obj("ok" to true, "data" to obj("dispatch" to obj("id" to "accepted")))) + f.answer(id, obj("ok" to true, "command" to "agents.input", "data" to obj("input" to obj("bytes" to 5, "trailing_enter_sent" to true)))) assertEquals(Delivery.ACCEPTED, f.session.state.value.delivery) assertEquals("new draft", f.session.state.value.draft) f.session.close() diff --git a/MirrorClient/iOS/ProwlMirror-iOS/Features/Sessions/MirrorInputRoute.swift b/MirrorClient/iOS/ProwlMirror-iOS/Features/Sessions/MirrorInputRoute.swift index e4d2b3f0..ecd05838 100644 --- a/MirrorClient/iOS/ProwlMirror-iOS/Features/Sessions/MirrorInputRoute.swift +++ b/MirrorClient/iOS/ProwlMirror-iOS/Features/Sessions/MirrorInputRoute.swift @@ -6,7 +6,7 @@ nonisolated enum MirrorInputRoute { guard result.ok, let item = result.data?.items.first(where: { UUID(uuidString: $0.pane.id) == paneID }) else { throw Refusal.missing } - if item.pane.agent != nil { return .agentsDispatch(.init(pane: paneID.uuidString, prompt: text)) } + if item.pane.agent != nil { return .agentsInput(.init(pane: paneID.uuidString, prompt: text)) } guard item.task.status == "idle" else { throw Refusal.busy } return .send(.init(pane: paneID, text: text)) } diff --git a/MirrorClient/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift b/MirrorClient/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift index 4c9fea1a..83bad137 100644 --- a/MirrorClient/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift +++ b/MirrorClient/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift @@ -36,6 +36,7 @@ final class MirrorSession: Identifiable { private(set) var supportsHistory = false private(set) var supportsLaunch = false @ObservationIgnored private var supportsShellSend = false + @ObservationIgnored private var supportsAgentInput = false @ObservationIgnored private var pendingCommand: PendingCommand? @ObservationIgnored private var commandTimeout: Task? @ObservationIgnored private let clock: any Clock @@ -45,12 +46,13 @@ final class MirrorSession: Identifiable { let continuation: CheckedContinuation } private enum CommandFailure: LocalizedError { - case unavailable, disconnected, timedOut, shellUnavailable + case unavailable, disconnected, timedOut, shellUnavailable, agentInputUnavailable var errorDescription: String? { switch self { case .unavailable: "Host command service is unavailable or another command is pending." case .disconnected: "Connection lost before Host confirmed the command." case .timedOut: "Host did not confirm the command in time." + case .agentInputUnavailable: "Update Host to support interactive Agent input." case .shellUnavailable: "This Host does not support shell submission. Choose an Agent pane." } } @@ -128,6 +130,7 @@ final class MirrorSession: Identifiable { status = .connecting supportsLaunch = false supportsShellSend = false + supportsAgentInput = false generation = UUID() let attempt = generation do { @@ -277,7 +280,7 @@ final class MirrorSession: Identifiable { let request = Submission( id: UUID(), text: draft, paneID: pane.id, runID: hostRunID, draftRevision: draftRevision, configuration: configuration, - outcome: .init(status: .pending, detail: "Host is checking readiness and dispatching…")) + outcome: .init(status: .pending, detail: "Host is checking readiness and sending…")) submission = request Task { await routeSubmission(request, lease: subscriptionID) } let clock = clock @@ -294,6 +297,7 @@ final class MirrorSession: Identifiable { guard status == .live, subscriptionID == lease, submission?.id == request.id else { return } let input = try MirrorInputRoute.command( listing: listing, paneID: request.paneID, text: request.text) + if case .agentsInput = input, !supportsAgentInput { throw CommandFailure.agentInputUnavailable } if case .send = input, !supportsShellSend { throw CommandFailure.shellUnavailable } send( .command( @@ -314,12 +318,10 @@ final class MirrorSession: Identifiable { else { return } struct Result: Decodable { struct Payload: Decodable { - struct Dispatch: Decodable { let id: String } struct Input: Decodable { let bytes: Int let trailing_enter_sent: Bool } - let dispatch: Dispatch? let input: Input? } struct Failure: Decodable { @@ -335,16 +337,11 @@ final class MirrorSession: Identifiable { markDeliveryUncertain() return } - if result.ok, let dispatch = result.data?.dispatch { - current.outcome = .init( - status: .accepted, detail: "Dispatched (" + dispatch.id + "). Agent completion is separate." - ) - if current.draftRevision == draftRevision { draft = "" } - } else if result.ok, result.command == "send", let input = result.data?.input, + if result.ok, ["send", "agents.input"].contains(result.command ?? ""), let input = result.data?.input, input.bytes == current.text.utf8.count, input.trailing_enter_sent { current.outcome = .init( - status: .accepted, detail: "Sent to the shell. Command completion is separate.") + status: .accepted, detail: "Sent. Agent completion is separate.") if current.draftRevision == draftRevision { draft = "" } } else if let error = result.error, !["REMOTE_COMMAND_UNCONFIRMED", "DISPATCH_FAILED", "SEND_FAILED"].contains(error.code) @@ -419,6 +416,7 @@ final class MirrorSession: Identifiable { return } supportsLaunch = message.capabilities?.contains("launch-profile") == true + supportsAgentInput = message.capabilities?.contains("agent-input") == true supportsShellSend = message.capabilities?.contains("shell-send") == true supportsRefresh = message.capabilities?.contains("refresh") == true supportsHistory = message.capabilities?.contains("history") == true diff --git a/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorCommand.swift b/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorCommand.swift index 00a073b8..61467669 100644 --- a/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorCommand.swift +++ b/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorCommand.swift @@ -17,12 +17,13 @@ nonisolated struct MirrorCommandRequest: Codable, Equatable, Sendable { case list(Empty) case profiles(Empty) case create(Create) + case agentsInput(Dispatch) case agentsDispatch(Dispatch) case send(MirrorShellInput) var targetPaneID: UUID? { switch self { - case .agentsDispatch(let input): UUID(uuidString: input.pane) + case .agentsInput(let input), .agentsDispatch(let input): UUID(uuidString: input.pane) case .send(let input): UUID(uuidString: input.selector.value) default: nil } diff --git a/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorUIFixture.swift b/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorUIFixture.swift index 4159da28..d648028d 100644 --- a/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorUIFixture.swift +++ b/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorUIFixture.swift @@ -14,7 +14,7 @@ static func launchCommand(_ command: MirrorCommandRequest.Command) async throws -> MirrorJSON { let payload: String switch command { - case .agentsDispatch, .send: throw CancellationError() + case .agentsInput, .agentsDispatch, .send: throw CancellationError() case .list: payload = #"{"ok":true,"data":{"items":[{"worktree":{"id":"fixture-worktree","name":"main","path":"/Projects/Prowl","root_path":"/Projects/Prowl"}}]}}"# @@ -68,7 +68,7 @@ .init( panes: [pane], capabilities: [ - "text-v1", "history", "refresh", "launch-profile", "agents-dispatch", + "text-v1", "history", "refresh", "launch-profile", "agents-dispatch", "agent-input", "shell-send", ], hostRunID: UUID()))) case .subscribe: @@ -101,10 +101,12 @@ ]) ]), ]) - case .agentsDispatch: + case .agentsInput(let input): payload = .object([ - "ok": .bool(true), - "data": .object(["dispatch": .object(["id": .string(UUID().uuidString)])]), + "ok": .bool(true), "command": .string("agents.input"), + "data": .object(["input": .object([ + "bytes": .number(Double(input.prompt.utf8.count)), "trailing_enter_sent": .bool(true), + ])]), ]) default: onClose?("Unexpected fixture command") diff --git a/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorCommandTransportTests.swift b/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorCommandTransportTests.swift index dc984d7d..8d69e356 100644 --- a/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorCommandTransportTests.swift +++ b/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorCommandTransportTests.swift @@ -158,6 +158,28 @@ struct MirrorCommandTransportTests { #expect(session.submission?.outcome.status == .accepted) } + @Test(.timeLimit(.minutes(1))) func oldHostRejectsWithoutDispatchFallback() async { + let channel = Channel() + channel.supportsAgentInput = false + let session = makeSession(channel) + session.connect() + session.select(channel.pane) + session.draft = "hello" + session.submitDraft() + let changes = AsyncStream.makeStream(of: Void.self) + defer { changes.continuation.finish() } + changes.continuation.yield(()) + for await _ in changes.stream { + let finished = withObservationTracking { + session.submission?.outcome.status == .rejected + } onChange: { changes.continuation.yield(()) } + if finished { break } + } + #expect(channel.commands.count == 1) + #expect(session.draft == "hello") + #expect(session.submission?.outcome.detail.contains("Update Host") == true) + } + private func makeSession(_ channel: Channel) -> MirrorSession { MirrorSession( configuration: .init(address: "127.0.0.1", port: 7880, pairingKey: "ABCD-EFGH"), @@ -170,6 +192,7 @@ struct MirrorCommandTransportTests { var onClose: ((String?) -> Void)? var supportsLaunch = true var supportsShellSend = false + var supportsAgentInput = true var reply: ((MirrorCommandRequest) -> Void)? var commands: [MirrorCommandRequest] = [] let inputs = AsyncStream.makeStream(of: MirrorCommandRequest.self) @@ -184,7 +207,13 @@ struct MirrorCommandTransportTests { let json: MirrorJSON = success ? .object([ - "ok": .bool(true), "data": .object(["dispatch": .object(["id": .string("d1")])]), + "ok": .bool(true), "command": .string("agents.input"), + "data": .object(["input": .object([ + "bytes": .number(Double(commands.first(where: { $0.requestID == id }).map { + if case .agentsInput(let input) = $0.request.command { return input.prompt.utf8.count } + return 0 + } ?? 0)), "trailing_enter_sent": .bool(true), + ])]), ]) : .object([ "ok": .bool(false), @@ -201,6 +230,7 @@ struct MirrorCommandTransportTests { .init( panes: [pane], capabilities: ["text-v1", "agents-dispatch"] + + (supportsAgentInput ? ["agent-input"] : []) + (supportsLaunch ? ["launch-profile"] : []) + (supportsShellSend ? ["shell-send"] : []), hostRunID: UUID()))) } else if message.kind == .subscribe { diff --git a/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorInputRouteTests.swift b/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorInputRouteTests.swift index 868f6962..058b075d 100644 --- a/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorInputRouteTests.swift +++ b/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorInputRouteTests.swift @@ -6,7 +6,7 @@ struct MirrorInputRouteTests { @Test func usesAgentDispatchOrIdleShellAndPreservesText() throws { let pane = UUID() #expect(try MirrorInputRoute.command(listing: listing(pane, agent: .string("claude"), status: .string("running")), - paneID: pane, text: "first\nsecond") == .agentsDispatch(.init(pane: pane.uuidString, prompt: "first\nsecond"))) + paneID: pane, text: "first\nsecond") == .agentsInput(.init(pane: pane.uuidString, prompt: "first\nsecond"))) #expect(try MirrorInputRoute.command(listing: listing(pane, agent: .null, status: .string("idle")), paneID: pane, text: "echo hello") == .send(.init(pane: pane, text: "echo hello"))) } diff --git a/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorLaunchModelTests.swift b/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorLaunchModelTests.swift index cb4722e1..bb49ef89 100644 --- a/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorLaunchModelTests.swift +++ b/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorLaunchModelTests.swift @@ -7,7 +7,7 @@ struct MirrorLaunchModelTests { @Test func deduplicatesWorktreesAndUsesOnlyAvailableHostProfiles() async { let model = MirrorLaunchModel { command in switch command { - case .agentsDispatch, .send: throw CancellationError() + case .agentsInput, .agentsDispatch, .send: throw CancellationError() case .list: return try json(Self.listing) case .profiles: return try json(Self.profiles) case .create: throw CancellationError() @@ -26,7 +26,7 @@ struct MirrorLaunchModelTests { var requests: [MirrorCommandRequest.Create] = [] let model = MirrorLaunchModel { command in switch command { - case .agentsDispatch, .send: throw CancellationError() + case .agentsInput, .agentsDispatch, .send: throw CancellationError() case .list: return try json(Self.listing) case .profiles: return try json(Self.profiles) case .create(let create): requests.append(create); return try json(Self.created) @@ -47,7 +47,7 @@ struct MirrorLaunchModelTests { var calls = 0 let model = MirrorLaunchModel { command in switch command { - case .agentsDispatch, .send: throw CancellationError() + case .agentsInput, .agentsDispatch, .send: throw CancellationError() case .list: return try json(Self.listing) case .profiles: return try json(Self.profiles) case .create: calls += 1; throw URLError(.networkConnectionLost) @@ -65,7 +65,7 @@ struct MirrorLaunchModelTests { @Test func unconfirmedHostResultBlocksAnotherCreation() async { let model = MirrorLaunchModel { command in switch command { - case .agentsDispatch, .send: throw CancellationError() + case .agentsInput, .agentsDispatch, .send: throw CancellationError() case .list: return try json(Self.listing) case .profiles: return try json(Self.profiles) case .create: @@ -81,7 +81,7 @@ struct MirrorLaunchModelTests { @Test func explicitRejectionKeepsTheFormEditable() async { let model = MirrorLaunchModel { command in switch command { - case .agentsDispatch, .send: throw CancellationError() + case .agentsInput, .agentsDispatch, .send: throw CancellationError() case .list: return try json(Self.listing) case .profiles: return try json(Self.profiles) case .create: return try json(#"{"ok":false,"error":{"message":"Profile removed"}}"#) diff --git a/MirrorClient/iOS/README.md b/MirrorClient/iOS/README.md index 11ca07d6..5cf74f90 100644 --- a/MirrorClient/iOS/README.md +++ b/MirrorClient/iOS/README.md @@ -25,12 +25,14 @@ available Host Agent Profile. Host creates a normal background tab through its public CLI. Models and permissions come from that Profile. There is no separate AI Control Console. An uncertain creation is not repeated automatically. -**Send** first reads public Host `list`. Detected Agents use `agents dispatch`. +**Send** first reads public Host `list`. Detected Agents use `agentsInput` +(`agent-input` capability), which delivers plain interactive text without task +dispatch receipts. Older Hosts must be updated. The current Host does not permit structured Shell submission because it cannot confirm that the Shell input line is empty. The client preserves the draft and explains the refusal. Host validates the pane lease, readiness, drafts and IME/editing activity. Claude paste/Enter and Codex hint styling are checked in -the shared Host path. Dispatch success is not Agent completion. Unknown delivery +the shared Host path. Input delivery is not Agent completion. Unknown delivery preserves the draft and queries the original request receipt after reconnect; commands are never automatically replayed. @@ -71,3 +73,7 @@ are separate from real Agent/cross-device acceptance. See the shared [wire contract](../../docs/remote-mirror-wire.md) and `ThirdPartyNotices/` for source/license notices. + +Ordinary Send requires Host capability `agent-input`. It does not add a completion +prompt or create a pending dispatch; prior automation receipts remain unchanged. +Delivery confirmation and reconnect receipt lookup remain separate from task success. diff --git a/ProwlCLITests/DispatchWireModelTests.swift b/ProwlCLITests/DispatchWireModelTests.swift index e1155a4b..44847466 100644 --- a/ProwlCLITests/DispatchWireModelTests.swift +++ b/ProwlCLITests/DispatchWireModelTests.swift @@ -3,6 +3,17 @@ import ProwlCLIShared import XCTest final class DispatchWireModelTests: XCTestCase { + func testInteractiveInputRoundTripsWithoutDispatchMetadata() throws { + let input = DispatchInput(pane: UUID().uuidString, prompt: "你好\nfollow-up") + let data = try JSONEncoder().encode(CommandEnvelope(output: .json, command: .agentsInput(input))) + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) + XCTAssertEqual(decoded.command.name, "agents.input") + guard case .agentsInput(let actual) = decoded.command else { + return XCTFail("Expected interactive input") + } + XCTAssertEqual(actual, input) + } + func testDispatchCommandsRoundTripWithStableNames() throws { let commands: [Command] = [ .agentsDispatch(DispatchInput(pane: "p7", prompt: "Round two.\nRe-review the diff.")), diff --git a/docs-ai/067-remote-mirror/000-plan.md b/docs-ai/067-remote-mirror/000-plan.md index 35539a0e..62b85158 100644 --- a/docs-ai/067-remote-mirror/000-plan.md +++ b/docs-ai/067-remote-mirror/000-plan.md @@ -67,3 +67,5 @@ capability needed here. Browser clients are outside this implementation. - Updated 2026-09-13: Planned unified macOS Host/Client controls and viewport decisions — see [006-macos-ux.md](006-macos-ux.md). - Updated 2026-09-13: Polished the macOS popover, pairing sheet and connection form without a protocol change — see [007-ux-polish.md](007-ux-polish.md). - Updated 2026-09-17: Planned Mac-side Shell and Agent Profile creation and connection-picker improvements — see [008-client-pane-launch.md](008-client-pane-launch.md). + +- Updated 2026-10-01: Separate mobile interactive input from task receipts — see [009](009-interactive-input.md). diff --git a/docs-ai/067-remote-mirror/009-interactive-input.md b/docs-ai/067-remote-mirror/009-interactive-input.md new file mode 100644 index 00000000..b646765c --- /dev/null +++ b/docs-ai/067-remote-mirror/009-interactive-input.md @@ -0,0 +1,42 @@ +# 067.009 — Interactive mobile input without task receipts + +Status: Implemented (2026-10-01). + +## Context + +Mobile text mirrors route ordinary human messages through agents.dispatch. A failed +Agent completion command leaves a pending task that blocks every later message even +when the Agent is idle. Mac keyboard input has no such task-completion requirement. + +## Design + +Add agentsInput to the structured command wire and advertise agent-input. Reuse the +Agent dispatch readiness and guarded delivery code, but deliver the exact human text +without creating, completing, abandoning or inspecting a dispatch. Existing automation +receipts remain unchanged. This is explicit human interaction, like local keyboard input; +it does not certify or cancel an automation task. A per-pane in-flight guard prevents +interactive and automated deliveries racing while readiness is awaited. + +Keep text-v1, authenticated pane leases, cancellation, bounded request-ID deduplication, +and delivery-receipt recovery. Missing capability requires upgrading Host; never fall +back to dispatch. iOS and Android acknowledge bytes plus Enter, not Agent completion. +Shell submission remains unavailable. Profile launch prompts still use their existing +launch contract; subsequent interactive messages do not create further dispatches. + +## Alternatives and result + +Completing or abandoning the old dispatch from the phone would falsely assert a task +outcome. Relaxing automation gating would change the workflow contract for unrelated +callers. A separate interactive command preserves both contracts without switching +mobile clients to a terminal protocol. + +Host dispatch/Mirror tests, including real Ghostty terminal integration, passed. +iOS simulator tests (48) and Android unit tests (25 passed, one external native TLS +fixture skipped), lint and Debug build passed. CLI smoke and 106 integration tests passed. The final CLI unit run passed 300 +tests, including the new wire round trip; two unchanged WorkflowHistoryRetentionTests +export cases failed with exportFailed, also on an isolated rerun. macOS Debug built and signature verified. +Changed Host Swift files passed strict lint; the full check remains blocked by five +pre-existing legacy_swiftui_aspect_ratio violations in RepositoryIconImage.swift. + +Physical-device reconnect/background acceptance remains to be checked. Mobile clients +were not installed as part of this change. diff --git a/docs/remote-mirror-wire.md b/docs/remote-mirror-wire.md index 10484e62..43b9f1f1 100644 --- a/docs/remote-mirror-wire.md +++ b/docs/remote-mirror-wire.md @@ -65,3 +65,20 @@ bounds backpressure; an ACK must exactly match its outstanding sequence. History ID/offset refer to one frozen snapshot, with fixed total and capture time. Pages cannot overlap, skip or exceed the total byte budget. Heartbeats run every two seconds; eight seconds without inbound traffic ends an established connection. + +## Interactive Agent input + +A Host advertising `agent-input` accepts structured `agentsInput` with the same +`{pane, prompt}` payload as `agentsDispatch`. The pane must be a UUID owned by the +current authenticated subscription. The command is routed as `agents.input`; it +uses the shared Agent readiness and guarded paste/Enter path without creating or +mutating task dispatch records or injecting completion instructions. `text-v1` +remains the mobile representation. `agentsDispatch` retains its automation semantics. + +Success has `command: "agents.input"`, schema `prowl.cli.agents.input.v1`, and +`data.input` containing UTF-8 `bytes`, `characters`, `source`, and +`trailing_enter_sent`. Clients verify the submitted byte count and Enter flag before +clearing that draft revision. Failed/uncertain delivery retains the draft; +`SEND_FAILED` is uncertain, not permission to replay. Existing request-ID deduplication +and `commandReceipt` recovery apply. A missing `agent-input` capability requires a +Host update; clients must not silently fall back to `agentsDispatch` or raw input. diff --git a/docs/remote-mirror.md b/docs/remote-mirror.md index c95b7a47..34518be4 100644 --- a/docs/remote-mirror.md +++ b/docs/remote-mirror.md @@ -135,7 +135,14 @@ public CLI router. There is no special AI Control Console, bundled private contr skill, or separate agent-launch implementation. Profile settings determine model and permissions. -Every Send first reads public `list`. A detected Agent uses public `agents dispatch`. +Every Send first reads public `list`. A detected Agent uses structured `agentsInput` +(`agents.input`), advertised by the `agent-input` capability. Update Host if this +capability is missing; mobile clients never fall back to task dispatch. +Interactive input delivers the message as written, without a `[Prowl]` prefix or a +completion instruction. It creates no dispatch record and does not require +`dispatch-complete`. An existing pending automation receipt is neither completed nor +abandoned and does not block this human input, just as local typing does not settle +an automation task. The usual Agent readiness and local input protections still apply. Host rechecks the exact subscribed pane and lease, plus input protection, before delivery. Mobile shell panes are read-only: Host cannot yet verify an empty shell command line, so structured shell Send is refused and its capability is not advertised. An idle task @@ -143,14 +150,14 @@ can still contain an older local draft. Use an Agent Profile for mobile prompts, control the shell on Host. Local CLI `send` and Mac mirror keyboard input retain their existing direct-input behavior. -Shared Agent dispatch rejects IME composition and recent Host editing. Claude must +Shared Agent input delivery rejects IME composition and recent Host editing. Claude must have a recognized empty composer. It inserts text, waits up to two seconds for the paste echo, and only then sends Enter, provided the surface/Agent/edit revision is unchanged. Codex has an explicit idle-composer rule; the delivery boundary also checks formatter dim styling so a hint is not confused with an identically worded draft. Unknown layouts, wrapped drafts and attachments refuse delivery. -A successful dispatch receipt confirms delivery, not Agent completion. Replies are +A successful input receipt confirms delivery, not Agent completion. Replies are correlated by UUID. Unknown delivery preserves the draft and is not automatically replayed; reconnection queries the original request receipt on the same Host run. If delivery stops after paste but before Enter, text may remain in the Host composer; @@ -158,7 +165,7 @@ check it before retrying. Host retains up to 1024 mutation receipts per App lifetime, and rejects further mutations when full. Catalog reads do not consume this budget. A retained mutation ID reused with -different parameters is rejected. Takeover/disconnect cancels a dispatch still waiting for readiness. Explicit device revocation or Host stop +different parameters is rejected. Takeover/disconnect cancels input still waiting for readiness. Explicit device revocation or Host stop also cancels pending Profile preparation, including requests whose connection was already lost. A plain disconnect alone does not cancel accepted Profile creation; check Host before retrying an uncertain result. Existing terminal programs continue. diff --git a/supacode/CLIService/AgentDispatchCommandHandler.swift b/supacode/CLIService/AgentDispatchCommandHandler.swift index 5a070289..d14711e0 100644 --- a/supacode/CLIService/AgentDispatchCommandHandler.swift +++ b/supacode/CLIService/AgentDispatchCommandHandler.swift @@ -32,6 +32,7 @@ final class AgentDispatchCommandHandler: CommandHandler { case settling(String) } + private var deliveringPanes: Set = [] private let inputProtection: InputProtection private let resolveTarget: ResolveTarget private let pendingDispatch: PendingDispatch @@ -69,9 +70,20 @@ final class AgentDispatchCommandHandler: CommandHandler { } func handle(envelope: CommandEnvelope) async -> CommandResponse { - guard case .agentsDispatch(let input) = envelope.command else { - return failure(code: CLIErrorCode.invalidArgument, message: "Expected an agents.dispatch command.") + switch envelope.command { + case .agentsDispatch(let input): + return await handle(input: input, interactive: false) + case .agentsInput(let input): + let response = await handle(input: input, interactive: true) + return CommandResponse( + ok: response.ok, command: "agents.input", schemaVersion: "prowl.cli.agents.input.v1", + data: response.data, error: response.error) + default: + return failure(code: CLIErrorCode.invalidArgument, message: "Expected an Agent input command.") } + } + + private func handle(input: DispatchInput, interactive: Bool) async -> CommandResponse { if let message = input.validationErrorMessage { return failure(code: CLIErrorCode.invalidArgument, message: message) } @@ -84,13 +96,47 @@ final class AgentDispatchCommandHandler: CommandHandler { case .success(let resolved): target = resolved } - if let pending = pendingDispatch(target) { + guard deliveringPanes.insert(target.paneID).inserted else { + return failure(code: CLIErrorCode.dispatchTargetBusy, message: "Another input is being delivered to this pane.") + } + defer { deliveringPanes.remove(target.paneID) } + if !interactive, let pending = pendingDispatch(target) { return pendingFailure(pending, target: target) } if let refusal = await prepareDelivery(target: target) { return refusal } + guard !Task.isCancelled else { + return failure(code: CLIErrorCode.timeout, message: "Input was cancelled before delivery.") + } + if interactive { + // Human input does not assert an outcome for an automation task or create a new one. + guard await deliverPrompt(target, input.prompt) else { + return failure( + code: CLIErrorCode.sendFailed, message: "Input delivery is unconfirmed. Check the pane before retrying.") + } + struct Payload: Encodable { + let input: SendInputInfo + } + do { + return try CommandResponse( + ok: true, command: "agents.input", schemaVersion: "prowl.cli.agents.input.v1", + data: RawJSON( + encoding: Payload( + input: .init( + source: "argument", characters: input.prompt.count, bytes: input.prompt.utf8.count, + trailingEnterSent: true)))) + } catch { + return failure( + code: CLIErrorCode.sendFailed, message: "Input was delivered but its receipt could not be encoded.") + } + } + + return await dispatch(input: input, target: target) + } + + private func dispatch(input: DispatchInput, target: TabResolvedTarget) async -> CommandResponse { let issued: AgentDispatchSnapshot switch issueDispatch(target) { case .success(let snapshot): diff --git a/supacode/CLIService/CLICommandRouter.swift b/supacode/CLIService/CLICommandRouter.swift index 23d50890..2be43a9b 100644 --- a/supacode/CLIService/CLICommandRouter.swift +++ b/supacode/CLIService/CLICommandRouter.swift @@ -85,7 +85,7 @@ final class CLICommandRouter { case .agentsRead: handler = agentsReadHandler case .agentsSignal: handler = agentsSignalHandler case .agentsHook: handler = agentsHookHandler - case .agentsDispatch: handler = agentsDispatchHandler + case .agentsInput, .agentsDispatch: handler = agentsDispatchHandler case .agentsDispatchComplete: handler = agentsDispatchCompleteHandler case .agentsDispatchAbandon: handler = agentsDispatchAbandonHandler case .agentsWait: handler = agentsWaitHandler diff --git a/supacode/CLIService/Shared/CommandEnvelope.swift b/supacode/CLIService/Shared/CommandEnvelope.swift index 06955e8b..c73492d8 100644 --- a/supacode/CLIService/Shared/CommandEnvelope.swift +++ b/supacode/CLIService/Shared/CommandEnvelope.swift @@ -20,6 +20,7 @@ public enum Command: Codable, Sendable { case agentsRead(AgentReadInput) case agentsSignal(AgentSignalInput) case agentsHook(AgentNativeHookInput) + case agentsInput(DispatchInput) case agentsDispatch(DispatchInput) case agentsDispatchComplete(DispatchCompleteInput) case agentsDispatchAbandon(DispatchAbandonInput) @@ -43,6 +44,7 @@ public enum Command: Codable, Sendable { case .agentsRead: "agents.read" case .agentsSignal: "agents.signal" case .agentsHook: "agents._hook" + case .agentsInput: "agents.input" case .agentsDispatch: "agents.dispatch" case .agentsDispatchComplete: "agents.dispatch-complete" case .agentsDispatchAbandon: "agents.dispatch-abandon" diff --git a/supacode/Features/RemoteMirror/MirrorCommand.swift b/supacode/Features/RemoteMirror/MirrorCommand.swift index 00a073b8..61467669 100644 --- a/supacode/Features/RemoteMirror/MirrorCommand.swift +++ b/supacode/Features/RemoteMirror/MirrorCommand.swift @@ -17,12 +17,13 @@ nonisolated struct MirrorCommandRequest: Codable, Equatable, Sendable { case list(Empty) case profiles(Empty) case create(Create) + case agentsInput(Dispatch) case agentsDispatch(Dispatch) case send(MirrorShellInput) var targetPaneID: UUID? { switch self { - case .agentsDispatch(let input): UUID(uuidString: input.pane) + case .agentsInput(let input), .agentsDispatch(let input): UUID(uuidString: input.pane) case .send(let input): UUID(uuidString: input.selector.value) default: nil } diff --git a/supacode/Features/RemoteMirror/MirrorCommandService.swift b/supacode/Features/RemoteMirror/MirrorCommandService.swift index c7ee1d67..38f1c777 100644 --- a/supacode/Features/RemoteMirror/MirrorCommandService.swift +++ b/supacode/Features/RemoteMirror/MirrorCommandService.swift @@ -95,7 +95,7 @@ final class MirrorCommandService { private func validate(_ command: MirrorCommandRequest.Command) -> MirrorJSON? { switch command { case .list, .profiles: break - case .agentsDispatch(let input): + case .agentsInput(let input), .agentsDispatch(let input): guard UUID(uuidString: input.pane) != nil, input.prompt.utf8.count <= MirrorWire.maximumInput else { return failure("Invalid dispatch target or prompt size.") diff --git a/supacode/Features/RemoteMirror/MirrorHost.swift b/supacode/Features/RemoteMirror/MirrorHost.swift index a9bec134..082b2eb5 100644 --- a/supacode/Features/RemoteMirror/MirrorHost.swift +++ b/supacode/Features/RemoteMirror/MirrorHost.swift @@ -493,7 +493,7 @@ final class MirrorHost { panes: panes, capabilities: ["vt-v1", "text-v1", "takeover", "refresh"] + (commandService != nil - ? ["launch-profile", "launch-shell", "agents-dispatch"] : []) + ? ["launch-profile", "launch-shell", "agents-dispatch", "agent-input"] : []) + (source.supportsBoundedHistory ? ["history"] : []), hostRunID: hostRunID))) case .command: try handleCommand(message, peer: peer) diff --git a/supacodeTests/AgentDispatchCommandHandlerTests.swift b/supacodeTests/AgentDispatchCommandHandlerTests.swift index 9ab88611..dd023124 100644 --- a/supacodeTests/AgentDispatchCommandHandlerTests.swift +++ b/supacodeTests/AgentDispatchCommandHandlerTests.swift @@ -628,6 +628,115 @@ struct AgentDispatchCommandHandlerTests { #expect(cancelled == ["d10"]) } + @Test func interactiveInputIgnoresPendingWithoutMutatingItOrInjectingProtocol() async throws { + let target = resolvedTarget() + let pending = AgentDispatchSnapshot(record: .pending(id: "old-task", createdAt: Self.start), binding: nil) + var delivered: [String] = [] + var issued = 0 + var cancelled = 0 + let handler = AgentDispatchCommandHandler( + resolveTarget: { _ in .success(target) }, pendingDispatch: { _ in pending }, + conditionSnapshot: { _ in self.snapshot(target, status: .idle, signal: self.turnEnded) }, + issueDispatch: { _ in + issued += 1 + return .failure(.surfacePending) + }, + deliverPrompt: { _, text in + delivered.append(text) + return true + }, + cancelDispatch: { _ in cancelled += 1 }) + for text in ["你好\nsecond line", "follow-up"] { + let response = await handler.handle(envelope: envelope(.agentsInput(.init(pane: target.paneID, prompt: text)))) + #expect(response.ok) + #expect(response.command == "agents.input") + struct Payload: Decodable { let input: SendInputInfo } + let data = try #require(response.data).decode(as: Payload.self) + #expect(data.input.bytes == text.utf8.count) + #expect(data.input.trailingEnterSent) + } + #expect(delivered == ["你好\nsecond line", "follow-up"]) + #expect(issued == 0 && cancelled == 0) + let automated = await handler.handle(envelope: dispatch(pane: target.paneID)) + #expect(automated.error?.code == CLIErrorCode.dispatchPending) + } + + @Test func interactiveInputRetainsProtectionAndReportsUnconfirmedDelivery() async { + let target = resolvedTarget() + for (status, protection, delivered) in [ + (AgentDisplayState.working, String?.none, false), (.idle, "Local draft", false), (.idle, nil, false), + ] { + var calls = 0 + let handler = AgentDispatchCommandHandler( + resolveTarget: { _ in .success(target) }, inputProtection: { _ in protection }, + conditionSnapshot: { _ in self.snapshot(target, status: status, signal: self.turnEnded) }, + deliverPrompt: { _, _ in + calls += 1 + return delivered + }) + let response = await handler.handle(envelope: envelope(.agentsInput(.init(pane: target.paneID, prompt: "hello")))) + #expect(!response.ok) + if status == .idle && protection == nil { + #expect(response.error?.code == CLIErrorCode.sendFailed) + #expect(calls == 1) + } else { + #expect(response.error?.code == CLIErrorCode.dispatchTargetBusy) + #expect(calls == 0) + } + } + } + + @Test func concurrentInteractiveAndAutomatedInputsCannotInterleave() async { + let target = resolvedTarget() + let started = AsyncStream.makeStream(of: Void.self) + let release = AsyncStream.makeStream(of: Void.self) + defer { + started.continuation.finish() + release.continuation.finish() + } + let handler = AgentDispatchCommandHandler( + resolveTarget: { _ in .success(target) }, + conditionSnapshot: { _ in self.snapshot(target, status: .idle, signal: self.turnEnded) }, + deliverPrompt: { _, _ in + started.continuation.yield(()) + for await _ in release.stream { break } + return true + }) + let first = Task { + await handler.handle(envelope: envelope(.agentsInput(.init(pane: target.paneID, prompt: "one")))) + } + for await _ in started.stream { break } + let second = await handler.handle(envelope: dispatch(pane: target.paneID)) + #expect(second.error?.code == CLIErrorCode.dispatchTargetBusy) + release.continuation.yield(()) + #expect(await first.value.ok) + } + + @Test func interactiveInputRejectsInvalidTextAndCancelledRequests() async { + let target = resolvedTarget() + var calls = 0 + let handler = AgentDispatchCommandHandler( + resolveTarget: { _ in .success(target) }, + conditionSnapshot: { _ in self.snapshot(target, status: .idle, signal: self.turnEnded) }, + deliverPrompt: { _, _ in + calls += 1 + return true + }) + for text in ["", " ", "escape\u{1b}"] { + let response = await handler.handle(envelope: envelope(.agentsInput(.init(pane: target.paneID, prompt: text)))) + #expect(response.error?.code == CLIErrorCode.invalidArgument) + } + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + return await handler.handle(envelope: envelope(.agentsInput(.init(pane: target.paneID, prompt: "hello")))) + } + #expect(await task.value.ok == false) + #expect(calls == 0) + // Cancellation must release the per-pane in-flight guard. + #expect(await handler.handle(envelope: envelope(.agentsInput(.init(pane: target.paneID, prompt: "retry")))).ok) + #expect(calls == 1) + } + // MARK: - Helpers private static let start = Date(timeIntervalSince1970: 1_000) diff --git a/supacodeTests/RemoteMirror/MirrorCommandServiceTests.swift b/supacodeTests/RemoteMirror/MirrorCommandServiceTests.swift index 79a26704..5a795502 100644 --- a/supacodeTests/RemoteMirror/MirrorCommandServiceTests.swift +++ b/supacodeTests/RemoteMirror/MirrorCommandServiceTests.swift @@ -122,13 +122,42 @@ struct MirrorCommandServiceTests { #expect(handler.count == 0) } + @Test func interactiveDeliveryKeepsRequestReceiptsAndPaneOwnership() async throws { + let handler = DispatchHandler() + let service = MirrorCommandService(router: CLICommandRouter(agentsDispatchHandler: handler)) + let pane = UUID() + let id = UUID() + let request = MirrorCommandRequest( + requestID: id, + request: .init( + command: .agentsInput(.init(pane: pane.uuidString, prompt: "hello")))) + #expect(request.request.command.targetPaneID == pane) + #expect(try await service.execute(request, authorize: { false }).response.decode(CommandResponse.self).ok == false) + #expect(handler.count == 0) + #expect(try await service.execute(request).response.decode(CommandResponse.self).ok) + #expect(try await service.receipt(id, paneID: pane).response.decode(CommandResponse.self).ok) + #expect(try await service.execute(request).response.decode(CommandResponse.self).ok) + #expect(handler.count == 1) + #expect(handler.prompt == "hello") + #expect(try await service.receipt(id, paneID: UUID()).response.decode(CommandResponse.self).ok == false) + let changed = MirrorCommandRequest( + requestID: id, + request: .init( + command: .agentsInput(.init(pane: pane.uuidString, prompt: "different")))) + #expect(try await service.execute(changed).response.decode(CommandResponse.self).ok == false) + #expect(handler.count == 1) + } + private final class DispatchHandler: CommandHandler { var count = 0 var prompt: String? func handle(envelope: CommandEnvelope) async -> CommandResponse { count += 1 await Task.yield() - if case .agentsDispatch(let input) = envelope.command { prompt = input.prompt } + switch envelope.command { + case .agentsInput(let input), .agentsDispatch(let input): prompt = input.prompt + default: break + } return CommandResponse( ok: true, command: "agents.dispatch", schemaVersion: "prowl.cli.agents.dispatch.v1") } diff --git a/supacodeTests/RemoteMirror/MirrorLaunchModelTests.swift b/supacodeTests/RemoteMirror/MirrorLaunchModelTests.swift index d50533c9..40c6d5b3 100644 --- a/supacodeTests/RemoteMirror/MirrorLaunchModelTests.swift +++ b/supacodeTests/RemoteMirror/MirrorLaunchModelTests.swift @@ -8,7 +8,7 @@ struct MirrorLaunchModelTests { @Test func deduplicatesWorktreesAndUsesOnlyAvailableHostProfiles() async { let model = MirrorLaunchModel { command in switch command { - case .agentsDispatch, .send: throw CancellationError() + case .agentsInput, .agentsDispatch, .send: throw CancellationError() case .list: return try json(Self.listing) case .profiles: return try json(Self.profiles) case .create: throw CancellationError() @@ -27,7 +27,7 @@ struct MirrorLaunchModelTests { var requests: [MirrorCommandRequest.Create] = [] let model = MirrorLaunchModel { command in switch command { - case .agentsDispatch, .send: throw CancellationError() + case .agentsInput, .agentsDispatch, .send: throw CancellationError() case .list: return try json(Self.listing) case .profiles: return try json(Self.profiles) case .create(let create): @@ -50,7 +50,7 @@ struct MirrorLaunchModelTests { var calls = 0 let model = MirrorLaunchModel { command in switch command { - case .agentsDispatch, .send: throw CancellationError() + case .agentsInput, .agentsDispatch, .send: throw CancellationError() case .list: return try json(Self.listing) case .profiles: return try json(Self.profiles) case .create: @@ -72,7 +72,7 @@ struct MirrorLaunchModelTests { @Test func unconfirmedHostResultBlocksAnotherCreation() async { let model = MirrorLaunchModel { command in switch command { - case .agentsDispatch, .send: throw CancellationError() + case .agentsInput, .agentsDispatch, .send: throw CancellationError() case .list: return try json(Self.listing) case .profiles: return try json(Self.profiles) case .create: @@ -98,7 +98,7 @@ struct MirrorLaunchModelTests { @Test func explicitRejectionKeepsTheFormEditable() async { let model = MirrorLaunchModel { command in switch command { - case .agentsDispatch, .send: throw CancellationError() + case .agentsInput, .agentsDispatch, .send: throw CancellationError() case .list: return try json(Self.listing) case .profiles: return try json(Self.profiles) case .create: return try json(#"{"ok":false,"error":{"message":"Profile removed"}}"#) -- 2.51.2 From 45ac847e4a8117a04d4dfca04cbea511a03e7179 Mon Sep 17 00:00:00 2001 From: Awhisper <4347733+Awhisper@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:01:58 +0800 Subject: [PATCH 02/14] Add QR pairing to Host and mobile Mirror clients --- MirrorClient/Android/README.md | 4 + .../Android/ThirdPartyNotices/README.md | 1 + MirrorClient/Android/app/build.gradle.kts | 1 + .../Android/app/src/main/AndroidManifest.xml | 3 + .../com/awhisper/prowlmirror/MirrorApp.kt | 1 + .../awhisper/prowlmirror/PairingPayload.kt | 44 +++++++ .../awhisper/prowlmirror/PairingScanButton.kt | 39 ++++++ .../prowlmirror/PairingPayloadTest.kt | 34 ++++++ .../Shared/MirrorPairingPayload.swift | 67 ++++++++++ .../ProwlMirror-iOS.xcodeproj/project.pbxproj | 6 + .../iOS/ProwlMirror-iOS/ContentView.swift | 20 +++ .../Composer/MirrorPairingScanner.swift | 115 ++++++++++++++++++ .../MirrorPairingPayloadTests.swift | 40 ++++++ MirrorClient/iOS/README.md | 4 + docs-ai/067-remote-mirror/000-plan.md | 1 + docs-ai/067-remote-mirror/010-qr-pairing.md | 32 +++++ docs-ai/README.md | 2 +- docs/remote-mirror-wire.md | 17 +++ docs/remote-mirror.md | 13 ++ supacode.xcodeproj/project.pbxproj | 4 + .../RemoteMirror/MirrorHostButton.swift | 27 +++- .../RemoteMirror/MirrorPairingQRCode.swift | 45 +++++++ .../MirrorPairingPayloadTests.swift | 40 ++++++ .../MirrorPairingQRCodeTests.swift | 21 ++++ 24 files changed, 579 insertions(+), 2 deletions(-) create mode 100644 MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/PairingPayload.kt create mode 100644 MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/PairingScanButton.kt create mode 100644 MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/PairingPayloadTest.kt create mode 100644 MirrorClient/Shared/MirrorPairingPayload.swift create mode 100644 MirrorClient/iOS/ProwlMirror-iOS/Features/Composer/MirrorPairingScanner.swift create mode 100644 MirrorClient/iOS/ProwlMirror-iOSTests/MirrorPairingPayloadTests.swift create mode 100644 docs-ai/067-remote-mirror/010-qr-pairing.md create mode 100644 supacode/Features/RemoteMirror/MirrorPairingQRCode.swift create mode 100644 supacodeTests/RemoteMirror/MirrorPairingPayloadTests.swift create mode 100644 supacodeTests/RemoteMirror/MirrorPairingQRCodeTests.swift diff --git a/MirrorClient/Android/README.md b/MirrorClient/Android/README.md index 73f61e65..3266bb8e 100644 --- a/MirrorClient/Android/README.md +++ b/MirrorClient/Android/README.md @@ -73,3 +73,7 @@ Source attribution and upstream terms: [ThirdPartyNotices](ThirdPartyNotices/REA Ordinary Send requires Host capability `agent-input`. It does not add a completion prompt or create a pending dispatch; prior automation receipts remain unchanged. Delivery confirmation and reconnect receipt lookup remain separate from task success. + +Use **Scan QR Code** in the Host connection form to scan **Add a Device** on Host. +Scanning connects with the encoded address, port and short-lived code. Camera +permission is optional; manual entry remains available. Refresh expired codes on Host. diff --git a/MirrorClient/Android/ThirdPartyNotices/README.md b/MirrorClient/Android/ThirdPartyNotices/README.md index d74f2069..a77ccc01 100644 --- a/MirrorClient/Android/ThirdPartyNotices/README.md +++ b/MirrorClient/Android/ThirdPartyNotices/README.md @@ -10,6 +10,7 @@ Runtime dependencies: - AndroidX / Jetpack Compose: Apache License 2.0. - Kotlin and kotlinx.coroutines: Apache License 2.0. - Gson: Apache License 2.0. +- ZXing Android Embedded and ZXing Core: Apache License 2.0. - Bouncy Castle Java libraries: [Bouncy Castle licence](https://www.bouncycastle.org/licence.html). Android uses Bouncy Castle's low-level `PSKTlsClient`, not the JSSE trust managers diff --git a/MirrorClient/Android/app/build.gradle.kts b/MirrorClient/Android/app/build.gradle.kts index 7093297c..ca61568a 100644 --- a/MirrorClient/Android/app/build.gradle.kts +++ b/MirrorClient/Android/app/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.2") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") implementation("com.google.code.gson:gson:2.11.0") + implementation("com.journeyapps:zxing-android-embedded:4.3.0") implementation("org.bouncycastle:bctls-jdk18on:1.83") testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") diff --git a/MirrorClient/Android/app/src/main/AndroidManifest.xml b/MirrorClient/Android/app/src/main/AndroidManifest.xml index e709f94a..bbd25a56 100644 --- a/MirrorClient/Android/app/src/main/AndroidManifest.xml +++ b/MirrorClient/Android/app/src/main/AndroidManifest.xml @@ -1,5 +1,8 @@ + + + diff --git a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt index 4f5a53bd..acaa8571 100644 --- a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt +++ b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt @@ -288,6 +288,7 @@ private fun HostDialog( Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp), ) { + PairingScanButton(connect = connect, failed = { error = it }) saved.takeLast(5).reversed().forEach { host -> TextButton( onClick = { diff --git a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/PairingPayload.kt b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/PairingPayload.kt new file mode 100644 index 00000000..4d306281 --- /dev/null +++ b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/PairingPayload.kt @@ -0,0 +1,44 @@ +package com.awhisper.prowlmirror + +import com.google.gson.JsonParser +import java.net.InetAddress + +internal data class PairingPayload(val address: String, val port: Int, val code: String) { + companion object { + fun parse(text: String, nowSeconds: Long = System.currentTimeMillis() / 1000): PairingPayload { + val invalid = "Scan a Prowl pairing QR code from Add a Device on Host" + require(text.toByteArray(Charsets.UTF_8).size <= 2048) { invalid } + val data = try { + JsonParser.parseString(text).asJsonObject + } catch (_: Exception) { + throw IllegalArgumentException(invalid) + } + fun string(name: String): String { + val value = data.get(name) + require(value != null && value.isJsonPrimitive && value.asJsonPrimitive.isString) { invalid } + return value.asString + } + fun number(name: String): Long { + val value = data.get(name) + require(value != null && value.isJsonPrimitive && value.asJsonPrimitive.isNumber) { invalid } + return value.asString.toLongOrNull() ?: throw IllegalArgumentException(invalid) + } + require(string("type") == "prowl-mirror-pairing" && number("version") == 1L) { invalid } + val address = string("address") + val port = number("port") + val code = string("code") + val expires = number("expiresAt") + val numericIP = if (':' in address && address.all { it in "0123456789abcdefABCDEF:." }) { + // The character restriction prevents DNS lookup of scanned host names. + try { InetAddress.getByName(address) } catch (_: Exception) { null } + } else if (address.matches(Regex("[0-9]{1,3}(\\.[0-9]{1,3}){3}")) && + address.split('.').all { it.toInt() in 0..255 && (it == "0" || !it.startsWith('0')) }) { + InetAddress.getByAddress(address.split('.').map { it.toInt().toByte() }.toByteArray()) + } else null + require(numericIP != null && !numericIP.isAnyLocalAddress && !numericIP.isLoopbackAddress && + port in 1..65535 && Authentication.code(code) == code && expires > 0) { invalid } + require(expires > nowSeconds) { "This pairing code expired. Refresh Code on Host and scan again" } + return PairingPayload(address, port.toInt(), code) + } + } +} diff --git a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/PairingScanButton.kt b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/PairingScanButton.kt new file mode 100644 index 00000000..e92dacb8 --- /dev/null +++ b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/PairingScanButton.kt @@ -0,0 +1,39 @@ +package com.awhisper.prowlmirror + +import android.Manifest +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions + +@Composable +internal fun PairingScanButton(connect: (Host, String) -> Unit, failed: (String) -> Unit) { + val scanner = rememberLauncherForActivityResult(ScanContract()) { result -> + result.contents?.let { text -> + try { + val payload = PairingPayload.parse(text) + connect(Host(payload.address, payload.port), payload.code) + } catch (e: Exception) { + failed(e.message ?: "Unable to read the pairing QR code") + } + } + } + val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + scanner.launch(ScanOptions().apply { + setDesiredBarcodeFormats(ScanOptions.QR_CODE) + setPrompt("Scan the QR code in Prowl Host → Add a Device") + setBeepEnabled(false) + setOrientationLocked(false) + }) + } else { + failed("Allow camera access in Settings, or enter the Host details manually") + } + } + TextButton(onClick = { permission.launch(Manifest.permission.CAMERA) }) { + Text("Scan QR Code") + } +} diff --git a/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/PairingPayloadTest.kt b/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/PairingPayloadTest.kt new file mode 100644 index 00000000..4736a2c4 --- /dev/null +++ b/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/PairingPayloadTest.kt @@ -0,0 +1,34 @@ +package com.awhisper.prowlmirror + +import org.junit.Assert.* +import org.junit.Test + +class PairingPayloadTest { + private val valid = """{"type":"prowl-mirror-pairing","version":1,"address":"192.168.1.20","port":7880,"code":"ABCDEFGH","expiresAt":1800000060}""" + + @Test fun readsHostPayloadWithoutSavedCredentials() { + for (address in listOf("192.168.1.20", "100.64.0.1", "2001:db8::2")) { + val result = PairingPayload.parse(valid.replace("192.168.1.20", address), 1800000000) + assertEquals(address, result.address) + assertEquals(7880, result.port) + assertEquals("ABCDEFGH", result.code) + } + } + + @Test fun rejectsExpiredOrInvalidPayloads() { + val bad = listOf( + "https://example.com", "{}", "x".repeat(2049), + valid.replace("1800000060", "1800000000"), + valid.replace("\"version\":1", "\"version\":2"), + valid.replace("7880", "0"), valid.replace("7880", "65536"), + valid.replace("7880", "\"7880\""), valid.replace("ABCDEFGH", "invalid"), + valid.replace("192.168.1.20", "example.com"), + valid.replace("192.168.1.20", "127.0.0.1"), + valid.replace("192.168.1.20", "0:0:0:0:0:0:0:1"), + valid.replace("192.168.1.20", "::ffff:127.0.0.1"), + ) + for (text in bad) assertThrows(IllegalArgumentException::class.java) { + PairingPayload.parse(text, 1800000000) + } + } +} diff --git a/MirrorClient/Shared/MirrorPairingPayload.swift b/MirrorClient/Shared/MirrorPairingPayload.swift new file mode 100644 index 00000000..b03ed2c0 --- /dev/null +++ b/MirrorClient/Shared/MirrorPairingPayload.swift @@ -0,0 +1,67 @@ +import Foundation +import Network + +nonisolated struct MirrorPairingPayload: Codable, Equatable { + let type: String + let version: Int + let address: String + let port: UInt16 + let code: String + let expiresAt: Int64 + + enum Problem: LocalizedError { + case invalid, expired + + var errorDescription: String? { + switch self { + case .invalid: String(localized: "Scan a Prowl pairing QR code from Add a Device on Host.") + case .expired: + String(localized: "This pairing code expired. Refresh Code on Host and scan again.") + } + } + } + + init(address: String, port: UInt16, code: String, expires: Date) throws { + type = "prowl-mirror-pairing" + version = 1 + self.address = address + self.port = port + self.code = try MirrorPairingCode.normalized(code) + expiresAt = Int64(expires.timeIntervalSince1970.rounded(.up)) + try validate(now: .distantPast) + } + + static func parse(_ text: String, now: Date = Date()) throws -> Self { + guard text.utf8.count <= 2048, + let payload = try? JSONDecoder().decode(Self.self, from: Data(text.utf8)) + else { throw Problem.invalid } + try payload.validate(now: now) + return payload + } + + func encoded() throws -> String { + String(decoding: try JSONEncoder().encode(self), as: UTF8.self) + } + + private var isLocalOnly: Bool { + if let ipv4 = IPv4Address(address) { + return ipv4.isLoopback || ipv4.rawValue.allSatisfy { $0 == 0 } + } + if let ipv6 = IPv6Address(address) { + let bytes = Array(ipv6.rawValue) + return ipv6.isLoopback || bytes.allSatisfy { $0 == 0 } + || (bytes.prefix(10).allSatisfy { $0 == 0 } && bytes[10] == 255 && bytes[11] == 255 + && (bytes[12] == 127 || bytes.suffix(4).allSatisfy { $0 == 0 })) + } + return true + } + + private func validate(now: Date) throws { + guard type == "prowl-mirror-pairing", version == 1, port > 0, + IPv4Address(address) != nil || IPv6Address(address) != nil, + !isLocalOnly, !address.contains("%"), + (try? MirrorPairingCode.normalized(code)) == code, expiresAt > 0 + else { throw Problem.invalid } + guard Double(expiresAt) > now.timeIntervalSince1970 else { throw Problem.expired } + } +} diff --git a/MirrorClient/iOS/ProwlMirror-iOS.xcodeproj/project.pbxproj b/MirrorClient/iOS/ProwlMirror-iOS.xcodeproj/project.pbxproj index 098152e3..7ebf90f0 100644 --- a/MirrorClient/iOS/ProwlMirror-iOS.xcodeproj/project.pbxproj +++ b/MirrorClient/iOS/ProwlMirror-iOS.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ A82000000000000000000001 /* MirrorLaunchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A82000000000000000000002 /* MirrorLaunchModel.swift */; }; + A83000000000000000000001 /* MirrorPairingPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = A83000000000000000000002 /* MirrorPairingPayload.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -29,6 +30,7 @@ /* Begin PBXFileReference section */ A82000000000000000000002 /* MirrorLaunchModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "../Shared/MirrorLaunchModel.swift"; sourceTree = SOURCE_ROOT; }; + A83000000000000000000002 /* MirrorPairingPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "../Shared/MirrorPairingPayload.swift"; sourceTree = SOURCE_ROOT; }; 6C0FBE88305194410093AD5F /* ProwlMirror-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "ProwlMirror-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 6C0FBE95305194420093AD5F /* ProwlMirror-iOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "ProwlMirror-iOSTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 6C0FBE9F305194420093AD5F /* ProwlMirror-iOSUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "ProwlMirror-iOSUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -81,6 +83,7 @@ isa = PBXGroup; children = ( A82000000000000000000002 /* MirrorLaunchModel.swift */, + A83000000000000000000002 /* MirrorPairingPayload.swift */, 6C0FBE8A305194410093AD5F /* ProwlMirror-iOS */, 6C0FBE98305194420093AD5F /* ProwlMirror-iOSTests */, 6C0FBEA2305194420093AD5F /* ProwlMirror-iOSUITests */, @@ -243,6 +246,7 @@ buildActionMask = 2147483647; files = ( A82000000000000000000001 /* MirrorLaunchModel.swift in Sources */, + A83000000000000000000001 /* MirrorPairingPayload.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -407,6 +411,7 @@ DEVELOPMENT_TEAM = 4HRXWL85AP; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "Scan a Prowl Host pairing code to connect."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Connect to your Prowl Host to mirror terminal panes."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -443,6 +448,7 @@ DEVELOPMENT_TEAM = 4HRXWL85AP; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "Scan a Prowl Host pairing code to connect."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Connect to your Prowl Host to mirror terminal panes."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; diff --git a/MirrorClient/iOS/ProwlMirror-iOS/ContentView.swift b/MirrorClient/iOS/ProwlMirror-iOS/ContentView.swift index 18f520a2..829bf41f 100644 --- a/MirrorClient/iOS/ProwlMirror-iOS/ContentView.swift +++ b/MirrorClient/iOS/ProwlMirror-iOS/ContentView.swift @@ -156,6 +156,7 @@ private struct AddConnectionView: View { @State private var error: String? @State private var session: MirrorSession? @State private var added = false + @State private var showsScanner = false var body: some View { NavigationStack { @@ -199,6 +200,13 @@ private struct AddConnectionView: View { } } else { Section("Host connection") { + Button { + showsScanner = true + } label: { + Label("Scan QR Code", systemImage: "qrcode.viewfinder") + } + .disabled(session?.status == .connecting) + .accessibilityIdentifier("scan-pairing-qr") TextField("Host IP", text: $address) .textInputAutocapitalization(.never).autocorrectionDisabled() .accessibilityIdentifier("host-address") @@ -234,6 +242,18 @@ private struct AddConnectionView: View { } catch { self.error = error.localizedDescription } } } + .sheet(isPresented: $showsScanner) { + MirrorPairingScanner { text in + showsScanner = false + do { + let payload = try MirrorPairingPayload.parse(text) + address = payload.address + port = String(payload.port) + key = payload.code + connect() + } catch { self.error = error.localizedDescription } + } + } .onDisappear { if !added { session?.disconnect() } } } diff --git a/MirrorClient/iOS/ProwlMirror-iOS/Features/Composer/MirrorPairingScanner.swift b/MirrorClient/iOS/ProwlMirror-iOS/Features/Composer/MirrorPairingScanner.swift new file mode 100644 index 00000000..8b955afe --- /dev/null +++ b/MirrorClient/iOS/ProwlMirror-iOS/Features/Composer/MirrorPairingScanner.swift @@ -0,0 +1,115 @@ +import AVFoundation +import SwiftUI +import Vision +import VisionKit + +struct MirrorPairingScanner: View { + @Environment(\.dismiss) private var dismiss + let scanned: (String) -> Void + @State private var ready = false + @State private var error: String? + + var body: some View { + NavigationStack { + Group { + if let error { + ContentUnavailableView( + "Camera unavailable", systemImage: "camera", description: Text(error)) + } else if ready { + PairingCamera(scanned: scanned) { error = $0 } + } else { + ProgressView("Preparing camera…") + } + } + .navigationTitle("Scan Host QR Code") + .navigationBarTitleDisplayMode(.inline) + .toolbar { Button("Cancel") { dismiss() } } + .task { + guard DataScannerViewController.isSupported else { + error = + "This device cannot scan QR codes. Enter the Host address, port and code manually." + return + } + guard await AVCaptureDevice.requestAccess(for: .video) else { + error = "Allow camera access in Settings, or enter the Host details manually." + return + } + guard !Task.isCancelled else { return } + guard DataScannerViewController.isAvailable else { + error = "The camera is unavailable. Try again or enter the Host details manually." + return + } + ready = true + } + } + } +} + +private struct PairingCamera: UIViewControllerRepresentable { + let scanned: (String) -> Void + let failed: (String) -> Void + + func makeCoordinator() -> Coordinator { Coordinator(scanned: scanned, failed: failed) } + + func makeUIViewController(context: Context) -> DataScannerViewController { + let scanner = DataScannerViewController( + recognizedDataTypes: [.barcode(symbologies: [.qr])], qualityLevel: .balanced, + recognizesMultipleItems: false, isGuidanceEnabled: true, isHighlightingEnabled: true) + scanner.delegate = context.coordinator + return scanner + } + + func updateUIViewController(_ scanner: DataScannerViewController, context: Context) { + guard !scanner.isScanning, !context.coordinator.finished else { return } + do { try scanner.startScanning() } catch { + let coordinator = context.coordinator + Task { coordinator.fail(error.localizedDescription) } + } + } + + static func dismantleUIViewController( + _ scanner: DataScannerViewController, coordinator: Coordinator + ) { + scanner.stopScanning() + coordinator.finished = true + } + + final class Coordinator: NSObject, DataScannerViewControllerDelegate { + let scanned: (String) -> Void + let failed: (String) -> Void + var finished = false + + init(scanned: @escaping (String) -> Void, failed: @escaping (String) -> Void) { + self.scanned = scanned + self.failed = failed + } + + func dataScanner( + _ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], + allItems: [RecognizedItem] + ) { + guard !finished else { return } + for item in addedItems { + if case .barcode(let barcode) = item, let text = barcode.payloadStringValue { + finished = true + dataScanner.stopScanning() + scanned(text) + return + } + } + } + + func dataScanner( + _ dataScanner: DataScannerViewController, + becameUnavailableWithError error: DataScannerViewController.ScanningUnavailable + ) { + fail(error.localizedDescription) + } + + func fail(_ message: String) { + guard !finished else { return } + finished = true + failed(message) + } + } +} diff --git a/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorPairingPayloadTests.swift b/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorPairingPayloadTests.swift new file mode 100644 index 00000000..a1e126c7 --- /dev/null +++ b/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorPairingPayloadTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing + +@testable import ProwlMirror_iOS + +struct MirrorPairingPayloadTests { + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + @Test func roundTripIPv4AndIPv6() throws { + for address in ["192.168.1.20", "100.64.0.1", "2001:db8::2"] { + let payload = try MirrorPairingPayload( + address: address, port: 7880, code: "ABCD-EFGH", expires: now.addingTimeInterval(60)) + let parsed = try MirrorPairingPayload.parse(payload.encoded(), now: now) + #expect(parsed == payload) + #expect(parsed.code == "ABCDEFGH") + } + } + + @Test func rejectsExpiredAndMalformedCodes() throws { + let valid = + #"{"type":"prowl-mirror-pairing","version":1,"address":"192.168.1.20","port":7880,"# + + #""code":"ABCDEFGH","expiresAt":1800000060}"# + #expect(try MirrorPairingPayload.parse(valid, now: now).port == 7880) + let bad = [ + "https://example.com", "{}", String(repeating: "x", count: 2049), + valid.replacing("1800000060", with: "1800000000"), + valid.replacing("\"version\":1", with: "\"version\":2"), + valid.replacing("7880", with: "0"), valid.replacing("7880", with: "65536"), + valid.replacing("7880", with: "\"7880\""), + valid.replacing("ABCDEFGH", with: "invalid"), + valid.replacing("192.168.1.20", with: "example.com"), + valid.replacing("192.168.1.20", with: "127.0.0.1"), + valid.replacing("192.168.1.20", with: "0:0:0:0:0:0:0:1"), + valid.replacing("192.168.1.20", with: "::ffff:127.0.0.1"), + ] + for text in bad { + #expect(throws: (any Error).self) { try MirrorPairingPayload.parse(text, now: now) } + } + } +} diff --git a/MirrorClient/iOS/README.md b/MirrorClient/iOS/README.md index 5cf74f90..00628ecf 100644 --- a/MirrorClient/iOS/README.md +++ b/MirrorClient/iOS/README.md @@ -77,3 +77,7 @@ See the shared [wire contract](../../docs/remote-mirror-wire.md) and Ordinary Send requires Host capability `agent-input`. It does not add a completion prompt or create a pending dispatch; prior automation receipts remain unchanged. Delivery confirmation and reconnect receipt lookup remain separate from task success. + +Use **Scan QR Code** in the Host connection form to scan **Add a Device** on Host. +Scanning connects with the encoded address, port and short-lived code. Camera +permission is optional; manual entry remains available. Refresh expired codes on Host. diff --git a/docs-ai/067-remote-mirror/000-plan.md b/docs-ai/067-remote-mirror/000-plan.md index 62b85158..5f1cd7ec 100644 --- a/docs-ai/067-remote-mirror/000-plan.md +++ b/docs-ai/067-remote-mirror/000-plan.md @@ -69,3 +69,4 @@ capability needed here. Browser clients are outside this implementation. - Updated 2026-09-17: Planned Mac-side Shell and Agent Profile creation and connection-picker improvements — see [008-client-pane-launch.md](008-client-pane-launch.md). - Updated 2026-10-01: Separate mobile interactive input from task receipts — see [009](009-interactive-input.md). +- Updated 2026-10-01: QR enrollment for mobile clients — see [010-qr-pairing.md](010-qr-pairing.md). diff --git a/docs-ai/067-remote-mirror/010-qr-pairing.md b/docs-ai/067-remote-mirror/010-qr-pairing.md new file mode 100644 index 00000000..cbfd65d2 --- /dev/null +++ b/docs-ai/067-remote-mirror/010-qr-pairing.md @@ -0,0 +1,32 @@ +# 067.010 — QR device pairing + +Status: Implemented (2026-10-01). + +## Context and design + +Manual mobile enrollment requires copying an IP, port and short-lived code. Add a +QR representation to Host's existing Add a Device sheet and an explicit scanner in +both mobile connection forms. Scanning fills a fresh connection and immediately +uses the existing authenticated enrollment flow; it never reuses a saved credential. + +The versioned JSON payload contains type, version, numeric IP address, port, code +and expiry (Unix seconds). Clients validate bounded input, field types, IP, port, +code and expiry before connecting. Host remains authoritative for code expiry and +single use. Do not log or persist QR payloads. No URL registration or network +protocol change is required. Multiple Host interfaces require an address picker. + +macOS uses Core Image to generate QR images. iOS uses the system VisionKit scanner +with camera permission and manual fallback. Android uses embedded ZXing so scanning +works without Google Play Services. Cancel/denied/unsupported camera retains manual +entry. Test shared parser vectors, malformed/expired payloads and QR decoding; +build all three targets. Physical camera acceptance is separate from simulator tests. + +## Verification and remaining acceptance + +macOS Debug build and signature verification passed. Host tests cover payload +validation, IPv4/IPv6, QR image generation and decoding, plus existing Host and +address-selection behavior. iOS simulator tests passed (50). Android Debug build, +unit tests and lint passed (27 passed, one external TLS fixture skipped). +Full repository check still reports the five existing legacy SwiftUI aspect-ratio +violations in RepositoryIconImage.swift; changed Host Swift files pass strict lint. +Physical-camera scan/pair acceptance remains for the user; no PR is submitted. diff --git a/docs-ai/README.md b/docs-ai/README.md index 6ed92f83..1fd62982 100644 --- a/docs-ai/README.md +++ b/docs-ai/README.md @@ -126,7 +126,7 @@ agent-facing manual for that). | 064 | [agent-completion-signals](064-agent-completion-signals/000-plan.md) | 2026-08-22 | Layered agent signal bus (cooperative / launch-scoped hooks / transcript+process+OSC / heuristic), `prowl agents signal` + `agents wait` with source/confidence, per-runtime hook research, [T1 contract-test plan](064-agent-completion-signals/016-t1-contract-test-plan.md), [planned log/screen state decision](064-agent-completion-signals/017-agent-state-decision.md), [foreground/subagent findings](064-agent-completion-signals/018-foreground-and-subagent-findings.md), [foreground identity contract](064-agent-completion-signals/019-foreground-identity-contract.md), [selection channels/fallback](064-agent-completion-signals/020-selection-channel-research.md), [provider/state-machine design](064-agent-completion-signals/021-provider-state-machine-design.md), and [implementation/acceptance](064-agent-completion-signals/022-provider-implementation.md), and [continuity hardening](064-agent-completion-signals/023-provider-continuity-hardening.md), and [capture/emission corrections](064-agent-completion-signals/024-observation-order-and-emission.md) | | 065 | [bundled-agent-skills](065-bundled-agent-skills/000-plan.md) | 2026-08-22 | Bundle Prowl's official agent skills into the app, `prowl skills` install/uninstall via symlinks into agent skill folders, Agent Skills section on Settings › CLI & Skills, shared registry for 063 | | 066 | [agent-island](066-agent-island/000-plan.md) | 2026-09-01 | Notch-aware Active Agents island, global keyboard entry, Agents Display settings, and hover-revealed floating placement | -| 067 | [remote-mirror](067-remote-mirror/000-plan.md) | 2026-09-09 | App-owned Host and native remote terminal mirrors; [macOS UX and viewport](067-remote-mirror/006-macos-ux.md), [Mac pane creation](067-remote-mirror/008-client-pane-launch.md) | +| 067 | [remote-mirror](067-remote-mirror/000-plan.md) | 2026-09-09 | App-owned Host and native remote terminal mirrors; [macOS UX and viewport](067-remote-mirror/006-macos-ux.md), [Mac pane creation](067-remote-mirror/008-client-pane-launch.md), [QR pairing](067-remote-mirror/010-qr-pairing.md) | | 068 | [agent-state-providers](068-agent-state-providers/000-plan.md) | 2026-09-14 | Shared [architecture](068-agent-state-providers/architecture.md), released [Codex provider](068-agent-state-providers/codex.md), and [Claude native adapter](068-agent-state-providers/claude.md); [staged acceptance](068-agent-state-providers/002-native-runtime-implementation.md), [acceptance complete](068-agent-state-providers/001-action.md); fullscreen case excluded by owner | | 069 | [undo-close-terminal](069-undo-close-terminal/000-plan.md) | 2026-09-16 | Undo a pane or tab close with ⌘Z within Ghostty’s `undo-timeout`: closed surfaces stay alive off-tree and restore into their original tab and split position | | 070 | [app-localization](070-app-localization/000-plan.md) | 2026-09-18 | Simplified Chinese UI from a String Catalog; translations sync at release time through the `sync-l10n` skill (nothing blocks everyday work); compiler-based coverage audit plus a baseline for unlocalized copy; living [glossary](070-app-localization/glossary.md); language choice stored only in the per-app `AppleLanguages` default | diff --git a/docs/remote-mirror-wire.md b/docs/remote-mirror-wire.md index 43b9f1f1..104d5b7b 100644 --- a/docs/remote-mirror-wire.md +++ b/docs/remote-mirror-wire.md @@ -82,3 +82,20 @@ clearing that draft revision. Failed/uncertain delivery retains the draft; `SEND_FAILED` is uncertain, not permission to replay. Existing request-ID deduplication and `commandReceipt` recovery apply. A missing `agent-input` capability requires a Host update; clients must not silently fall back to `agentsDispatch` or raw input. + +## QR enrollment payload (version 1) + +The QR carries UTF-8 JSON, not a URL or a transport message: + +```json +{"type":"prowl-mirror-pairing","version":1,"address":"192.168.1.20","port":7880,"code":"ABCDEFGH","expiresAt":1800000060} +``` + +`address` is a numeric IPv4/IPv6 address reachable from the phone, never a wildcard +or loopback. `port` is an integer in 1...65535. `code` uses the existing eight-symbol +normalized pairing alphabet. `expiresAt` is an integer Unix time in seconds. +Scanners accept at most 2048 UTF-8 bytes, reject invalid/expired payloads and create +a fresh connection without a saved credential. Host still enforces expiry and +single use. The payload is not persisted or logged; transport authentication and +credential storage are unchanged. Version 1 is generated by Host and parsed by +both mobile apps. Cameras are not required for manual enrollment. diff --git a/docs/remote-mirror.md b/docs/remote-mirror.md index 34518be4..a80d22bd 100644 --- a/docs/remote-mirror.md +++ b/docs/remote-mirror.md @@ -196,3 +196,16 @@ Native clients live in [MirrorClient/iOS](../MirrorClient/iOS/) (iPhone and iPad and [MirrorClient/Android](../MirrorClient/Android/) (phones and tablets). Each project retains its own build and test entry points; neither is built by the Mac App target or release pipeline. See [client setup](../MirrorClient/README.md). + +### Scan to pair a phone + +Open **Add a Device** on Host, then **Scan QR Code** in the mobile app's Host +connection form. The QR includes the numeric IP address, port and current pairing +code. Scanning starts the normal authenticated pairing flow and then offers the +Host panes. Choose the Wi-Fi or VPN address reachable from your phone in **QR +address** when Host has multiple interfaces. Both devices still need network access +to that address; scanning does not create a tunnel. + +The QR expires with the existing 60-second, single-use code. Refresh Code on Host +and scan again if it expires. Camera permission is requested only when scanning; +manual entry remains available if permission is denied or scanning is unsupported. diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index f63428a8..f064c3a7 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ A82000000000000000000001 /* MirrorLaunchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A82000000000000000000002 /* MirrorLaunchModel.swift */; }; + A83000000000000000000001 /* MirrorPairingPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = A83000000000000000000002 /* MirrorPairingPayload.swift */; }; A79300000000000000000001 /* prowl-mirror-relay in Copy Files */ = {isa = PBXBuildFile; fileRef = A79300000000000000000002; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; A906AC710000000000000003 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = A906AC710000000000000004 /* OrderedCollections */; }; A906AC710000000000000001 /* workflows in Resources */ = {isa = PBXBuildFile; fileRef = A906AC710000000000000002 /* workflows */; }; @@ -58,6 +59,7 @@ /* Begin PBXFileReference section */ A82000000000000000000002 /* MirrorLaunchModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MirrorClient/Shared/MirrorLaunchModel.swift"; sourceTree = SOURCE_ROOT; }; + A83000000000000000000002 /* MirrorPairingPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MirrorClient/Shared/MirrorPairingPayload.swift"; sourceTree = SOURCE_ROOT; }; A79300000000000000000002 /* prowl-mirror-relay */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; path = "Resources/prowl-mirror-relay/prowl-mirror-relay"; sourceTree = SOURCE_ROOT; }; A906AC710000000000000002 /* workflows */ = {isa = PBXFileReference; lastKnownFileType = folder; name = workflows; path = Resources/workflows; sourceTree = ""; }; D64162AD2F23CAF100260CA3 /* ghostty */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ghostty; path = Resources/ghostty; sourceTree = ""; }; @@ -142,6 +144,7 @@ isa = PBXGroup; children = ( A82000000000000000000002 /* MirrorLaunchModel.swift */, + A83000000000000000000002 /* MirrorPairingPayload.swift */, D6DOC0022F99000300000001 /* docs */, A906AC710000000000000002 /* workflows */, D6SKL0022F99000500000001 /* skills */, @@ -361,6 +364,7 @@ buildActionMask = 2147483647; files = ( A82000000000000000000001 /* MirrorLaunchModel.swift in Sources */, + A83000000000000000000001 /* MirrorPairingPayload.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/supacode/Features/RemoteMirror/MirrorHostButton.swift b/supacode/Features/RemoteMirror/MirrorHostButton.swift index 019f4361..9e6221b0 100644 --- a/supacode/Features/RemoteMirror/MirrorHostButton.swift +++ b/supacode/Features/RemoteMirror/MirrorHostButton.swift @@ -385,6 +385,7 @@ private struct MirrorSettingsView: View { private struct MirrorPairingView: View { @Bindable var host: MirrorHost let dismiss: () -> Void + @State private var qrAddress = "" @State private var hasRequestedCode = false @State private var copied = false @State private var copyError: String? @@ -413,13 +414,14 @@ private struct MirrorPairingView: View { Text( """ On the other device, open Remote Mirror → Client → Connect to a New Host. \ - Enter one of these addresses with the port, then the code below. + Scan the QR code in the mobile app, or enter an address, port and code below. """ ) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) addresses code + pairingQR } if let error = copyError ?? host.error { Text(error).foregroundStyle(.red).fixedSize(horizontal: false, vertical: true) @@ -471,6 +473,29 @@ private struct MirrorPairingView: View { } } + private var pairingQR: some View { + let choices = reachable.filter { !$0.isLoopback && $0.address != "::1" } + let selected = + choices.first(where: { $0.address == qrAddress })?.address ?? choices.first?.address + return VStack(spacing: 8) { + if let expires = host.pairingExpiresAt, let selected { + if choices.count > 1 { + Picker("QR address", selection: Binding(get: { selected }, set: { qrAddress = $0 })) { + ForEach(choices) { item in + Text("\(item.label): \(item.address)").tag(item.address) + } + } + .help("Choose an address reachable from your phone over Wi-Fi or VPN") + } + MirrorPairingQRCode( + address: selected, port: host.port, code: host.pairingKey, expires: expires) + Text("In Prowl Mirror on your phone, tap Scan QR Code.") + .font(.caption).foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity) + } + private var addresses: some View { VStack(alignment: .leading, spacing: 8) { Text("This Mac’s address").font(.subheadline.weight(.semibold)) diff --git a/supacode/Features/RemoteMirror/MirrorPairingQRCode.swift b/supacode/Features/RemoteMirror/MirrorPairingQRCode.swift new file mode 100644 index 00000000..a26d49b9 --- /dev/null +++ b/supacode/Features/RemoteMirror/MirrorPairingQRCode.swift @@ -0,0 +1,45 @@ +import CoreImage.CIFilterBuiltins +import SwiftUI + +struct MirrorPairingQRCode: View { + let address: String + let port: String + let code: String + let expires: Date + + var body: some View { + if let image = image { + Image(decorative: image, scale: 1) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: 180, height: 180) + .padding(12) + // A fixed light quiet zone keeps the code readable in either appearance. + .background(.white) + .accessibilityLabel("Pairing QR code") + .accessibilityIdentifier("remote-mirror-pairing-qr") + } else { + Text("Unable to generate the QR code. Use the address and pairing code above.") + .font(.caption).foregroundStyle(.secondary) + } + } + + private var image: CGImage? { + guard let number = UInt16(port), + let payload = try? MirrorPairingPayload( + address: address, port: number, code: code, expires: expires), + let text = try? payload.encoded() + else { return nil } + return Self.image(for: text) + } + + static func image(for text: String) -> CGImage? { + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(text.utf8) + filter.correctionLevel = "M" + guard let output = filter.outputImage else { return nil } + let scaled = output.transformed(by: CGAffineTransform(scaleX: 6, y: 6)) + return CIContext().createCGImage(scaled, from: scaled.extent) + } +} diff --git a/supacodeTests/RemoteMirror/MirrorPairingPayloadTests.swift b/supacodeTests/RemoteMirror/MirrorPairingPayloadTests.swift new file mode 100644 index 00000000..e677224d --- /dev/null +++ b/supacodeTests/RemoteMirror/MirrorPairingPayloadTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing + +@testable import supacode + +struct MirrorPairingPayloadTests { + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + @Test func roundTripIPv4AndIPv6() throws { + for address in ["192.168.1.20", "100.64.0.1", "2001:db8::2"] { + let payload = try MirrorPairingPayload( + address: address, port: 7880, code: "ABCD-EFGH", expires: now.addingTimeInterval(60)) + let parsed = try MirrorPairingPayload.parse(payload.encoded(), now: now) + #expect(parsed == payload) + #expect(parsed.code == "ABCDEFGH") + } + } + + @Test func rejectsExpiredAndMalformedCodes() throws { + let valid = + #"{"type":"prowl-mirror-pairing","version":1,"address":"192.168.1.20","port":7880,"# + + #""code":"ABCDEFGH","expiresAt":1800000060}"# + #expect(try MirrorPairingPayload.parse(valid, now: now).port == 7880) + let bad = [ + "https://example.com", "{}", String(repeating: "x", count: 2049), + valid.replacing("1800000060", with: "1800000000"), + valid.replacing("\"version\":1", with: "\"version\":2"), + valid.replacing("7880", with: "0"), valid.replacing("7880", with: "65536"), + valid.replacing("7880", with: "\"7880\""), + valid.replacing("ABCDEFGH", with: "invalid"), + valid.replacing("192.168.1.20", with: "example.com"), + valid.replacing("192.168.1.20", with: "127.0.0.1"), + valid.replacing("192.168.1.20", with: "0:0:0:0:0:0:0:1"), + valid.replacing("192.168.1.20", with: "::ffff:127.0.0.1"), + ] + for text in bad { + #expect(throws: (any Error).self) { try MirrorPairingPayload.parse(text, now: now) } + } + } +} diff --git a/supacodeTests/RemoteMirror/MirrorPairingQRCodeTests.swift b/supacodeTests/RemoteMirror/MirrorPairingQRCodeTests.swift new file mode 100644 index 00000000..452e8f4b --- /dev/null +++ b/supacodeTests/RemoteMirror/MirrorPairingQRCodeTests.swift @@ -0,0 +1,21 @@ +import CoreImage +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct MirrorPairingQRCodeTests { + @Test func generatedImageDecodesToPairingPayload() throws { + let payload = try MirrorPairingPayload( + address: "192.168.1.20", port: 7880, code: "ABCD-EFGH", expires: Date().addingTimeInterval(60) + ) + let text = try payload.encoded() + let image = try #require(MirrorPairingQRCode.image(for: text)) + let detector = try #require(CIDetector(ofType: CIDetectorTypeQRCode, context: CIContext())) + let feature = try #require( + detector.features(in: CIImage(cgImage: image)).first as? CIQRCodeFeature) + #expect(feature.messageString == text) + #expect(try MirrorPairingPayload.parse(#require(feature.messageString)) == payload) + } +} -- 2.51.2 From ad350c6d7828c448bafa1f7f847fe1a42407007a Mon Sep 17 00:00:00 2001 From: Awhisper <4347733+Awhisper@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:27:18 +0800 Subject: [PATCH 03/14] Add remote viewport scrolling across mirror clients --- MirrorClient/Android/README.md | 11 +- .../awhisper/prowlmirror/RemoteScrollTest.kt | 219 ++++++++++++ .../com/awhisper/prowlmirror/MirrorApp.kt | 76 +++- .../com/awhisper/prowlmirror/RemoteScroll.kt | 26 ++ .../java/com/awhisper/prowlmirror/Session.kt | 72 +++- .../java/com/awhisper/prowlmirror/Wire.kt | 5 + .../java/com/awhisper/prowlmirror/CoreTest.kt | 33 ++ .../com/awhisper/prowlmirror/SessionTest.kt | 164 ++++++++- MirrorClient/Android/docs/validation.md | 22 ++ .../iOS/ProwlMirror-iOS/ContentView.swift | 77 +++- .../Reading/MirrorRemoteScrollGesture.swift | 13 + .../Features/Sessions/MirrorSession.swift | 80 +++++ .../Infrastructure/MirrorProtocol.swift | 38 +- .../Infrastructure/MirrorUIFixture.swift | 56 ++- .../MirrorRemoteScrollTests.swift | 333 ++++++++++++++++++ .../ProwlMirror_iOSUITests.swift | 123 +++++++ MirrorClient/iOS/README.md | 17 + docs-ai/067-remote-mirror/000-plan.md | 1 + .../067-remote-mirror/011-remote-scroll.md | 80 +++++ docs/remote-mirror-wire.md | 60 +++- docs/remote-mirror.md | 30 +- .../GhosttyMirrorPaneSource.swift | 125 ++++++- .../Features/RemoteMirror/MirrorClient.swift | 118 ++++++- .../Features/RemoteMirror/MirrorHost.swift | 127 ++++++- .../RemoteMirror/MirrorPaneSource.swift | 13 + .../RemoteMirror/MirrorProtocol.swift | 63 +++- .../RemoteMirror/MirrorScrollState.swift | 73 ++++ .../RemoteMirror/MirrorTerminalViewport.swift | 136 ++++++- .../RemoteMirror/MirrorViewportState.swift | 33 ++ .../RemoteMirror/RemoteMirrorPaneView.swift | 36 +- .../RemoteMirror/MirrorHostTests.swift | 264 +++++++++++++- .../RemoteMirror/MirrorProtocolTests.swift | 62 ++++ .../RemoteMirror/MirrorScrollStateTests.swift | 90 +++++ .../MirrorTerminalIntegrationTests.swift | 207 +++++++++++ .../MirrorViewportStateTests.swift | 73 ++++ 35 files changed, 2888 insertions(+), 68 deletions(-) create mode 100644 MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt create mode 100644 MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/RemoteScroll.kt create mode 100644 MirrorClient/iOS/ProwlMirror-iOS/Features/Reading/MirrorRemoteScrollGesture.swift create mode 100644 MirrorClient/iOS/ProwlMirror-iOSTests/MirrorRemoteScrollTests.swift create mode 100644 docs-ai/067-remote-mirror/011-remote-scroll.md create mode 100644 supacode/Features/RemoteMirror/MirrorScrollState.swift create mode 100644 supacode/Features/RemoteMirror/MirrorViewportState.swift create mode 100644 supacodeTests/RemoteMirror/MirrorScrollStateTests.swift create mode 100644 supacodeTests/RemoteMirror/MirrorViewportStateTests.swift diff --git a/MirrorClient/Android/README.md b/MirrorClient/Android/README.md index 3266bb8e..80de5db8 100644 --- a/MirrorClient/Android/README.md +++ b/MirrorClient/Android/README.md @@ -21,7 +21,7 @@ Select an open pane, or New Agent Pane → workspace → available Host Agent Pr → optional initial message. Models and permissions come from that Profile. There is no private AI console or dependency on personal shell wrappers. -- Active text replaces the last snapshot; it is not an append-only transcript. +- Live text replaces the last viewport snapshot; it is not an append-only transcript. - Markdown emphasis/inline code, code fences and tables have readable previews; expanding code/table freezes the detail and supports copying the original text. - Multiline input sends only on Send or two physical Returns within 350 ms. @@ -34,6 +34,15 @@ is no private AI console or dependency on personal shell wrappers. If another client claims a free pane before Mirror completes, Android reports it busy. - History is a bounded frozen snapshot with earlier-page loading. Returning to Live keeps receiving current output. Closing a mirror never stops the Host program. +- Hosts with `remote-scroll` also show **Scroll up / Scroll down** in Live. Pull + outward from the top or bottom of the current text to request a bounded scroll step; + ordinary local scrolling, horizontal gestures and History do not send remote input. + These actions scroll the actual Host pane, including a terminal application's own + scroll handling, so the distance depends on that application. One request runs at + a time with a loading indicator; an unchanged screen also completes normally. + Requests time out after five seconds and are never replayed after reconnecting. + After scrolling up, reading continues at the newly revealed top edge; + scrolling down moves to the bottom edge. Automatic following is paused by remote scrolling. - Saving credentials does not persist terminal output or drafts across process death. Rotation retains the current ViewModel; reopening the app offers saved Hosts. diff --git a/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt b/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt new file mode 100644 index 00000000..9ce6b0da --- /dev/null +++ b/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt @@ -0,0 +1,219 @@ +package com.awhisper.prowlmirror + +import android.graphics.Bitmap +import androidx.activity.ComponentActivity +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.io.File +import kotlinx.coroutines.* +import org.junit.* +import org.junit.Assert.* +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RemoteScrollTest { + @get:Rule val rule = createAndroidComposeRule() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private lateinit var session: Session + private lateinit var receive: (Packet) -> Unit + private lateinit var ready: (Host) -> Unit + private val sent = mutableListOf() + private val lease = uuid() + private var sequence = 1L + + @After + fun close() { + rule.runOnIdle { + if (::session.isInitialized) session.close() + scope.cancel() + } + } + + private fun show(text: String = "Current viewport\nReady for remote scrolling.", capability: Boolean = true) { + val host = Host("test-host") + val pane = Pane(uuid(), "Scroll test", "/test", false) + val run = uuid() + lateinit var model: MirrorModel + rule.runOnIdle { + session = Session(host, scope, TransportFactory { _, _, _, r, a, _, _ -> + receive = r + ready = a + object : Transport { + override fun close() {} + override fun send(message: Packet.Control) { sent += message } + } + }) + session.connect() + ready(host) + receive(control("panes", obj( + "panes" to listOf(pane), + "capabilities" to listOfNotNull("text-v1", "history", "remote-scroll".takeIf { capability }), + "hostRunID" to run, + ))) + session.choose(pane) + receive(control("subscribed", obj( + "paneID" to pane.id, "subscriptionID" to lease, "hostRunID" to run, + ))) + receive(Packet.Text(lease, sequence, 80, 24, false, text)) + session.setFollow(false) + model = MirrorModel(rule.activity.application) + model.sessions += session + model.selected = session.id + } + rule.setContent { MirrorApp(model) } + } + + private fun complete(text: String = session.state.value.text) { + rule.runOnIdle { + val request = sent.last { it.kind == "scroll" }.payload() + receive(Packet.Text(lease, ++sequence, 80, 24, false, text)) + receive(control("scrollResult", obj( + "subscriptionID" to lease, + "requestID" to request.string("requestID"), + "sequence" to sequence, + ))) + } + rule.waitForIdle() + } + + private fun screenshot(name: String) { + val bitmap = rule.onRoot().captureToImage().asAndroidBitmap() + val directory = File(InstrumentationRegistry.getInstrumentation().targetContext.cacheDir, "scroll-tests") + directory.mkdirs() + File(directory, "$name.png").outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + } + + @Test + fun buttonsShowLoadingAndUnchangedFrameFinishesWithoutDisconnect() { + show() + rule.onNodeWithText("Scroll up").assertIsEnabled().performClick() + rule.onNodeWithText("Scroll up").assertIsNotEnabled() + rule.onNodeWithText("Scroll down").assertIsNotEnabled() + rule.onNodeWithText("Loading…").assertIsDisplayed() + screenshot("loading") + rule.runOnIdle { + assertEquals(1, sent.count { it.kind == "scroll" }) + assertFalse(session.state.value.follow) + } + complete() + rule.onNodeWithText("Loading…").assertDoesNotExist() + rule.onNodeWithText("Scroll down").assertIsEnabled().performClick() + complete("Later viewport\nRemote scroll completed.") + rule.onNodeWithText("Later viewport\nRemote scroll completed.").assertIsDisplayed() + rule.runOnIdle { + assertEquals(listOf("up", "down"), sent.filter { it.kind == "scroll" }.map { it.payload().string("direction") }) + assertEquals(Status.live, session.state.value.status) + } + screenshot("completed") + } + + @Test + fun edgePullsSendOneRequestAndHorizontalSmallAndBusyGesturesDoNot() { + show() + val output = rule.onNodeWithTag("mirror-output") + output.performTouchInput { swipe(Offset(width * .8f, height * .5f), Offset(width * .2f, height * .5f)) } + output.performTouchInput { swipe(Offset(width * .5f, height * .4f), Offset(width * .5f, height * .4f + 8f)) } + rule.runOnIdle { assertEquals(0, sent.count { it.kind == "scroll" }) } + output.performTouchInput { swipe(Offset(width * .5f, height * .3f), Offset(width * .5f, height * .7f)) } + rule.runOnIdle { assertEquals("up", sent.last { it.kind == "scroll" }.payload().string("direction")) } + output.performTouchInput { swipeUp() } + rule.runOnIdle { assertEquals(1, sent.count { it.kind == "scroll" }) } + complete() + output.performTouchInput { swipe(Offset(width * .5f, height * .7f), Offset(width * .5f, height * .3f)) } + rule.runOnIdle { + assertEquals(2, sent.count { it.kind == "scroll" }) + assertEquals("down", sent.last { it.kind == "scroll" }.payload().string("direction")) + } + complete() + } + + @Test + fun longPressDragDoesNotScrollTheRemotePane() { + show() + rule.onNodeWithTag("mirror-output").performTouchInput { + down(Offset(width * .3f, 80f)) + advanceEventTime(700) + moveTo(Offset(width * .3f, 330f)) + up() + } + rule.runOnIdle { assertEquals(0, sent.count { it.kind == "scroll" }) } + } + + @Test + fun nativeTextScrollingRemainsLocalAndRemoteCompletionAnchorsCorrectly() { + val text = (1..140).joinToString("\n") { "Terminal row $it" } + show(text) + val output = rule.onNodeWithTag("mirror-output") + output.performTouchInput { swipeUp() } + rule.runOnIdle { + assertEquals(0, sent.count { it.kind == "scroll" }) + assertTrue(session.liveScrollIndex > 0 || session.liveScrollOffset > 0) + } + rule.onNodeWithText("Scroll up").performClick() + complete(text) + rule.runOnIdle { + assertEquals(0, session.liveScrollIndex) + assertEquals(0, session.liveScrollOffset) + } + rule.onNodeWithText("Scroll down").performClick() + complete(text) + rule.runOnIdle { + assertTrue(session.liveScrollIndex > 0 || session.liveScrollOffset > 0) + } + screenshot("local-reading") + } + + @Test + fun oldHostsHaveNoRemoteControlsOrRemoteGestures() { + show(capability = false) + rule.onNodeWithText("Scroll up").assertDoesNotExist() + rule.onNodeWithText("Scroll down").assertDoesNotExist() + rule.onNodeWithTag("mirror-output").performTouchInput { swipeDown() } + rule.runOnIdle { assertEquals(0, sent.count { it.kind == "scroll" }) } + } + + @Test + fun historyKeepsItsOwnScrollingOnHostsThatSupportRemoteScrolling() { + show() + rule.onNodeWithContentDescription("History").performClick() + rule.runOnIdle { + receive(control("historyPage", obj( + "subscriptionID" to lease, "historyID" to uuid(), "offset" to 0, + "total" to 2, "capturedAt" to 1.0, "truncated" to false, + "lines" to listOf("Frozen history", "Still available"), + ))) + } + rule.onNodeWithText("Frozen history\nStill available").assertIsDisplayed() + rule.onNodeWithText("Scroll up").assertDoesNotExist() + rule.onNodeWithText("Scroll down").assertDoesNotExist() + rule.onNodeWithText("Load earlier").assertIsNotEnabled() + rule.onNodeWithTag("mirror-output").performTouchInput { swipeDown() } + rule.runOnIdle { assertEquals(0, sent.count { it.kind == "scroll" }) } + screenshot("history") + rule.onNodeWithText("Back to Live").performClick() + rule.onNodeWithText("Current viewport\nReady for remote scrolling.").assertIsDisplayed() + } + + @Test + fun unavailableEndsLoadingAndKeepsLiveControlsUsable() { + show() + rule.onNodeWithText("Scroll up").performClick() + rule.runOnIdle { + val request = sent.last { it.kind == "scroll" }.payload() + receive(control("failure", obj( + "requestID" to request.string("requestID"), + "subscriptionID" to lease, + "error" to "SCROLL_UNAVAILABLE: The pane is not available for scrolling.", + ))) + } + rule.onNodeWithText("Loading…").assertDoesNotExist() + rule.onNodeWithText("SCROLL_UNAVAILABLE: The pane is not available for scrolling.").assertIsDisplayed() + rule.onNodeWithText("Scroll down").assertIsEnabled() + rule.runOnIdle { assertEquals(Status.live, session.state.value.status) } + screenshot("unavailable") + } +} diff --git a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt index acaa8571..208ff3b5 100644 --- a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt +++ b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt @@ -2,6 +2,9 @@ package com.awhisper.prowlmirror import android.view.KeyEvent as AndroidKeyEvent import androidx.compose.foundation.* +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.interaction.DragInteraction import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -17,7 +20,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontFamily @@ -379,6 +385,46 @@ private fun Reading(session: Session, state: SessionState) { if (state.showsHistory) session.historyScrollIndex else session.liveScrollIndex, if (state.showsHistory) session.historyScrollOffset else session.liveScrollOffset, ) + val canScrollRemote by rememberUpdatedState(state.canScrollRemote) + val threshold = with(LocalDensity.current) { 56.dp.toPx() } + val remoteGesture = + if (!state.showsHistory && "remote-scroll" in state.capabilities) + Modifier.pointerInput(session.id, threshold) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + val enabled = canScrollRemote + val atTop = !scroll.canScrollBackward + val atBottom = !scroll.canScrollForward + var position = down.position + var multiplePointers = false + var dragging = false + var longPressed = false + do { + val event = awaitPointerEvent(PointerEventPass.Initial) + if (event.changes.count { it.pressed } > 1) multiplePointers = true + val change = event.changes.firstOrNull { it.id == down.id } ?: break + position = change.position + if (!dragging) { + // A hold before moving belongs to native text selection. + if (change.uptimeMillis - down.uptimeMillis >= viewConfiguration.longPressTimeoutMillis) + longPressed = true + if ((position - down.position).getDistance() > viewConfiguration.touchSlop) + dragging = true + } + } while (change.pressed) + // Observe without consuming: local text scrolling and selection stay native. + if (enabled && canScrollRemote && !multiplePointers && !longPressed) { + remoteScrollDirection( + position.x - down.position.x, + position.y - down.position.y, + atTop, + atBottom, + threshold, + )?.let(session::scrollRemote) + } + } + } + else Modifier LaunchedEffect(scroll) { scroll.interactionSource.interactions.collect { if (it is DragInteraction.Start && !state.showsHistory) session.setFollow(false) @@ -400,6 +446,19 @@ private fun Reading(session: Session, state: SessionState) { if (state.follow && !state.showsHistory && blocks.isNotEmpty()) scroll.scrollToItem(blocks.lastIndex) } + LaunchedEffect(state.scrollCompletion) { + val completion = state.scrollCompletion + if (!state.showsHistory && completion != null && blocks.isNotEmpty()) { + if (completion.direction == ScrollDirection.DOWN) { + scroll.scrollToItem(blocks.lastIndex) + val layout = scroll.layoutInfo + layout.visibleItemsInfo.lastOrNull { it.index == blocks.lastIndex }?.let { + val remaining = it.offset + it.size + layout.afterContentPadding - layout.viewportEndOffset + scroll.scrollBy(remaining.coerceAtLeast(0).toFloat()) + } + } else scroll.scrollToItem(0) + } + } Column(Modifier.fillMaxSize()) { if (state.showsHistory) Row(verticalAlignment = Alignment.CenterVertically) { @@ -413,8 +472,23 @@ private fun Reading(session: Session, state: SessionState) { if (state.historyTruncated) Text("History truncated", style = MaterialTheme.typography.labelSmall) } + else if ("remote-scroll" in state.capabilities) + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton( + onClick = { session.scrollRemote(ScrollDirection.UP) }, + enabled = state.canScrollRemote, + ) { Text("Scroll up") } + TextButton( + onClick = { session.scrollRemote(ScrollDirection.DOWN) }, + enabled = state.canScrollRemote, + ) { Text("Scroll down") } + if (state.scrolling != null) { + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + Text("Loading…", modifier = Modifier.padding(start = 8.dp)) + } + } LazyColumn( - Modifier.weight(1f).fillMaxWidth().testTag("mirror-output"), + Modifier.weight(1f).fillMaxWidth().then(remoteGesture).testTag("mirror-output"), state = scroll, contentPadding = PaddingValues(16.dp), ) { diff --git a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/RemoteScroll.kt b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/RemoteScroll.kt new file mode 100644 index 00000000..1b5777fc --- /dev/null +++ b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/RemoteScroll.kt @@ -0,0 +1,26 @@ +package com.awhisper.prowlmirror + +import kotlin.math.abs + +enum class ScrollDirection(val wireName: String) { + UP("up"), + DOWN("down"), +} + +data class ScrollCompletion(val requestID: String, val direction: ScrollDirection) + +internal fun remoteScrollDirection( + horizontal: Float, + vertical: Float, + startedAtTop: Boolean, + startedAtBottom: Boolean, + threshold: Float, +): ScrollDirection? { + if (!horizontal.isFinite() || !vertical.isFinite() || + abs(vertical) < threshold || abs(vertical) <= abs(horizontal)) return null + return when { + vertical > 0 && startedAtTop -> ScrollDirection.UP + vertical < 0 && startedAtBottom -> ScrollDirection.DOWN + else -> null + } +} diff --git a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Session.kt b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Session.kt index 1d792d6b..7489546c 100644 --- a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Session.kt +++ b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Session.kt @@ -47,7 +47,13 @@ data class SessionState( val loadingHistory: Boolean = false, val showsHistory: Boolean = false, val follow: Boolean = true, + val scrolling: ScrollDirection? = null, + val scrollCompletion: ScrollCompletion? = null, ) { + val canScrollRemote: Boolean + get() = status == Status.live && "remote-scroll" in capabilities && + !showsHistory && scrolling == null + val canSend: Boolean get() = status == Status.live && @@ -133,6 +139,14 @@ class Session( private val historyGate = HistoryGate() private var historyTimeout: Job? = null private var commandPending: Pair>? = null + private var scrollTimeout: Job? = null + private data class ScrollRequest( + val id: String, + val lease: String, + val direction: ScrollDirection, + val sequence: Long, + ) + private var scrollRequest: ScrollRequest? = null private data class Submission( val id: String, @@ -213,6 +227,7 @@ class Session( } private fun detach() { + clearScroll() historyTimeout?.cancel() state.update { it.copy(loadingHistory = false) } generation++ @@ -320,6 +335,7 @@ class Session( } private fun subscribe() { + clearScroll() val pane = state.value.pane ?: return lease = null state.update { it.copy(status = Status.subscribing, sequence = 0) } @@ -435,6 +451,7 @@ class Session( fun loadHistory(refresh: Boolean = false) { val active = lease ?: return if (state.value.loadingHistory || "history" !in state.value.capabilities) return + clearScroll() if (refresh) { historyGate.reset() state.update { it.copy(history = emptyList()) } @@ -465,6 +482,49 @@ class Session( } } + fun scrollRemote(direction: ScrollDirection) { + val active = lease ?: return + if (!state.value.canScrollRemote) return + val request = ScrollRequest(uuid(), active, direction, state.value.sequence) + scrollRequest = request + state.update { + it.copy(scrolling = direction, scrollCompletion = null, follow = false, error = null) + } + scrollTimeout = scope.launch { + delay(5_000) + if (scrollRequest?.id == request.id) { + clearScroll() + state.update { + it.copy(error = "Remote scroll timed out. Check the current screen before trying again.") + } + } + } + send(control("scroll", obj( + "requestID" to request.id, + "direction" to direction.wireName, + "subscriptionID" to active, + ))) + } + + private fun clearScroll() { + scrollTimeout?.cancel() + scrollTimeout = null + scrollRequest = null + state.update { it.copy(scrolling = null, scrollCompletion = null) } + } + + private fun scrollResult(p: JsonObject) { + val request = scrollRequest ?: return + if (canonical(p.string("requestID")) != request.id || + canonical(p.string("subscriptionID")) != request.lease) return + val sequence = p.longInteger("sequence") + require(sequence > request.sequence && sequence <= state.value.sequence) { + "Remote scroll result has no matching frame" + } + clearScroll() + state.update { it.copy(scrollCompletion = ScrollCompletion(request.id, request.direction)) } + } + private fun receive(packet: Packet) { if (packet is Packet.Text) { require(packet.text.toByteArray().size <= 1024 * 1024) { @@ -507,6 +567,7 @@ class Session( else if (state.value.status != Status.live) subscribe() } "subscribed" -> { + clearScroll() require(canonical(p.string("paneID")) == state.value.pane?.id) lease = canonical(p.string("subscriptionID")) runID = canonical(p.string("hostRunID")) @@ -541,7 +602,15 @@ class Session( } "failure" -> { val error = p.string("error") - if ( + if (error.startsWith("SCROLL_UNAVAILABLE") || error.startsWith("SCROLL_BUSY")) { + val request = scrollRequest + if (request != null && + p.optionalString("subscriptionID")?.let(::canonical) == request.lease && + p.optionalString("requestID")?.let(::canonical) == request.id) { + clearScroll() + state.update { it.copy(error = error) } + } + } else if ( error.startsWith("HISTORY_UNAVAILABLE") && state.value.loadingHistory && p.optionalString("subscriptionID")?.let(::canonical) == lease @@ -554,6 +623,7 @@ class Session( failed(error) } } + "scrollResult" -> scrollResult(p) "historyPage" -> { require( canonical(p.string("subscriptionID")) == lease && state.value.loadingHistory diff --git a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Wire.kt b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Wire.kt index 4bf47dd7..a9f9eb6a 100644 --- a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Wire.kt +++ b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Wire.kt @@ -36,6 +36,9 @@ fun JsonObject.optionalString(key: String): String? = fun JsonObject.integer(key: String): Int = stringNumber(key).toIntOrNull() ?: throw IOException("Invalid $key") +fun JsonObject.longInteger(key: String): Long = + stringNumber(key).toLongOrNull() ?: throw IOException("Invalid $key") + private fun JsonObject.stringNumber(key: String): String = get(key)?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber }?.asString ?: throw IOException("Invalid $key") @@ -98,6 +101,8 @@ object Wire { "acknowledge", "input", "refresh", + "scroll", + "scrollResult", "history", "historyPage", "command", diff --git a/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/CoreTest.kt b/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/CoreTest.kt index 10acebb9..59760c2d 100644 --- a/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/CoreTest.kt +++ b/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/CoreTest.kt @@ -62,6 +62,39 @@ class CoreTest { assertEquals(0, request.record("command").record("list").record("_0").size()) } + @Test + fun scrollControlsUseSwiftEnvelopeAndStrictSequenceNumbers() { + val requestID = "00010203-0405-0607-0809-0A0B0C0D0E0F" + val lease = "11111111-2222-3333-4444-555555555555" + val encoded = Wire.encode(control("scroll", obj( + "requestID" to requestID, "direction" to "up", "subscriptionID" to lease, + ))) + assertEquals( + "{\"scroll\":{\"_0\":{\"requestID\":\"$requestID\",\"direction\":\"up\",\"subscriptionID\":\"$lease\"}}}", + String(encoded.drop(5).toByteArray()), + ) + val result = Wire.read(ByteArrayInputStream(Wire.encode(control("scrollResult", obj( + "requestID" to requestID, "sequence" to Long.MAX_VALUE, "subscriptionID" to lease, + ))))) as Packet.Control + assertEquals("scrollResult", result.kind) + assertEquals(Long.MAX_VALUE, result.payload().longInteger("sequence")) + for (invalid in listOf("1", 1.5, java.math.BigInteger("9223372036854775808"))) { + assertThrows(Exception::class.java) { obj("sequence" to invalid).longInteger("sequence") } + } + } + + @Test + fun onlyOutwardVerticalPullsAtTheStartingEdgeRequestRemoteScrolling() { + assertEquals(ScrollDirection.UP, remoteScrollDirection(5f, 70f, true, false, 56f)) + assertEquals(ScrollDirection.DOWN, remoteScrollDirection(5f, -70f, false, true, 56f)) + assertNull(remoteScrollDirection(5f, 30f, true, true, 56f)) + assertNull(remoteScrollDirection(80f, 70f, true, true, 56f)) + assertNull(remoteScrollDirection(0f, 70f, false, true, 56f)) + assertNull(remoteScrollDirection(0f, -70f, true, false, 56f)) + assertNull(remoteScrollDirection(0f, 100f, false, false, 56f)) + assertNull(remoteScrollDirection(Float.NaN, 70f, true, true, 56f)) + } + @Test fun pairingAndProofBinding() { assertEquals("ABCD2345", Authentication.code("abcd-2345")) diff --git a/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/SessionTest.kt b/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/SessionTest.kt index 1bd745ad..96be5bb2 100644 --- a/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/SessionTest.kt +++ b/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/SessionTest.kt @@ -41,23 +41,25 @@ class SessionTest { val peer get() = peers.last() - fun ready(interactive: Boolean = true) { + fun ready(interactive: Boolean = true, remoteScroll: Boolean = false) { peer.ready(host) peer.receive( control( "panes", obj( "panes" to listOf(pane), - "capabilities" to (listOf("text-v1", "launch-profile", "history", "refresh") + if (interactive) listOf("agent-input") else emptyList()), + "capabilities" to (listOf("text-v1", "launch-profile", "history", "refresh") + + (if (interactive) listOf("agent-input") else emptyList()) + + (if (remoteScroll) listOf("remote-scroll") else emptyList())), "hostRunID" to run, ), ) ) } - fun live() { + fun live(remoteScroll: Boolean = false) { session.connect() - ready() + ready(remoteScroll = remoteScroll) session.choose(pane) subscribed() } @@ -101,6 +103,160 @@ class SessionTest { } } + @Test + fun remoteScrollRequiresCapabilityAndLiveView() = runTest { + val f = Fixture(backgroundScope) + f.live() + f.session.scrollRemote(ScrollDirection.UP) + assertFalse(f.session.state.value.canScrollRemote) + assertFalse(f.peer.sent.any { it.kind == "scroll" }) + f.ready(remoteScroll = true) + assertTrue(f.session.state.value.canScrollRemote) + f.session.loadHistory() + f.session.scrollRemote(ScrollDirection.DOWN) + assertFalse(f.peer.sent.any { it.kind == "scroll" }) + f.session.close() + } + + @Test + fun remoteScrollIsSingleFlightAndCompletesOnlyAfterItsFrame() = runTest { + val f = Fixture(backgroundScope) + f.live(remoteScroll = true) + f.peer.receive(Packet.Text(f.lease, 1, 80, 24, false, "screen")) + f.session.scrollRemote(ScrollDirection.UP) + val request = f.peer.sent.last().payload() + assertEquals("up", request.string("direction")) + assertEquals(f.lease, request.string("subscriptionID")) + assertEquals(ScrollDirection.UP, f.session.state.value.scrolling) + assertFalse(f.session.state.value.follow) + f.session.scrollRemote(ScrollDirection.DOWN) + assertEquals(1, f.peer.sent.count { it.kind == "scroll" }) + // Even at a boundary, Host returns a fresh sequence for the unchanged screen. + f.peer.receive(Packet.Text(f.lease, 2, 80, 24, false, "screen")) + assertEquals(ScrollDirection.UP, f.session.state.value.scrolling) + f.peer.receive(control("scrollResult", obj( + "requestID" to request.string("requestID"), + "subscriptionID" to f.lease, + "sequence" to 2, + ))) + assertNull(f.session.state.value.scrolling) + assertEquals(ScrollDirection.UP, f.session.state.value.scrollCompletion?.direction) + assertFalse(f.session.state.value.follow) + assertEquals(Status.live, f.session.state.value.status) + advanceTimeBy(6_000) + assertNull(f.session.state.value.error) + f.session.close() + } + + @Test + fun remoteScrollRejectsMissingStaleOrInvalidFrameSequence() = runTest { + for (sequence in listOf(0L, 1L, 3L, -1L)) { + val f = Fixture(backgroundScope) + f.live(remoteScroll = true) + f.peer.receive(Packet.Text(f.lease, 1, 80, 24, false, "before")) + f.session.scrollRemote(ScrollDirection.DOWN) + val requestID = f.peer.sent.last().payload().string("requestID") + f.peer.receive(Packet.Text(f.lease, 2, 80, 24, false, "after")) + f.peer.receive(control("scrollResult", obj( + "requestID" to requestID, + "subscriptionID" to f.lease, + "sequence" to sequence, + ))) + assertEquals(Status.disconnected, f.session.state.value.status) + assertNull(f.session.state.value.scrolling) + f.session.close() + } + } + + @Test + fun remoteScrollTimeoutDoesNotReplayAndLateMessagesCannotFinishNextRequest() = runTest { + val f = Fixture(backgroundScope) + f.live(remoteScroll = true) + f.session.scrollRemote(ScrollDirection.UP) + val oldID = f.peer.sent.last().payload().string("requestID") + runCurrent() + advanceTimeBy(5_000) + runCurrent() + assertNull(f.session.state.value.scrolling) + assertEquals(Status.live, f.session.state.value.status) + assertTrue(f.session.state.value.error!!.contains("timed out")) + assertEquals(1, f.peer.sent.count { it.kind == "scroll" }) + f.session.scrollRemote(ScrollDirection.DOWN) + val newID = f.peer.sent.last().payload().string("requestID") + f.peer.receive(control("scrollResult", obj( + "requestID" to oldID, "subscriptionID" to f.lease, "sequence" to 1, + ))) + f.peer.receive(control("scrollResult", obj( + "requestID" to newID, "subscriptionID" to uuid(), "sequence" to 1, + ))) + f.peer.receive(control("failure", obj( + "requestID" to oldID, "subscriptionID" to f.lease, + "error" to "SCROLL_UNAVAILABLE: old request", + ))) + assertEquals(ScrollDirection.DOWN, f.session.state.value.scrolling) + f.peer.receive(Packet.Text(f.lease, 1, 80, 24, false, "next")) + f.peer.receive(control("scrollResult", obj( + "requestID" to newID, "subscriptionID" to f.lease, "sequence" to 1, + ))) + assertEquals(ScrollDirection.DOWN, f.session.state.value.scrollCompletion?.direction) + f.session.close() + } + + @Test + fun remoteScrollFailuresKeepConnectionAndHistoryAvailable() = runTest { + val f = Fixture(backgroundScope) + f.live(remoteScroll = true) + for (reason in listOf("SCROLL_UNAVAILABLE", "SCROLL_BUSY")) { + f.session.scrollRemote(ScrollDirection.UP) + val requestID = f.peer.sent.last().payload().string("requestID") + f.peer.receive(control("failure", obj( + "requestID" to requestID, "subscriptionID" to f.lease, + "error" to "$reason: retry later", + ))) + assertNull(f.session.state.value.scrolling) + assertEquals(Status.live, f.session.state.value.status) + assertFalse(f.peer.stopped) + } + f.session.loadHistory() + assertTrue(f.session.state.value.loadingHistory) + assertEquals("history", f.peer.sent.last().kind) + f.session.close() + } + + @Test + fun remoteScrollIsClearedOnDisconnectWithoutAutomaticReplay() = runTest { + val f = Fixture(backgroundScope) + f.live(remoteScroll = true) + f.session.scrollRemote(ScrollDirection.UP) + f.peer.closed("Network lost") + assertNull(f.session.state.value.scrolling) + f.session.retry() + f.ready(remoteScroll = true) + f.subscribed() + assertFalse(f.peer.sent.any { it.kind == "scroll" }) + advanceTimeBy(6_000) + assertNull(f.session.state.value.error) + assertEquals(Status.live, f.session.state.value.status) + f.session.close() + } + + @Test + fun enteringHistoryClearsScrollAndIgnoresItsLateResult() = runTest { + val f = Fixture(backgroundScope) + f.live(remoteScroll = true) + f.session.scrollRemote(ScrollDirection.UP) + val requestID = f.peer.sent.last().payload().string("requestID") + f.session.loadHistory() + assertNull(f.session.state.value.scrolling) + f.peer.receive(control("scrollResult", obj( + "requestID" to requestID, "subscriptionID" to f.lease, "sequence" to 1, + ))) + assertTrue(f.session.state.value.showsHistory) + assertNull(f.session.state.value.scrollCompletion) + assertEquals(Status.live, f.session.state.value.status) + f.session.close() + } + @Test fun savedEnrollmentIsUsedByRetryBeforeRuntimeReady() = runTest { val attempted = mutableListOf>() diff --git a/MirrorClient/Android/docs/validation.md b/MirrorClient/Android/docs/validation.md index c1750056..a6abf717 100644 --- a/MirrorClient/Android/docs/validation.md +++ b/MirrorClient/Android/docs/validation.md @@ -39,6 +39,28 @@ Ghostty, existing remote debug apps, or any physical device. selection cancellation, session-specific dialogs, session-owned launch state, and Unicode-safe detail chunking. +## Remote scrolling validation (2026-10-01) + +- Debug APK, instrumentation APK and lint succeeded with JDK 17 / Android SDK 34. +- 37 JVM tests ran: 36 passed; the optional native TLS fixture was not running, + so its one test was skipped. Remote scroll tests cover capability negotiation, + single in-flight requests, unchanged frames, request/lease/sequence validation, + five-second timeout, late responses, recoverable errors, disconnect without replay, + and preserving History access. +- All 12 instrumented UI tests passed on the Android 16 phone emulator, including + seven remote scrolling cases. They exercise buttons/loading, unchanged output, + top/bottom pulls, local text scrolling, reading anchors, long-press selection, + unavailable responses, History and older Hosts. A held selection drag initially + reproduced an unwanted remote request; it now passes without sending input. +- UI tests use a fake Host transport inside the actual application UI. They do not + send input to any real pane or validate end-to-end scrolling over TLS. +- The emulator ran read-only with snapshot loading/saving disabled. No physical + phone, saved Host credential or existing pane was used. +- Logs: `/tmp/prowl-android-scroll-final-build.log` and + `/tmp/prowl-android-scroll-ui-final-direct.log`. Loading, completed, long-text, + History and error screenshots were inspected under + `/tmp/prowl-android-scroll-screenshots/`. + ## Not represented as passed - Physical phone/tablet, vendor-specific IME, actual Bluetooth/USB keyboard. diff --git a/MirrorClient/iOS/ProwlMirror-iOS/ContentView.swift b/MirrorClient/iOS/ProwlMirror-iOS/ContentView.swift index 829bf41f..57ae30e9 100644 --- a/MirrorClient/iOS/ProwlMirror-iOS/ContentView.swift +++ b/MirrorClient/iOS/ProwlMirror-iOS/ContentView.swift @@ -285,6 +285,8 @@ private struct MirrorReadingView: View { @State private var position: ScrollPosition @State private var observedInitialPosition = false @State private var isEditing = false + @State private var scrollGeometry = MirrorRemoteScrollGesture(offset: 0, maximumOffset: 0) + @State private var dragStart: MirrorRemoteScrollGesture? init(session: MirrorSession) { self.session = session @@ -340,7 +342,10 @@ private struct MirrorReadingView: View { } .scrollPosition($position) .accessibilityIdentifier("mirror-live-scroll") - .onDisappear { observedInitialPosition = false } + .onDisappear { + observedInitialPosition = false + dragStart = nil + } .onAppear { if session.followsLatest { position.scrollTo(edge: .bottom) @@ -348,26 +353,76 @@ private struct MirrorReadingView: View { position.scrollTo(y: session.liveReadingOffset) } } - .onScrollGeometryChange(for: CGFloat.self) { geometry in - max(0, geometry.contentOffset.y + geometry.contentInsets.top) - } action: { _, offset in - if observedInitialPosition { session.liveReadingOffset = offset } + .onScrollGeometryChange(for: MirrorRemoteScrollGesture.self) { geometry in + MirrorRemoteScrollGesture( + offset: max(0, geometry.contentOffset.y + geometry.contentInsets.top), + maximumOffset: max( + 0, + geometry.contentSize.height + geometry.contentInsets.top + geometry.contentInsets.bottom + - geometry.containerSize.height)) + } action: { _, geometry in + scrollGeometry = geometry + if observedInitialPosition { session.liveReadingOffset = geometry.offset } observedInitialPosition = true } .onScrollPhaseChange { _, phase in + if phase == .tracking { dragStart = scrollGeometry } if phase == .interacting { session.followsLatest = false } } + .simultaneousGesture( + // A long press belongs to text selection, even if its finger later crosses a scroll edge. + LongPressGesture() + .exclusively(before: DragGesture(minimumDistance: 16)) + .onChanged { value in + if case .second = value, dragStart == nil { dragStart = scrollGeometry } + } + .onEnded { value in + defer { dragStart = nil } + guard case .second(let gesture) = value else { return } + if let direction = dragStart?.direction(translation: gesture.translation) { + session.scroll(direction) + } + } + ) .onChange(of: session.revision) { _, _ in if session.followsLatest { proxy.scrollTo("latest", anchor: .bottom) } } + .onChange(of: session.completedScroll) { _, completion in + guard let completion else { return } + position.scrollTo(edge: completion.direction == .upward ? .top : .bottom) + } .safeAreaInset(edge: .bottom) { - HStack { - Toggle("Follow latest", isOn: $session.followsLatest).toggleStyle(.button) - Spacer() - Button("Latest") { - session.followsLatest = true - proxy.scrollTo("latest", anchor: .bottom) + VStack(alignment: .leading, spacing: 8) { + HStack { + Button("Scroll Up", systemImage: "arrow.up") { session.scroll(.upward) } + .accessibilityIdentifier("mirror-scroll-up") + .help("Scroll the Host pane toward earlier output") + Spacer() + if session.isScrolling { + ProgressView("Scrolling…").accessibilityIdentifier("mirror-scroll-progress") + } + Spacer() + Button("Scroll Down", systemImage: "arrow.down") { session.scroll(.downward) } + .accessibilityIdentifier("mirror-scroll-down") + .help("Scroll the Host pane toward later output") + } + .disabled(!session.canScroll) + if let error = session.scrollError { + Text(error).foregroundStyle(.secondary) + } else if session.status == .live && !session.supportsRemoteScroll { + Text("Update Host to enable remote scrolling.").foregroundStyle(.secondary) + } else if session.status == .live { + Text("Pull past either text edge to scroll the Host pane.").foregroundStyle(.secondary) + } + HStack { + Toggle("Follow latest", isOn: $session.followsLatest).toggleStyle(.button) + Spacer() + Button("Latest") { + session.followsLatest = true + proxy.scrollTo("latest", anchor: .bottom) + } } + .disabled(session.isScrolling) } .font(.caption).padding(.horizontal).padding(.vertical, 8).background(.bar) } diff --git a/MirrorClient/iOS/ProwlMirror-iOS/Features/Reading/MirrorRemoteScrollGesture.swift b/MirrorClient/iOS/ProwlMirror-iOS/Features/Reading/MirrorRemoteScrollGesture.swift new file mode 100644 index 00000000..e6864528 --- /dev/null +++ b/MirrorClient/iOS/ProwlMirror-iOS/Features/Reading/MirrorRemoteScrollGesture.swift @@ -0,0 +1,13 @@ +import Foundation + +nonisolated struct MirrorRemoteScrollGesture: Equatable { + let offset: CGFloat + let maximumOffset: CGFloat + + func direction(translation: CGSize) -> MirrorMessage.ScrollDirection? { + guard abs(translation.height) >= 64, abs(translation.height) > abs(translation.width) * 1.5 else { return nil } + if offset <= 1, translation.height > 0 { return .upward } + if offset >= maximumOffset - 1, translation.height < 0 { return .downward } + return nil + } +} diff --git a/MirrorClient/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift b/MirrorClient/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift index 83bad137..d66258b6 100644 --- a/MirrorClient/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift +++ b/MirrorClient/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift @@ -35,6 +35,12 @@ final class MirrorSession: Identifiable { private(set) var error: String? private(set) var supportsHistory = false private(set) var supportsLaunch = false + private(set) var supportsRemoteScroll = false + private(set) var isScrolling = false + private(set) var scrollError: String? + private(set) var completedScroll: ScrollCompletion? + @ObservationIgnored private var pendingScroll: PendingScroll? + @ObservationIgnored private var scrollTimeout: Task? @ObservationIgnored private var supportsShellSend = false @ObservationIgnored private var supportsAgentInput = false @ObservationIgnored private var pendingCommand: PendingCommand? @@ -45,6 +51,19 @@ final class MirrorSession: Identifiable { let id: UUID let continuation: CheckedContinuation } + private struct PendingScroll { + let id: UUID + let direction: MirrorMessage.ScrollDirection + let subscriptionID: UUID + let baseline: UInt64 + } + struct ScrollCompletion: Equatable { + let id: UUID + let direction: MirrorMessage.ScrollDirection + } + var canScroll: Bool { + status == .live && supportsRemoteScroll && subscriptionID != nil && !isScrolling && !showsHistory + } private enum CommandFailure: LocalizedError { case unavailable, disconnected, timedOut, shellUnavailable, agentInputUnavailable var errorDescription: String? { @@ -131,6 +150,10 @@ final class MirrorSession: Identifiable { supportsLaunch = false supportsShellSend = false supportsAgentInput = false + supportsRemoteScroll = false + clearScroll() + scrollError = nil + completedScroll = nil generation = UUID() let attempt = generation do { @@ -154,6 +177,7 @@ final class MirrorSession: Identifiable { self.transport = nil self.finishCommand(.failure(CommandFailure.disconnected)) self.subscriptionID = nil + self.clearScroll() self.markDeliveryUncertain() self.isLoadingHistory = false if self.status == .live || self.status == .connecting || self.status == .subscribing @@ -231,6 +255,7 @@ final class MirrorSession: Identifiable { old?.onClose = nil old?.close(nil) subscriptionID = nil + clearScroll() markDeliveryUncertain() isLoadingHistory = false status = .disconnected @@ -395,11 +420,62 @@ final class MirrorSession: Identifiable { subscriptionID: subscriptionID))) } + func scroll(_ direction: MirrorMessage.ScrollDirection) { + guard canScroll, let subscriptionID else { return } + let request = PendingScroll(id: UUID(), direction: direction, subscriptionID: subscriptionID, baseline: revision) + pendingScroll = request + isScrolling = true + scrollError = nil + followsLatest = false + let clock = clock + scrollTimeout = Task { [weak self] in + do { try await clock.sleep(for: .seconds(5)) } catch { return } + guard self?.pendingScroll?.id == request.id else { return } + self?.clearScroll() + self?.scrollError = String( + localized: "Host did not confirm the scroll in time. Check the screen before trying again.") + } + send(.scroll(.init(requestID: request.id, direction: direction, subscriptionID: subscriptionID))) + } + + private func clearScroll() { + scrollTimeout?.cancel() + scrollTimeout = nil + pendingScroll = nil + isScrolling = false + } + + private func receiveScrollResult(_ message: MirrorMessage) { + guard let pendingScroll, message.scrollRequestID == pendingScroll.id, + message.subscriptionID == pendingScroll.subscriptionID, subscriptionID == pendingScroll.subscriptionID + else { return } + guard let sequence = message.sequence, sequence > pendingScroll.baseline, sequence <= revision else { + invalidMessage() + return + } + completedScroll = ScrollCompletion(id: pendingScroll.id, direction: pendingScroll.direction) + clearScroll() + } + + private func consumeScrollFailure(_ message: MirrorMessage) -> Bool { + guard message.error?.hasPrefix("SCROLL_UNAVAILABLE") == true || message.error?.hasPrefix("SCROLL_BUSY") == true + else { + return false + } + guard let pendingScroll, message.subscriptionID == pendingScroll.subscriptionID, + message.scrollRequestID == pendingScroll.id + else { return true } + clearScroll() + scrollError = message.error + return true + } + private func subscribe() { guard let pane else { return } status = .subscribing revision = 0 subscriptionID = nil + clearScroll() send( .subscribe(.init(paneID: pane.id, representation: .text, intent: intent))) } @@ -420,6 +496,7 @@ final class MirrorSession: Identifiable { supportsShellSend = message.capabilities?.contains("shell-send") == true supportsRefresh = message.capabilities?.contains("refresh") == true supportsHistory = message.capabilities?.contains("history") == true + supportsRemoteScroll = message.capabilities?.contains("remote-scroll") == true if supportsSubmission { querySubmission() } panes = message.panes ?? [] onVerifiedConnection?(configuration) @@ -474,6 +551,8 @@ final class MirrorSession: Identifiable { updatedAt = Date() status = .live send(.acknowledge(.init(sequence: sequence, subscriptionID: lease))) + case .scrollResult: + receiveScrollResult(message) case .historyPage: guard isLoadingHistory, subscriptionID != nil, message.subscriptionID == subscriptionID, let id = message.historyID, @@ -511,6 +590,7 @@ final class MirrorSession: Identifiable { } transport?.close(nil) case .failure: + if consumeScrollFailure(message) { return } if message.error?.hasPrefix("HISTORY_UNAVAILABLE") == true, message.subscriptionID == subscriptionID { diff --git a/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorProtocol.swift b/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorProtocol.swift index 6c60cb39..22e7086d 100644 --- a/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorProtocol.swift +++ b/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorProtocol.swift @@ -27,6 +27,10 @@ nonisolated enum MirrorMessage: Codable, Sendable { enum EndReason: String, Codable, Sendable { case takenOver, hostStopped, paneClosed } + enum ScrollDirection: String, Codable, Sendable { + case upward = "up" + case downward = "down" + } case challenge(Challenge) case authenticate(Authentication) case pair(PairRequest) @@ -47,7 +51,7 @@ nonisolated enum MirrorMessage: Codable, Sendable { enum Kind: String, Codable { case challenge, authenticate, pair, paired, authenticated, list, panes, subscribe, subscribed, frame, textFrame, acknowledge, input, history, historyPage, failure, ping, pong, ended, - refresh, command, commandResult, commandReceipt + refresh, command, commandResult, commandReceipt, scroll, scrollResult } case list case panes(PanesPayload) @@ -113,6 +117,7 @@ nonisolated enum MirrorMessage: Codable, Sendable { struct FailurePayload: Codable, Sendable { var error: String var subscriptionID: UUID? + var requestID: UUID? } case ping case pong @@ -124,6 +129,18 @@ nonisolated enum MirrorMessage: Codable, Sendable { struct RefreshPayload: Codable, Sendable { var subscriptionID: UUID } + case scroll(ScrollPayload) + struct ScrollPayload: Codable, Sendable { + var requestID: UUID + var direction: ScrollDirection + var subscriptionID: UUID + } + case scrollResult(ScrollResultPayload) + struct ScrollResultPayload: Codable, Sendable { + var requestID: UUID + var sequence: UInt64 + var subscriptionID: UUID + } case command(CommandPayload) struct CommandPayload: Codable, Sendable { var subscriptionID: UUID? @@ -160,6 +177,8 @@ nonisolated enum MirrorMessage: Codable, Sendable { case .pong: .pong case .ended: .ended case .refresh: .refresh + case .scroll: .scroll + case .scrollResult: .scrollResult case .command: .command case .commandResult: .commandResult case .commandReceipt: .commandReceipt @@ -214,6 +233,8 @@ nonisolated enum MirrorMessage: Codable, Sendable { case .historyPage(let payload): payload.subscriptionID case .failure(let payload): payload.subscriptionID case .refresh(let payload): payload.subscriptionID + case .scroll(let payload): payload.subscriptionID + case .scrollResult(let payload): payload.subscriptionID case .command(let payload): payload.subscriptionID case .commandReceipt(let payload): payload.subscriptionID default: nil @@ -230,6 +251,7 @@ nonisolated enum MirrorMessage: Codable, Sendable { case .frame(let payload): payload.sequence case .textFrame(let payload): payload.sequence case .acknowledge(let payload): payload.sequence + case .scrollResult(let payload): payload.sequence default: nil } } @@ -301,6 +323,20 @@ nonisolated enum MirrorMessage: Codable, Sendable { default: nil } } + var scrollDirection: ScrollDirection? { + switch self { + case .scroll(let payload): payload.direction + default: nil + } + } + var scrollRequestID: UUID? { + switch self { + case .scroll(let payload): payload.requestID + case .scrollResult(let payload): payload.requestID + case .failure(let payload): payload.requestID + default: nil + } + } var commandResponse: MirrorCommandResponse? { switch self { case .commandResult(let payload): payload.commandResponse diff --git a/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorUIFixture.swift b/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorUIFixture.swift index d648028d..7fbb35c2 100644 --- a/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorUIFixture.swift +++ b/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorUIFixture.swift @@ -56,9 +56,14 @@ private let history = MirrorHistory( text: (1...401).map { "Retained line \($0)" }.joined(separator: "\n"), truncated: true) private var sequence: UInt64 = 0 + private var scrollPage = 0 + private var scrollTask: Task? func start() { onReady?() } - func close(_ reason: String?) { onClose?(reason) } + func close(_ reason: String?) { + scrollTask?.cancel() + onClose?(reason) + } func send(_ message: MirrorMessage, closeAfterSending: Bool) { switch message.kind { @@ -69,7 +74,7 @@ panes: [pane], capabilities: [ "text-v1", "history", "refresh", "launch-profile", "agents-dispatch", "agent-input", - "shell-send", + "shell-send", "remote-scroll", ], hostRunID: UUID()))) case .subscribe: onMessage?( @@ -81,8 +86,11 @@ Task { do { let response = try await MirrorUIFixture.launchCommand(request.request.command) - onMessage?(.commandResult(.init(commandResponse: .init( - requestID: request.requestID, response: response)))) + onMessage?( + .commandResult( + .init( + commandResponse: .init( + requestID: request.requestID, response: response)))) } catch { onClose?(error.localizedDescription) } } return @@ -104,9 +112,11 @@ case .agentsInput(let input): payload = .object([ "ok": .bool(true), "command": .string("agents.input"), - "data": .object(["input": .object([ - "bytes": .number(Double(input.prompt.utf8.count)), "trailing_enter_sent": .bool(true), - ])]), + "data": .object([ + "input": .object([ + "bytes": .number(Double(input.prompt.utf8.count)), "trailing_enter_sent": .bool(true), + ]) + ]), ]) default: onClose?("Unexpected fixture command") @@ -116,6 +126,8 @@ .commandResult( .init(commandResponse: .init(requestID: request.requestID, response: payload)))) case .refresh: frame() + case .scroll: + scroll(message) case .history: do { let page = try history.page(before: message.offset ?? history.lines.count) @@ -131,8 +143,38 @@ } } + private func scroll(_ message: MirrorMessage) { + if CommandLine.arguments.contains("--mirror-ui-scroll-delay-fixture") { + scrollTask = Task { [weak self] in + do { try await ContinuousClock().sleep(for: .seconds(2)) } catch { return } + self?.completeScroll(message) + } + } else { + completeScroll(message) + } + } + + private func completeScroll(_ message: MirrorMessage) { + guard let id = message.scrollRequestID, let direction = message.scrollDirection else { return } + scrollPage += direction == .upward ? -1 : 1 + frame() + onMessage?(.scrollResult(.init(requestID: id, sequence: sequence, subscriptionID: lease))) + } + private func frame() { sequence += 1 + if CommandLine.arguments.contains("--mirror-ui-scroll-fixture") { + let text = + CommandLine.arguments.contains("--mirror-ui-scroll-long-fixture") + ? (1...40).map { "```text\nRemote \(scrollPage) marker \($0)\n```" }.joined(separator: "\n") + : "Remote page \(scrollPage)\nPull past the text edge to scroll the Host." + onMessage?( + .textFrame( + .init( + sequence: sequence, text: text, + subscriptionID: lease))) + return + } if CommandLine.arguments.contains("--mirror-ui-large-table-fixture") { let rows = (0..<10_000).map { "| Row \($0) | Value \($0) |" }.joined(separator: "\n") onMessage?( diff --git a/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorRemoteScrollTests.swift b/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorRemoteScrollTests.swift new file mode 100644 index 00000000..8e703e19 --- /dev/null +++ b/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorRemoteScrollTests.swift @@ -0,0 +1,333 @@ +import Foundation +import Observation +import Testing + +@testable import ProwlMirror_iOS + +@MainActor +struct MirrorRemoteScrollTests { + @Test func oldHostsAndHistoryNeverReceiveRemoteScroll() { + let old = Channel(supportsScroll: false) + let oldSession = makeSession(old) + #expect(!oldSession.canScroll) + oldSession.scroll(.upward) + #expect(old.requests.isEmpty) + + let channel = Channel() + let session = makeSession(channel) + session.loadHistory(refresh: true) + session.scroll(.downward) + #expect(channel.requests.isEmpty) + #expect(session.showsHistory) + #expect(session.isLoadingHistory) + } + + @Test func oneRequestWaitsForItsFreshFrameAndCorrelatedResultIncludingNoOp() throws { + let channel = Channel() + let session = makeSession(channel) + session.scroll(.upward) + let request = try #require(channel.requests.last) + #expect(session.isScrolling) + #expect(!session.followsLatest) + #expect(!session.canScroll) + session.scroll(.downward) + #expect(channel.requests.count == 1) + + channel.frame(2) + #expect(session.isScrolling) + channel.result(UUID(), sequence: 2) + channel.onMessage?(.scrollResult(.init(requestID: request, sequence: 2, subscriptionID: UUID()))) + #expect(session.isScrolling) + channel.result(request, sequence: 2) + #expect(!session.isScrolling) + #expect(session.canScroll) + #expect(session.completedScroll?.direction == .upward) + #expect(session.text == "current") + #expect(session.status == .live) + #expect(channel.closes == 0) + + session.scroll(.downward) + let next = try #require(channel.requests.last) + channel.result(request, sequence: 2) + #expect(session.isScrolling) + channel.frame(3) + channel.result(next, sequence: 3) + #expect(session.completedScroll?.direction == .downward) + #expect(!session.isScrolling) + } + + @Test(arguments: [UInt64(0), 1, 2]) func resultCannotCompleteWithoutItsFreshFrame(_ sequence: UInt64) throws { + let channel = Channel() + let session = makeSession(channel) + session.scroll(.upward) + channel.result(try #require(channel.requests.last), sequence: sequence) + #expect(session.completedScroll == nil) + #expect(!session.isScrolling) + #expect(session.status == .disconnected) + #expect(channel.closes == 1) + } + + @Test func scopedFailureKeepsConnectionAndLateFailureCannotCancelNextRequest() throws { + let channel = Channel() + let session = makeSession(channel) + session.scroll(.upward) + let first = try #require(channel.requests.last) + channel.failure(first, code: "SCROLL_UNAVAILABLE: Pane is unavailable") + #expect(!session.isScrolling) + #expect(session.scrollError?.contains("SCROLL_UNAVAILABLE") == true) + #expect(session.status == .live) + #expect(channel.closes == 0) + + session.scroll(.downward) + let second = try #require(channel.requests.last) + #expect(session.scrollError == nil) + channel.failure(first, code: "SCROLL_BUSY") + channel.onMessage?(.failure(.init(error: "SCROLL_BUSY", subscriptionID: UUID(), requestID: second))) + #expect(session.isScrolling) + channel.failure(second, code: "SCROLL_BUSY") + #expect(!session.isScrolling) + #expect(session.canScroll) + } + + @Test(.timeLimit(.minutes(1))) func timeoutEndsLoadingWithoutReplayAndIgnoresLateReply() async throws { + let clock = ScrollTestClock() + let channel = Channel() + let session = makeSession(channel, clock: clock) + session.scroll(.upward) + let first = try #require(channel.requests.last) + #expect(await clock.nextSleep() == .seconds(5)) + await clock.advance() + await waitUntil { !session.isScrolling } + #expect(session.scrollError?.contains("in time") == true) + #expect(session.status == .live) + #expect(channel.requests.count == 1) + session.scroll(.downward) + channel.frame(2) + channel.result(first, sequence: 2) + #expect(session.isScrolling) + channel.result(try #require(channel.requests.last), sequence: 2) + #expect(!session.isScrolling) + } + + @Test func disconnectAndTakeoverClearPendingWithoutReplaying() throws { + let channel = Channel() + let session = makeSession(channel) + session.scroll(.upward) + let stale = channel.onMessage + let first = try #require(channel.requests.last) + session.disconnect() + #expect(!session.isScrolling) + session.retry() + #expect(session.status == .live) + #expect(channel.requests.count == 1) + stale?(.scrollResult(.init(requestID: first, sequence: 2, subscriptionID: channel.lease))) + #expect(session.completedScroll == nil) + session.scroll(.downward) + channel.onMessage?(.ended(.init(reason: .takenOver))) + #expect(!session.isScrolling) + #expect(session.status == .takenOver) + #expect(channel.requests.count == 2) + } + + @Test func networkLossClearsLoadingAndOldLeaseCannotCompleteNewScroll() throws { + let channel = Channel() + let session = makeSession(channel) + session.scroll(.upward) + let oldRequest = try #require(channel.requests.last) + let oldLease = channel.lease + channel.onClose?("Network lost") + #expect(!session.isScrolling) + #expect(session.status == .disconnected) + session.retry() + #expect(channel.requests.count == 1) + session.scroll(.downward) + let currentRequest = try #require(channel.requests.last) + channel.onMessage?(.scrollResult(.init(requestID: currentRequest, sequence: 2, subscriptionID: oldLease))) + channel.result(oldRequest, sequence: 2) + #expect(session.isScrolling) + channel.frame(2) + channel.result(currentRequest, sequence: 2) + #expect(!session.isScrolling) + #expect(session.completedScroll?.direction == .downward) + } + + @Test(arguments: [MirrorMessage.EndReason.hostStopped, .paneClosed]) + func hostTerminationClearsLoading(_ reason: MirrorMessage.EndReason) { + let channel = Channel() + let session = makeSession(channel) + session.scroll(.upward) + channel.onMessage?(.ended(.init(reason: reason))) + #expect(!session.isScrolling) + #expect(!session.canScroll) + #expect(session.status == (reason == .hostStopped ? .hostStopped : .paneClosed)) + #expect(channel.requests.count == 1) + } + + @Test(.timeLimit(.minutes(1))) func confirmedScrollCannotLaterTimeOut() async throws { + let clock = ScrollTestClock() + let channel = Channel() + let session = makeSession(channel, clock: clock) + session.scroll(.upward) + #expect(await clock.nextSleep() == .seconds(5)) + channel.frame(2) + channel.result(try #require(channel.requests.last), sequence: 2) + await clock.advance() + #expect(!session.isScrolling) + #expect(session.scrollError == nil) + #expect(session.completedScroll?.direction == .upward) + } + + @Test func edgeGestureLeavesInteriorHorizontalAndSmallDragsLocal() { + let top = MirrorRemoteScrollGesture(offset: 0, maximumOffset: 500) + let middle = MirrorRemoteScrollGesture(offset: 250, maximumOffset: 500) + let bottom = MirrorRemoteScrollGesture(offset: 500, maximumOffset: 500) + #expect(top.direction(translation: CGSize(width: 0, height: 80)) == .upward) + #expect(bottom.direction(translation: CGSize(width: 0, height: -80)) == .downward) + #expect(top.direction(translation: CGSize(width: 0, height: -80)) == nil) + #expect(bottom.direction(translation: CGSize(width: 0, height: 80)) == nil) + #expect(middle.direction(translation: CGSize(width: 0, height: 300)) == nil) + #expect(top.direction(translation: CGSize(width: 0, height: 40)) == nil) + #expect(top.direction(translation: CGSize(width: 100, height: 80)) == nil) + let short = MirrorRemoteScrollGesture(offset: 0, maximumOffset: 0) + #expect(short.direction(translation: CGSize(width: 0, height: -80)) == .downward) + } + + @Test func scrollJSONRoundTripsAndRejectsUnknownDirection() throws { + let request = UUID() + let lease = UUID() + for direction in [MirrorMessage.ScrollDirection.upward, .downward] { + let message = MirrorMessage.scroll(.init(requestID: request, direction: direction, subscriptionID: lease)) + let decoded = try MirrorWire.decode(MirrorWire.encode(message).dropFirst(4)) + #expect(decoded.kind == .scroll) + #expect(decoded.scrollDirection == direction) + #expect(decoded.scrollRequestID == request) + #expect(decoded.subscriptionID == lease) + } + let result = MirrorMessage.scrollResult(.init(requestID: request, sequence: 4, subscriptionID: lease)) + let decoded = try MirrorWire.decode(MirrorWire.encode(result).dropFirst(4)) + #expect(decoded.kind == .scrollResult) + #expect(decoded.scrollRequestID == request) + #expect(decoded.subscriptionID == lease) + #expect(decoded.sequence == 4) + let failure = MirrorMessage.failure(.init(error: "SCROLL_BUSY", subscriptionID: lease, requestID: request)) + #expect(try MirrorWire.decode(MirrorWire.encode(failure).dropFirst(4)).scrollRequestID == request) + let invalid = """ + {"scroll":{"_0":{"requestID":"\(request)","direction":"left","subscriptionID":"\(lease)"}}} + """ + #expect(throws: (any Error).self) { try MirrorWire.decode(Data([0]) + Data(invalid.utf8)) } + } + + private func makeSession(_ channel: Channel, clock: any Clock = ContinuousClock()) -> MirrorSession { + let session = MirrorSession( + configuration: .init(address: "127.0.0.1", port: 7880, pairingKey: ""), clock: clock, + makeTransport: { _ in channel }) + session.connect() + session.select(channel.pane) + return session + } + + private func waitUntil(_ predicate: @MainActor () -> Bool) async { + let changes = AsyncStream.makeStream(of: Void.self) + defer { changes.continuation.finish() } + changes.continuation.yield(()) + for await _ in changes.stream { + let finished = withObservationTracking { + predicate() + } onChange: { + changes.continuation.yield(()) + } + if finished { break } + } + } + + private final class Channel: MirrorTransport { + var onReady: (() -> Void)? + var onMessage: ((MirrorMessage) -> Void)? + var onClose: ((String?) -> Void)? + let supportsScroll: Bool + let pane = MirrorPaneDescriptor(id: UUID(), title: "Scroll", directory: "/", busy: false) + var lease = UUID() + var requests: [UUID] = [] + var closes = 0 + init(supportsScroll: Bool = true) { self.supportsScroll = supportsScroll } + func start() { onReady?() } + func close(_ reason: String?) { + closes += 1 + onClose?(reason) + } + func send(_ message: MirrorMessage, closeAfterSending: Bool) { + switch message.kind { + case .list: + onMessage?( + .panes( + .init( + panes: [pane], capabilities: ["text-v1", "history"] + (supportsScroll ? ["remote-scroll"] : []), + hostRunID: UUID()))) + case .subscribe: + lease = UUID() + onMessage?(.subscribed(.init(paneID: pane.id, subscriptionID: lease, hostRunID: UUID()))) + frame(1) + case .scroll: + if let id = message.scrollRequestID { requests.append(id) } + default: break + } + } + func frame(_ sequence: UInt64) { + onMessage?(.textFrame(.init(sequence: sequence, text: "current", subscriptionID: lease))) + } + func result(_ id: UUID, sequence: UInt64) { + onMessage?(.scrollResult(.init(requestID: id, sequence: sequence, subscriptionID: lease))) + } + func failure(_ id: UUID, code: String) { + onMessage?(.failure(.init(error: code, subscriptionID: lease, requestID: id))) + } + } +} + +private nonisolated struct ScrollTestClock: Clock { + let now = ContinuousClock.now + let minimumResolution = Duration.zero + private let storage = Storage() + func sleep(until deadline: ContinuousClock.Instant, tolerance: Duration?) async throws { + let id = UUID() + try await withTaskCancellationHandler { + try await storage.sleep(id: id, duration: now.duration(to: deadline)) + } onCancel: { + Task { await storage.cancel(id) } + } + } + func nextSleep() async -> Duration? { await storage.nextSleep() } + func advance() async { await storage.advance() } + + private actor Storage { + let sleeps = AsyncStream.makeStream(of: Duration.self) + var pending: [UUID: CheckedContinuation] = [:] + var cancelled: Set = [] + func sleep(id: UUID, duration: Duration) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + if cancelled.remove(id) != nil { + continuation.resume(throwing: CancellationError()) + } else { + pending[id] = continuation + sleeps.continuation.yield(duration) + } + } + } + func nextSleep() async -> Duration? { + var iterator = sleeps.stream.makeAsyncIterator() + return await iterator.next() + } + func advance() { + let continuations = pending.values + pending.removeAll() + for continuation in continuations { continuation.resume() } + } + func cancel(_ id: UUID) { + if let continuation = pending.removeValue(forKey: id) { + continuation.resume(throwing: CancellationError()) + } else { + cancelled.insert(id) + } + } + } +} diff --git a/MirrorClient/iOS/ProwlMirror-iOSUITests/ProwlMirror_iOSUITests.swift b/MirrorClient/iOS/ProwlMirror-iOSUITests/ProwlMirror_iOSUITests.swift index 52e4aedb..a0fe4786 100644 --- a/MirrorClient/iOS/ProwlMirror-iOSUITests/ProwlMirror_iOSUITests.swift +++ b/MirrorClient/iOS/ProwlMirror-iOSUITests/ProwlMirror_iOSUITests.swift @@ -2,6 +2,129 @@ import UIKit import XCTest final class ProwlMirror_iOSUITests: XCTestCase { + @MainActor + func testSelectingTextDoesNotScrollTheHost() { + let app = XCUIApplication() + app.launchArguments = ["--mirror-ui-fixture", "--mirror-ui-scroll-fixture"] + XCUIDevice.shared.orientation = .portrait + app.launch() + let text = app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "Remote page 0\n")).firstMatch + XCTAssertTrue(text.waitForExistence(timeout: 10)) + let start = text.coordinate(withNormalizedOffset: CGVector(dx: 0.15, dy: 0.25)) + start.press(forDuration: 1, thenDragTo: start.withOffset(CGVector(dx: 0, dy: 140))) + XCTAssertTrue(text.exists) + XCTAssertTrue( + app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Copy")).firstMatch.exists) + XCTAssertFalse( + app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "Remote page -1\n")).firstMatch.exists) + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + attachment.name = "Text selection leaves Host scroll unchanged" + attachment.lifetime = .keepAlways + add(attachment) + } + + @MainActor + func testRemoteScrollRevealsTheNewEdgeWhileInteriorDragStaysLocal() { + let app = XCUIApplication() + app.launchArguments = [ + "--mirror-ui-fixture", "--mirror-ui-scroll-fixture", "--mirror-ui-scroll-long-fixture", + ] + XCUIDevice.shared.orientation = .portrait + app.launch() + let scrollUp = app.buttons["mirror-scroll-up"] + let reading = app.scrollViews["mirror-live-scroll"] + XCTAssertTrue(scrollUp.waitForExistence(timeout: 10)) + scrollUp.tap() + let earlier = app.staticTexts["Remote -1 marker 1"] + expectation( + for: NSPredicate { _, _ in + earlier.exists && earlier.isHittable && earlier.frame.minY >= reading.frame.minY + && earlier.frame.maxY <= reading.frame.maxY + }, evaluatedWith: nil) + waitForExpectations(timeout: 5) + reading.swipeUp() + let sameScreen = app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "Remote -1 marker ")) + XCTAssertTrue(sameScreen.allElementsBoundByIndex.contains { $0.isHittable }) + app.buttons["mirror-scroll-down"].tap() + let later = app.staticTexts["Remote 0 marker 40"] + expectation( + for: NSPredicate { _, _ in + later.exists && later.isHittable && later.frame.minY >= reading.frame.minY + && later.frame.maxY <= reading.frame.maxY + }, evaluatedWith: nil) + waitForExpectations(timeout: 5) + } + + @MainActor + func testRemoteScrollShowsLoadingAndKeepsHistoryIndependent() { + let app = XCUIApplication() + app.launchArguments = [ + "--mirror-ui-fixture", "--mirror-ui-scroll-fixture", "--mirror-ui-scroll-delay-fixture", + ] + XCUIDevice.shared.orientation = .portrait + app.launch() + let scrollUp = app.buttons["mirror-scroll-up"] + let scrollDown = app.buttons["mirror-scroll-down"] + let progress = app.descendants(matching: .any).matching(identifier: "mirror-scroll-progress").firstMatch + XCTAssertTrue(scrollUp.waitForExistence(timeout: 10)) + scrollUp.tap() + XCTAssertTrue(progress.waitForExistence(timeout: 5)) + XCTAssertFalse(scrollUp.isEnabled) + XCTAssertFalse(scrollDown.isEnabled) + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + attachment.name = "Remote scroll loading" + attachment.lifetime = .keepAlways + add(attachment) + let olderPage = app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "Remote page -1\n")).firstMatch + XCTAssertTrue(olderPage.waitForExistence(timeout: 8)) + XCTAssertFalse(progress.exists) + scrollDown.tap() + app.buttons["History"].tap() + XCTAssertTrue(app.staticTexts["Loaded lines 202–401"].waitForExistence(timeout: 5)) + XCTAssertFalse(scrollUp.exists) + app.buttons["Load Earlier 200 Lines"].tap() + XCTAssertTrue(app.staticTexts["Loaded lines 2–401"].waitForExistence(timeout: 5)) + app.buttons["Live Output"].tap() + let latestPage = app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "Remote page 0\n")).firstMatch + XCTAssertTrue(latestPage.waitForExistence(timeout: 8)) + XCTAssertTrue(scrollUp.isEnabled) + XCTAssertTrue(scrollDown.isEnabled) + } + + @MainActor + func testRemoteScrollButtonsAndEdgeGesturesLeaveHistoryUnchanged() { + let app = XCUIApplication() + app.launchArguments = ["--mirror-ui-fixture", "--mirror-ui-scroll-fixture"] + XCUIDevice.shared.orientation = .portrait + app.launch() + let scrollUp = app.buttons["mirror-scroll-up"] + let down = app.buttons["mirror-scroll-down"] + XCTAssertTrue(scrollUp.waitForExistence(timeout: 10)) + XCTAssertTrue(scrollUp.isEnabled) + func page(_ number: Int) -> XCUIElement { + app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "Remote page \(number)\n")).firstMatch + } + scrollUp.tap() + XCTAssertTrue(page(-1).waitForExistence(timeout: 5)) + down.tap() + XCTAssertTrue(page(0).waitForExistence(timeout: 5)) + let reading = app.scrollViews["mirror-live-scroll"] + reading.swipeDown() + XCTAssertTrue(page(-1).waitForExistence(timeout: 5)) + reading.swipeUp() + XCTAssertTrue(page(0).waitForExistence(timeout: 5)) + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + attachment.name = "Remote scroll controls" + attachment.lifetime = .keepAlways + add(attachment) + app.buttons["History"].tap() + XCTAssertTrue(app.buttons["Load Earlier 200 Lines"].waitForExistence(timeout: 5)) + XCTAssertFalse(scrollUp.exists) + XCTAssertFalse(down.exists) + app.buttons["Load Earlier 200 Lines"].tap() + XCTAssertTrue(app.staticTexts["Loaded lines 2–401"].waitForExistence(timeout: 5)) + } + @MainActor func testClearingBothPairingHalvesAllowsCredentialReconnect() { let app = XCUIApplication() diff --git a/MirrorClient/iOS/README.md b/MirrorClient/iOS/README.md index 00628ecf..dddb488f 100644 --- a/MirrorClient/iOS/README.md +++ b/MirrorClient/iOS/README.md @@ -48,6 +48,23 @@ History reads 200-line pages from a frozen retained snapshot, with a 2 MiB budge Each pane keeps separate live/history reading positions. Erased transient output is not archived. Ghostty is not embedded in this client. +On Hosts advertising `remote-scroll`, **Scroll Up** and **Scroll Down** move the +Host pane by a small, bounded scroll amount. Pull downward past the top of the live text, or +upward past its bottom, for the same action. Ordinary scrolling within the local +text remains local, and long presses keep native text selection. These controls +operate the Host's current terminal/application view; they are separate from History, +whose frozen snapshots and paging are unchanged. +Applications decide how terminal scroll input is handled, so the exact distance +can vary. Remote scrolling changes the view seen on Host as well. + +One scroll waits for a fresh screen and matching Host acknowledgement; a spinner +ends after five seconds if no confirmation arrives. Timed-out or disconnected +scrolls are never replayed automatically. A fresh capture does not prove that the +application moved, including when it is already at the beginning or end. Following +latest text pauses during remote scrolling. After scrolling up, the local reader +starts at the top of the returned screen; after scrolling down, it starts at the +bottom. Older Hosts keep working with remote scroll controls disabled. + The composer is one line when unfocused, expands while editing, then scrolls at its height limit. Send is explicit; Return inserts a newline. Two physical Returns within 350 ms send when enabled, removing only the shortcut's first newline. diff --git a/docs-ai/067-remote-mirror/000-plan.md b/docs-ai/067-remote-mirror/000-plan.md index 5f1cd7ec..980c053a 100644 --- a/docs-ai/067-remote-mirror/000-plan.md +++ b/docs-ai/067-remote-mirror/000-plan.md @@ -70,3 +70,4 @@ capability needed here. Browser clients are outside this implementation. - Updated 2026-10-01: Separate mobile interactive input from task receipts — see [009](009-interactive-input.md). - Updated 2026-10-01: QR enrollment for mobile clients — see [010-qr-pairing.md](010-qr-pairing.md). +- Updated 2026-10-01: Preserve History and add direct remote viewport scrolling — see [011-remote-scroll.md](011-remote-scroll.md). diff --git a/docs-ai/067-remote-mirror/011-remote-scroll.md b/docs-ai/067-remote-mirror/011-remote-scroll.md new file mode 100644 index 00000000..9d0887ac --- /dev/null +++ b/docs-ai/067-remote-mirror/011-remote-scroll.md @@ -0,0 +1,80 @@ +# 067.011 — Remote viewport scrolling + +Status: Implemented and locally verified (2026-10-01); physical-device and remote-Host acceptance pending. + +## Context + +Mirror History reads a frozen Ghostty retained-text buffer. It cannot navigate +history owned by a full-screen application. Preserve History unchanged and add +direct, bounded up/down interaction with the live Host terminal on all clients. + +## Design + +- Advertise `remote-scroll`. A leased `scroll` control carries a request UUID and + `up` / `down` direction, independent of `vt-v1` / `text-v1` representation. +- Host routes a small wheel movement through its own Ghostty surface, at + the terminal center. Negotiated mouse / alternate-scroll modes remain owned + by Ghostty; there are no agent-specific keys or dispatch completion receipts. +- Ghostty source and prebuilt artifacts remain unchanged. Existing wheel routing + handles both application mouse/alternate-scroll input and native scrollback. + Mobile text frames use the existing viewport text API. For terminal clients, + active styled VT remains authoritative for display/input-mode negotiation. +- Clients explicitly opt into `viewport-text-v1` using `includeViewportText`. + An optional plain-text viewport accompanies each VT frame with the same sequence, + before the binary frame. It is displayed only after that frame is acknowledged. + Native scrollback uses this plain overlay; the active viewport clears it. Older + clients never receive this additional message. History stays unchanged. +- Native scrollback is detected using the visible geometry of ACTIVE `(0,0)`, + including when that cell is blank or fully outside the viewport. Capture checks + dimensions and geometry for concurrent resize; no agent identity is consulted. + Physical-row reads preserve soft wraps in the Mac overlay. Original scrollback + colors are unavailable through these public APIs, an accepted presentation tradeoff. +- A `scrollResult` identifies a fresh frame sequence after scrolling; this confirms + terminal input and capture, not application-level page position or completion. +- Clients allow one scroll at a time and provide loading, bounded timeout, + cancellation on disconnect/takeover, and up/down buttons as gesture fallback. + No automatic retry of input after uncertain delivery. +- Mac wheel gestures scroll the remote terminal. Mobile text first scrolls its + local readable content; an outward drag at a reading boundary requests a remote + step. Directions follow ordinary scroll semantics; History is unaffected. +- Local Host and remote client share the live viewport. Page distance depends on + application handling and configured wheel multiplier. This is not an independent archive. + +## Verification plan + +Cover wire controls, lease/capability checks, no-op scrolling, sequence correlation, +bounded loading, stale replies, reconnect and unchanged History. Exercise both +normal scrollback and alternate-screen input using isolated terminal fixtures. +Build macOS, iOS and Android; do not restart the user's active Host or submit a PR. + +## Outcome + +Implemented across the Host, macOS, iOS and Android. The Host injects one wheel +notch, allows changed captures after 200ms, and requests a forced fresh capture +at 800ms when the screen has not changed. Existing frame acknowledgement and +capture retries can delay delivery; 800ms is not a response deadline. Clients +stop loading after five seconds without replaying uncertain input. + +Native Mac scrollback uses a selectable plain-text overlay with physical row +boundaries, including wide-character wraps. Reaching the active screen restores +the styled terminal. Overlay shortcuts preserve other input fields' focus. On +mobile, up completion anchors the readable viewport at the top and down at the +bottom. Interior drags remain local; long-press selection does not scroll the Host. +History retains its existing frozen-buffer paging behavior. + +Verification covered 54 macOS protocol, lifecycle, state and real Ghostty +integration tests, plus a focused overlay keyboard regression. The integrations +exercise native scrollback, an alternate-screen application, text-v1 over TLS, +resize, blank/wide-character rows, frame pairing and the complete Mac pane view. +An isolated on-screen Mac fixture also verified the overlay and scroll controls. +iOS passed 64 cases / 70 parameterized runs on iPad, with additional iPhone +gesture checks. Android passed 36 unit tests (one existing optional TLS case +skipped), 12 emulator UI tests, Debug assembly and Android lint. Mac Debug, +iOS simulator and unsigned iPhoneOS arm64 Debug builds succeeded. The Ghostty submodule and bundled framework +remain unchanged. + +Repository formatting, 210 script tests, performance-script tests, workflow naming +and the string-catalog check pass (script checks require Python 3.10 or newer). +Full repository lint remains blocked by five +pre-existing `legacy_swiftui_aspect_ratio` violations in `RepositoryIconImage.swift`; +the remote-scroll macOS files pass. No live Host was restarted and no PR was opened. diff --git a/docs/remote-mirror-wire.md b/docs/remote-mirror-wire.md index 104d5b7b..2b42fb2d 100644 --- a/docs/remote-mirror-wire.md +++ b/docs/remote-mirror-wire.md @@ -23,7 +23,7 @@ is `{"list":{}}`; a subscribe is `{"subscribe":{"_0":{"paneID":"UUID", fixed-byte vectors, independent of encode/decode round-trip tests. Controls: challenge, authenticate, pair, paired, authenticated; list, panes; -subscribe, subscribed, acknowledge, input, refresh; history, historyPage; command, +subscribe, subscribed, acknowledge, input, refresh, scroll, scrollResult, viewport; history, historyPage; command, commandResult, commandReceipt; failure, ended, ping, pong. There is no private Agent-state or private submission control. Command envelopes preserve the public CLI JSON and UUID request ID. See the feature documentation for command scope. @@ -56,7 +56,7 @@ the enrollment connection and reconnecting with its device credential. Host name are display metadata, never identity. Authentication has a five-second deadline. Failure accounting and pending TLS pools are bounded. No secret is logged. -A subscribed connection has exactly one lease. Every input, ACK, history request, +A subscribed connection has exactly one lease. Every input, scroll, ACK, history request, refresh and targeted command must match it. Takeover revokes the previous connection. Delayed commands recheck authorization immediately before delivery. A command result is not a terminal/frame acknowledgement. One unacknowledged frame per subscriber @@ -66,6 +66,62 @@ History ID/offset refer to one frozen snapshot, with fixed total and capture tim Pages cannot overlap, skip or exceed the total byte budget. Heartbeats run every two seconds; eight seconds without inbound traffic ends an established connection. +## Remote scroll + +The additive `remote-scroll` capability permits scroll controls for both `vt-v1` +and `text-v1`. Clients without that capability must not send scroll controls. +Binary frame formats remain unchanged. + +```json +{"scroll":{"_0":{"requestID":"UUID","direction":"up","subscriptionID":"UUID"}}} +{"scrollResult":{"_0":{"requestID":"UUID","sequence":42,"subscriptionID":"UUID"}}} +``` + +Direction is exactly `up` or `down`. Host applies one discrete wheel notch through +Ghostty at the terminal center, honoring its configured wheel multiplier and +application mouse/alternate-scroll negotiation. Distance depends on the program +and configuration, not a fixed page size. Mobile live text uses the Host viewport; +retained-text History capture remains unchanged. + +There is one pending scroll per subscription. After applying input, Host waits +at least 200 ms and prefers a changed capture; after 800 ms it requests a fresh +frame even if unchanged. Existing frame acknowledgement backpressure still applies. It +sends `scrollResult` after that frame on the same ordered connection. `sequence` +identifies the fresh frame, not an application-level acknowledgement: terminal +applications may redraw later or ignore the input. Clients wait for the correlated +frame and result, allow five seconds, and never replay scroll input automatically. +Mac additionally waits for its display relay to acknowledge that frame. + +`SCROLL_UNAVAILABLE` and `SCROLL_BUSY` are recoverable `failure` messages carrying +the subscription ID and additive optional `requestID`; only a matching pending +scroll is failed. Delayed results/errors cannot complete a newer scroll. Disconnect, +takeover and a replacement subscription clear pending local waits. Duplicate IDs +for the current pending or most recently completed scroll never inject input twice. +No Agent dispatch or Agent-generated completion receipt participates in scrolling. + +## Native scrollback text for terminal clients + +`viewport-text-v1` is a separate additive capability. A terminal client opts in +with `includeViewportText: true` in its `subscribe` payload. Without both opt-in +and Host support, the Host never sends the additional control message. + +```json +{"viewport":{"_0":{"text":"earlier terminal rows","sequence":42,"subscriptionID":"UUID"}}} +``` + +This control precedes each corresponding binary VT frame on the same ordered +connection. Its sequence and lease must match that frame. Missing/null `text` +selects the original styled active screen; a string, including an empty string, +selects a plain native-scrollback overlay. The client commits this presentation +only with the matching relay acknowledgement. The VT frame and its input modes +remain intact underneath. Native viewport text participates in Host change +detection even when the active VT bytes are unchanged. Binary formats are unchanged. + +Per-row viewport text preserves physical wrapping. The source checks terminal +geometry before/after capture and retries transient resize mismatches on later +polls, with a finite retry bound. This is not an atomic terminal checkpoint or +an independent history archive, and does not export scrollback colors. + ## Interactive Agent input A Host advertising `agent-input` accepts structured `agentsInput` with the same diff --git a/docs/remote-mirror.md b/docs/remote-mirror.md index a80d22bd..d4c75971 100644 --- a/docs/remote-mirror.md +++ b/docs/remote-mirror.md @@ -99,11 +99,39 @@ A project that has never opened a terminal is not yet a pane, but its worktree i available in **New Pane…** on a current Host. The Host terminal owns the grid. Mac clients default to **Fit to Window**, shrinking the complete terminal to fit without changing the Host PTY. **Original Size** restores readable native-size -glyphs with local scrolling. It starts at the top; subsequent window resizes preserve +glyphs with local panning (hold Option while scrolling when remote scrolling is available). It starts at the top; subsequent window resizes preserve the manual scroll position within the available bounds. Large Host grids can make Fit to Window text small. iOS renders replacement text with local reflow. Cleared output is not an archive. +On a Host advertising remote scrolling, **Scroll Up** and **Scroll Down** move +the live Host terminal by a small wheel step. Mac vertical wheel/trackpad gestures +use the same controls; one trackpad gesture requests one step. On phones, scroll +the current text normally, then pull outward at the top for earlier content or +at the bottom for later content. The new view stays at the corresponding edge. +Buttons remain available +when a gesture is inconvenient. Horizontal scrolling remains local. + +**Scrolling…** remains visible until a fresh Host frame confirms the request, +or a five-second timeout ends the wait. A boundary that does not change the frame +still completes. Requests are never automatically replayed after uncertainty. +The Host handles scrolling using its terminal's negotiated mouse/alternate-scroll +mode, aimed at the center of its grid. Application-specific scroll regions or +bindings can change the distance or response; this is not an exact line-number +navigation contract. Both devices share the Host view, so scrolling can move the +Host user's view too. An older Host leaves remote scrolling unavailable. + +The Mac client keeps the original styled terminal while an application redraws +its own screen. When the Host viewport moves into native terminal scrollback, +it displays **Host scrollback · Plain text** using that viewport's physical rows. +Returning to the active viewport restores the styled terminal. This is selected +from terminal state, independent of the Agent being run. The existing Ghostty +APIs expose scrollback text but not its styles; mobile remains text throughout. + +History remains a separate, read-only view. Its scrolling and page loading do not +send remote scroll input or change the frozen snapshot. No output archive or +cross-frame text merging is added. + Host samples subscribed panes every 200 ms and allows only one unacknowledged frame per subscription. Unchanged frames are omitted; text geometry and truncation are also part of the change check. No subscriptions means no terminal sampling. diff --git a/supacode/Features/RemoteMirror/GhosttyMirrorPaneSource.swift b/supacode/Features/RemoteMirror/GhosttyMirrorPaneSource.swift index 3c20b17f..60c35344 100644 --- a/supacode/Features/RemoteMirror/GhosttyMirrorPaneSource.swift +++ b/supacode/Features/RemoteMirror/GhosttyMirrorPaneSource.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import GhosttyKit @@ -36,10 +37,11 @@ final class GhosttyMirrorPaneSource: MirrorPaneSource { }.sorted { $0.title < $1.title } } + var supportsViewportText: Bool { true } + func snapshot(_ id: UUID) throws -> MirrorFrame { guard let terminal = view(id)?.surface else { throw MirrorProtocolError.invalidMessage } - let size = ghostty_surface_size(terminal) - guard size.columns > 0, size.rows > 0 else { throw MirrorProtocolError.invalidMessage } + let geometry = try captureGeometry(terminal) var text = ghostty_text_s() guard ghostty_surface_read_snapshot(terminal, &text) else { throw MirrorProtocolError.invalidMessage @@ -48,9 +50,13 @@ final class GhosttyMirrorPaneSource: MirrorPaneSource { guard text.text_len <= MirrorWire.maximumPayload / 2, let bytes = text.text else { throw MirrorProtocolError.messageTooLarge } + let viewportText = + geometry.active.isScrolled + ? try viewportText(terminal, columns: geometry.columns, rows: geometry.rows, preserveRows: true) : nil + guard try captureGeometry(terminal) == geometry else { throw MirrorPaneSourceError.captureChanged } return MirrorFrame( - columns: UInt32(size.columns), rows: UInt32(size.rows), - bytes: Data(bytes: bytes, count: Int(text.text_len))) + columns: geometry.columns, rows: geometry.rows, + bytes: Data(bytes: bytes, count: Int(text.text_len)), viewportText: viewportText) } func write(_ bytes: Data, to id: UUID) throws { @@ -79,6 +85,28 @@ final class GhosttyMirrorPaneSource: MirrorPaneSource { .wakeAgentDetection(forSurfaceID: id) } + var supportsRemoteScroll: Bool { true } + + func scroll(_ direction: MirrorMessage.ScrollDirection, to id: UUID) throws { + guard let view = view(id), let terminal = view.surface else { throw MirrorProtocolError.invalidMessage } + let size = ghostty_surface_size(terminal) + guard size.columns > 0, size.rows > 0 else { throw MirrorProtocolError.invalidMessage } + let localPoint = view.window.map { view.convert($0.mouseLocationOutsideOfEventStream, from: nil) } + let localMods = view.ghosttyMods(NSEvent.modifierFlags) + defer { + if let localPoint, view.bounds.contains(localPoint) { + ghostty_surface_mouse_pos(terminal, localPoint.x, view.bounds.height - localPoint.y, localMods) + } else { + ghostty_surface_mouse_pos(terminal, -1, -1, localMods) + } + } + // Move through the outside position so an unchanged center cannot retain + // local modifier keys. One notch honors Host's configured wheel multiplier. + ghostty_surface_mouse_pos(terminal, -1, -1, GHOSTTY_MODS_NONE) + ghostty_surface_mouse_pos(terminal, view.bounds.midX, view.bounds.midY, GHOSTTY_MODS_NONE) + ghostty_surface_mouse_scroll(terminal, 0, direction == .upward ? 1 : -1, 0) + } + var supportsBoundedHistory: Bool { true } func boundedRetainedText(_ id: UUID) throws -> MirrorRetainedText { @@ -87,11 +115,92 @@ final class GhosttyMirrorPaneSource: MirrorPaneSource { func textSnapshot(_ id: UUID) throws -> MirrorTextSnapshot { guard let terminal = view(id)?.surface else { throw MirrorProtocolError.invalidMessage } + let geometry = try captureGeometry(terminal) + let text = try viewportText(terminal, columns: geometry.columns, rows: geometry.rows, preserveRows: false) + guard try captureGeometry(terminal) == geometry else { throw MirrorPaneSourceError.captureChanged } + return .init(text: text, columns: geometry.columns, rows: geometry.rows) + } + + private struct Probe: Equatable { + let pixelY: Double + let offset: UInt32 + var isScrolled: Bool { pixelY < 0 || offset > 0 } + } + + private struct CaptureGeometry: Equatable { + let columns: UInt32 + let rows: UInt32 + let active: Probe + } + + private func captureGeometry(_ terminal: ghostty_surface_t) throws -> CaptureGeometry { let size = ghostty_surface_size(terminal) - let value = try boundedText(id, active: true, maximumBytes: MirrorWire.maximumPayload / 8) - return .init( - text: value.text, columns: UInt32(size.columns), rows: UInt32(size.rows), - truncated: value.truncated) + guard (1...1000).contains(size.columns), (1...1000).contains(size.rows) else { + throw MirrorProtocolError.invalidMessage + } + let active = try probe(terminal, tag: GHOSTTY_POINT_ACTIVE, coordinate: GHOSTTY_POINT_COORD_EXACT) + let bottom = try probe(terminal, tag: GHOSTTY_POINT_VIEWPORT, coordinate: GHOSTTY_POINT_COORD_BOTTOM_RIGHT) + let columns = UInt32(size.columns) + let rows = UInt32(size.rows) + // Surface dimensions can advance before the terminal IO thread applies a + // resize. The viewport's final cell verifies the terminal grid itself. + guard bottom.pixelY >= 0, bottom.offset == columns * rows - 1 else { throw MirrorPaneSourceError.captureChanged } + return .init(columns: columns, rows: rows, active: active) + } + + private func probe( + _ terminal: ghostty_surface_t, tag: ghostty_point_tag_e, coordinate: ghostty_point_coord_e + ) throws -> Probe { + let point = ghostty_point_s(tag: tag, coord: coordinate, x: 0, y: 0) + var text = ghostty_text_s() + guard ghostty_surface_read_text(terminal, .init(top_left: point, bottom_right: point, rectangle: false), &text) + else { + throw MirrorProtocolError.invalidMessage + } + defer { ghostty_surface_free_text(terminal, &text) } + // Geometry remains valid for a blank cell; text length is not a mode probe. + guard text.tl_px_y.isFinite else { throw MirrorProtocolError.invalidMessage } + return .init(pixelY: text.tl_px_y, offset: text.offset_start) + } + + private func viewportText( + _ terminal: ghostty_surface_t, columns: UInt32, rows: UInt32, preserveRows: Bool + ) throws -> String { + if !preserveRows { + return try readText( + terminal, + selection: .init( + top_left: .init(tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_TOP_LEFT, x: 0, y: 0), + bottom_right: .init(tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, x: 0, y: 0), + rectangle: false)) + } + // The existing text API unwraps soft lines. Read physical rows separately + // so the Mac overlay preserves the Host grid without interpreting VT text. + var lines: [String] = [] + var byteCount = 0 + for row in 0.. 0 ? 1 : 0) + guard byteCount <= MirrorWire.maximumPayload / 8 else { throw MirrorProtocolError.messageTooLarge } + lines.append(line) + } + return lines.joined(separator: "\n") + } + + private func readText(_ terminal: ghostty_surface_t, selection: ghostty_selection_s) throws -> String { + var result = ghostty_text_s() + guard ghostty_surface_read_text(terminal, selection, &result) else { throw MirrorProtocolError.invalidMessage } + defer { ghostty_surface_free_text(terminal, &result) } + guard result.text_len <= MirrorWire.maximumPayload / 8 else { throw MirrorProtocolError.messageTooLarge } + guard let bytes = result.text, + let text = String(bytes: UnsafeRawBufferPointer(start: bytes, count: Int(result.text_len)), encoding: .utf8) + else { throw MirrorProtocolError.invalidMessage } + return text } func activeText(_ id: UUID) throws -> String { diff --git a/supacode/Features/RemoteMirror/MirrorClient.swift b/supacode/Features/RemoteMirror/MirrorClient.swift index 27693a3b..594a22db 100644 --- a/supacode/Features/RemoteMirror/MirrorClient.swift +++ b/supacode/Features/RemoteMirror/MirrorClient.swift @@ -22,6 +22,10 @@ final class MirrorClient: Identifiable { private(set) var supportsProfileLaunch = false private(set) var supportsShellLaunch = false private(set) var supportsHistory = true + private(set) var supportsRemoteScroll = false + private(set) var supportsViewportText = false + private(set) var viewportState = MirrorViewportState() + let scrollState = MirrorScrollState() private(set) var historyTruncated = false private(set) var isSubscribed = false var onVerifiedConnection: (() -> Void)? @@ -76,6 +80,10 @@ final class MirrorClient: Identifiable { failure = nil endReason = nil subscriptionID = nil + scrollState.reset() + supportsRemoteScroll = false + supportsViewportText = false + viewportState = MirrorViewportState() isSubscribed = false isConnecting = true let peer = makeConnection(configuration) @@ -105,6 +113,7 @@ final class MirrorClient: Identifiable { self.isConnected = false self.isConnecting = false self.isLoadingHistory = false + self.scrollState.cancel() self.isSubscribed = false self.subscriptionID = nil self.failure = peer.failure @@ -137,12 +146,24 @@ final class MirrorClient: Identifiable { message.kind == .input || message.kind == .acknowledge, message.subscriptionID == self.subscriptionID else { return } + if message.kind == .acknowledge, let sequence = message.sequence { + if self.supportsViewportText { + do { try self.viewportState.didPresent(sequence: sequence) } catch { + self.peer?.close(String(localized: "Invalid Host viewport frame.")) + return + } + } + self.scrollState.didPresent(sequence: sequence) + } self.peer?.send(message) } replica.onFailure = { [weak self] reason in self?.peer?.close(reason) } try replica.start() peer?.send( - .subscribe(.init(paneID: pane.id, representation: .terminal, intent: resumeIntent)) + .subscribe( + .init( + paneID: pane.id, representation: .terminal, intent: resumeIntent, + includeViewportText: supportsViewportText ? true : nil)) ) } catch { peer?.close(error.localizedDescription) } } @@ -156,6 +177,8 @@ final class MirrorClient: Identifiable { isConnected = false isConnecting = false isLoadingHistory = false + scrollState.reset() + viewportState = MirrorViewportState() isSubscribed = false subscriptionID = nil historyLines = [] @@ -172,6 +195,7 @@ final class MirrorClient: Identifiable { } isLoadingHistory = true showsHistory = true + scrollState.cancel() peer?.send( .history( .init( @@ -179,6 +203,15 @@ final class MirrorClient: Identifiable { subscriptionID: subscriptionID))) } + var canScroll: Bool { + isSubscribed && supportsRemoteScroll && !showsHistory && !scrollState.isLoading + } + + func scroll(_ direction: MirrorMessage.ScrollDirection) { + guard canScroll, let subscriptionID, let requestID = scrollState.begin() else { return } + peer?.send(.scroll(.init(requestID: requestID, direction: direction, subscriptionID: subscriptionID))) + } + private func receive(_ message: MirrorMessage) { switch message.kind { case .panes: receivePanes(message) @@ -196,6 +229,8 @@ final class MirrorClient: Identifiable { return } subscriptionID = id + scrollState.reset() + viewportState = MirrorViewportState() historyID = nil historyLines = [] historyOffset = 0 @@ -203,14 +238,9 @@ final class MirrorClient: Identifiable { case .ended: receiveEnd(message) case .frame: - guard selectedPane != nil, - subscriptionID != nil && message.subscriptionID == subscriptionID - else { - peer?.close(String(localized: "Unexpected Host frame.")) - return - } - isSubscribed = true - replica.display(message) + receiveFrame(message) + case .viewport: + receiveViewport(message) case .historyPage: if historyID == nil { historyPageGate = MirrorHistoryPageGate() } guard isLoadingHistory, message.subscriptionID == subscriptionID, @@ -227,18 +257,70 @@ final class MirrorClient: Identifiable { historyOffset = offset historyLines.insert(contentsOf: lines, at: 0) isLoadingHistory = false + case .scrollResult: + receiveScrollResult(message) case .failure: - if message.error?.hasPrefix("HISTORY_UNAVAILABLE") == true, - message.subscriptionID == subscriptionID - { - isLoadingHistory = false - error = message.error + receiveFailure(message) + default: peer?.close(String(localized: "Unexpected Host message.")) + } + } + + private func receiveFrame(_ message: MirrorMessage) { + guard selectedPane != nil, subscriptionID != nil && message.subscriptionID == subscriptionID else { + peer?.close(String(localized: "Unexpected Host frame.")) + return + } + if supportsViewportText { + do { + guard let sequence = message.sequence else { throw MirrorProtocolError.invalidMessage } + try viewportState.receiveFrame(sequence: sequence) + } catch { + peer?.close(String(localized: "Invalid Host viewport frame.")) return } - if message.error?.hasPrefix("PANE_BUSY") == true { endReason = .takenOver } - peer?.close(message.error ?? String(localized: "Host rejected the request.")) - default: peer?.close(String(localized: "Unexpected Host message.")) } + isSubscribed = true + replica.display(message) + } + + private func receiveViewport(_ message: MirrorMessage) { + guard supportsViewportText, subscriptionID != nil, message.subscriptionID == subscriptionID, + case .viewport(let payload) = message + else { + peer?.close(String(localized: "Unexpected Host viewport.")) + return + } + do { try viewportState.stage(payload) } catch { + peer?.close(String(localized: "Invalid Host viewport frame.")) + } + } + + private func receiveScrollResult(_ message: MirrorMessage) { + guard message.subscriptionID == subscriptionID, + let requestID = message.scrollRequestID, let sequence = message.sequence + else { + peer?.close(String(localized: "Invalid scroll result.")) + return + } + scrollState.receiveResult(requestID: requestID, sequence: sequence) + } + + private func receiveFailure(_ message: MirrorMessage) { + if message.error?.hasPrefix("SCROLL_") == true, message.subscriptionID == subscriptionID { + if let requestID = message.scrollRequestID, requestID == scrollState.requestID { + scrollState.fail(message.error ?? String(localized: "Host could not scroll.")) + } + return + } + if message.error?.hasPrefix("HISTORY_UNAVAILABLE") == true, + message.subscriptionID == subscriptionID + { + isLoadingHistory = false + error = message.error + return + } + if message.error?.hasPrefix("PANE_BUSY") == true { endReason = .takenOver } + peer?.close(message.error ?? String(localized: "Host rejected the request.")) } private func receiveEnd(_ message: MirrorMessage) { @@ -263,6 +345,8 @@ final class MirrorClient: Identifiable { supportsProfileLaunch = message.capabilities?.contains("launch-profile") == true supportsShellLaunch = message.capabilities?.contains("launch-shell") == true supportsHistory = message.capabilities?.contains("history") == true + supportsRemoteScroll = message.capabilities?.contains("remote-scroll") == true + supportsViewportText = message.capabilities?.contains("viewport-text-v1") == true supportsTakeover = message.capabilities?.contains("takeover") == true isConnecting = false onVerifiedConnection?() diff --git a/supacode/Features/RemoteMirror/MirrorHost.swift b/supacode/Features/RemoteMirror/MirrorHost.swift index 082b2eb5..205e29eb 100644 --- a/supacode/Features/RemoteMirror/MirrorHost.swift +++ b/supacode/Features/RemoteMirror/MirrorHost.swift @@ -152,9 +152,21 @@ final class MirrorHost { let paneID: UUID let id = UUID() var representation: MirrorMessage.Representation = .terminal + var includeViewportText = false + var unstableCaptures = 0 var gate = MirrorFrameGate() var textGate = MirrorTextFrameGate() var history: MirrorHistory? + var scroll: ScrollRequest? + } + + private struct ScrollRequest { + let payload: MirrorMessage.ScrollPayload + var isReady = false + var sequence: UInt64? + var failure: String? + + var isPending: Bool { sequence == nil && failure == nil } } init( @@ -494,7 +506,9 @@ final class MirrorHost { capabilities: ["vt-v1", "text-v1", "takeover", "refresh"] + (commandService != nil ? ["launch-profile", "launch-shell", "agents-dispatch", "agent-input"] : []) - + (source.supportsBoundedHistory ? ["history"] : []), hostRunID: hostRunID))) + + (source.supportsBoundedHistory ? ["history"] : []) + + (source.supportsRemoteScroll ? ["remote-scroll"] : []) + + (source.supportsViewportText ? ["viewport-text-v1"] : []), hostRunID: hostRunID))) case .command: try handleCommand(message, peer: peer) case .commandReceipt: @@ -516,6 +530,8 @@ final class MirrorHost { !bytes.isEmpty, bytes.count <= MirrorWire.maximumInput else { throw MirrorProtocolError.invalidMessage } try source.write(bytes, to: subscription.paneID) + case .scroll: + try scroll(message, peer: peer) case .history: try sendHistory(message, to: peer) default: throw MirrorProtocolError.invalidMessage @@ -523,6 +539,71 @@ final class MirrorHost { } catch { peer.close(error.localizedDescription) } } + private func scroll(_ message: MirrorMessage, peer: MirrorConnection) throws { + guard var subscription = subscription(for: message, peer: peer), case .scroll(let request) = message else { + throw MirrorProtocolError.invalidMessage + } + if let previous = subscription.scroll, previous.payload.requestID == request.requestID { + guard previous.payload.direction == request.direction else { throw MirrorProtocolError.invalidMessage } + if let sequence = previous.sequence { + peer.send( + .scrollResult(.init(requestID: request.requestID, sequence: sequence, subscriptionID: subscription.id))) + } else if let failure = previous.failure { + peer.send(.failure(.init(error: failure, subscriptionID: subscription.id, requestID: request.requestID))) + } + return + } + guard subscription.scroll?.isPending != true else { + peer.send( + .failure( + .init( + error: "SCROLL_BUSY: Wait for the current scroll to finish.", subscriptionID: subscription.id, + requestID: request.requestID))) + return + } + guard source.supportsRemoteScroll else { + peer.send( + .failure( + .init( + error: "SCROLL_UNAVAILABLE: Host cannot scroll this pane.", subscriptionID: subscription.id, + requestID: request.requestID))) + return + } + subscription.scroll = ScrollRequest(payload: request) + do { + try source.scroll(request.direction, to: subscription.paneID) + } catch { + let failure = "SCROLL_UNAVAILABLE: Host could not scroll this pane." + subscription.scroll?.failure = failure + subscriptions[peer.id] = subscription + peer.send(.failure(.init(error: failure, subscriptionID: subscription.id, requestID: request.requestID))) + return + } + subscriptions[peer.id] = subscription + let clock = clock + Task { @MainActor [weak self, weak peer] in + // TUI mouse input is processed asynchronously. Return a later capture, not + // the screen that was present synchronously when the wheel event was sent. + do { try await clock.sleep(for: .milliseconds(200)) } catch { return } + guard let self, let peer, var current = self.subscriptions[peer.id], current.id == request.subscriptionID, + current.scroll?.payload.requestID == request.requestID, current.scroll?.isPending == true + else { return } + current.scroll?.isReady = true + self.subscriptions[peer.id] = current + self.poll() + // Prefer a changed screen, but a boundary/no-op must also finish. This + // bounded refresh is not a receipt from the application processing input. + do { try await clock.sleep(for: .milliseconds(600)) } catch { return } + guard var latest = self.subscriptions[peer.id], latest.id == request.subscriptionID, + latest.scroll?.payload.requestID == request.requestID, latest.scroll?.isPending == true + else { return } + latest.gate.requestRefresh() + latest.textGate.requestRefresh() + self.subscriptions[peer.id] = latest + self.poll() + } + } + private func handleCommand(_ message: MirrorMessage, peer: MirrorConnection) throws { guard let service = commandService, let request = message.commandRequest else { throw MirrorProtocolError.invalidMessage } @@ -617,7 +698,9 @@ final class MirrorHost { return } var next = Subscription( - paneID: paneID, representation: representation) + paneID: paneID, representation: representation, + includeViewportText: representation == .terminal && message.includeViewportText == true + && source.supportsViewportText) // Prepare and encode before revoking the old lease. Capture failure leaves it intact. let first = try capture(&next) if let first { _ = try MirrorWire.encode(first) } @@ -628,12 +711,13 @@ final class MirrorHost { subscriptions[peer.id] = next subscriberCount = subscriptions.count peer.send(.subscribed(.init(paneID: paneID, subscriptionID: next.id, hostRunID: hostRunID))) - if let first { peer.send(first) } + if let first { send(first, subscription: next, to: peer) } if pollTask == nil { + let clock = clock pollTask = Task { [weak self] in while !Task.isCancelled { - do { try await Task.sleep(for: .milliseconds(200)) } catch { return } + do { try await clock.sleep(for: .milliseconds(200)) } catch { return } self?.poll() } } @@ -646,6 +730,19 @@ final class MirrorHost { } private func capture(_ subscription: inout Subscription) throws -> MirrorMessage? { + if let scroll = subscription.scroll, scroll.isPending, !scroll.isReady { return nil } + do { + let frame = try captureAvailable(&subscription) + subscription.unstableCaptures = 0 + return frame + } catch MirrorPaneSourceError.captureChanged { + subscription.unstableCaptures += 1 + guard subscription.unstableCaptures < 10 else { throw MirrorProtocolError.invalidMessage } + return nil + } + } + + private func captureAvailable(_ subscription: inout Subscription) throws -> MirrorMessage? { if subscription.representation == .text { guard subscription.textGate.outstanding == nil else { return nil } let captured = try source.textSnapshot(subscription.paneID) @@ -660,11 +757,20 @@ final class MirrorHost { sequence: sequence, text: text, subscriptionID: subscription.id)) } guard subscription.gate.outstanding == nil else { return nil } - let frame = try source.snapshot(subscription.paneID) + var frame = try source.snapshot(subscription.paneID) + if !subscription.includeViewportText { frame.viewportText = nil } guard let sequence = subscription.gate.offer(frame) else { return nil } return .frame(.init(frame: frame, sequence: sequence, subscriptionID: subscription.id)) } + private func send(_ message: MirrorMessage, subscription: Subscription, to peer: MirrorConnection) { + if subscription.includeViewportText, case .frame(let payload) = message { + peer.send( + .viewport(.init(text: payload.frame.viewportText, sequence: payload.sequence, subscriptionID: subscription.id))) + } + peer.send(message) + } + private func sendHistory(_ message: MirrorMessage, to peer: MirrorConnection) throws { guard var subscription = subscription(for: message, peer: peer) else { throw MirrorProtocolError.invalidMessage @@ -711,8 +817,17 @@ final class MirrorHost { } do { if let frame = try capture(&subscription) { + var result: MirrorMessage? + if let scroll = subscription.scroll, scroll.isPending, scroll.isReady, let sequence = frame.sequence { + subscription.scroll?.sequence = sequence + result = .scrollResult( + .init(requestID: scroll.payload.requestID, sequence: sequence, subscriptionID: subscription.id)) + } + subscriptions[id] = subscription + send(frame, subscription: subscription, to: peer) + if let result { peer.send(result) } + } else { subscriptions[id] = subscription - peer.send(frame) } } catch { peer.close("Host pane is unavailable: \(error.localizedDescription)") diff --git a/supacode/Features/RemoteMirror/MirrorPaneSource.swift b/supacode/Features/RemoteMirror/MirrorPaneSource.swift index bfd4bed4..1b5de753 100644 --- a/supacode/Features/RemoteMirror/MirrorPaneSource.swift +++ b/supacode/Features/RemoteMirror/MirrorPaneSource.swift @@ -7,6 +7,9 @@ protocol MirrorPaneSource { func write(_ bytes: Data, to id: UUID) throws func textSnapshot(_ id: UUID) throws -> MirrorTextSnapshot func activeText(_ id: UUID) throws -> String + var supportsViewportText: Bool { get } + var supportsRemoteScroll: Bool { get } + func scroll(_ direction: MirrorMessage.ScrollDirection, to id: UUID) throws var supportsBoundedHistory: Bool { get } func boundedRetainedText(_ id: UUID) throws -> MirrorRetainedText } @@ -20,9 +23,19 @@ struct MirrorTextSnapshot { extension MirrorPaneSource { func textSnapshot(_ id: UUID) throws -> MirrorTextSnapshot { .init(text: try activeText(id)) } + var supportsViewportText: Bool { false } + var supportsRemoteScroll: Bool { false } + func scroll(_ direction: MirrorMessage.ScrollDirection, to id: UUID) throws { + throw MirrorProtocolError.invalidMessage + } var supportsBoundedHistory: Bool { false } func boundedRetainedText(_ id: UUID) throws -> MirrorRetainedText { throw MirrorProtocolError.invalidMessage } func activeText(_ id: UUID) throws -> String { throw MirrorProtocolError.invalidMessage } } + +// A resize or a viewport move raced a read-only capture. Retry on the next poll. +enum MirrorPaneSourceError: Error { + case captureChanged +} diff --git a/supacode/Features/RemoteMirror/MirrorProtocol.swift b/supacode/Features/RemoteMirror/MirrorProtocol.swift index 097d604b..796d9c77 100644 --- a/supacode/Features/RemoteMirror/MirrorProtocol.swift +++ b/supacode/Features/RemoteMirror/MirrorProtocol.swift @@ -14,6 +14,7 @@ nonisolated struct MirrorFrame: Codable, Equatable, Sendable { let columns: UInt32 let rows: UInt32 let bytes: Data + var viewportText: String? } nonisolated enum MirrorMessage: Codable, Sendable { @@ -24,6 +25,10 @@ nonisolated enum MirrorMessage: Codable, Sendable { enum Intent: String, Codable, Sendable { case takeover, ifFree } + enum ScrollDirection: String, Codable, Sendable { + case upward = "up" + case downward = "down" + } enum EndReason: String, Codable, Sendable { case takenOver, hostStopped, paneClosed } @@ -46,8 +51,8 @@ nonisolated enum MirrorMessage: Codable, Sendable { } enum Kind: String, Codable { case challenge, authenticate, pair, paired, authenticated, list, panes, subscribe, subscribed, - frame, textFrame, acknowledge, input, history, historyPage, failure, ping, pong, ended, - refresh, command, commandResult, commandReceipt + frame, textFrame, viewport, acknowledge, input, history, historyPage, failure, ping, pong, ended, + refresh, scroll, scrollResult, command, commandResult, commandReceipt } case list case panes(PanesPayload) @@ -61,6 +66,7 @@ nonisolated enum MirrorMessage: Codable, Sendable { var paneID: UUID var representation: Representation var intent: Intent + var includeViewportText: Bool? } case subscribed(SubscribedPayload) struct SubscribedPayload: Codable, Sendable { @@ -83,6 +89,12 @@ nonisolated enum MirrorMessage: Codable, Sendable { var text: String var subscriptionID: UUID } + case viewport(ViewportPayload) + struct ViewportPayload: Codable, Sendable { + var text: String? + var sequence: UInt64 + var subscriptionID: UUID + } case acknowledge(AcknowledgePayload) struct AcknowledgePayload: Codable, Sendable { var sequence: UInt64 @@ -93,6 +105,18 @@ nonisolated enum MirrorMessage: Codable, Sendable { var bytes: Data var subscriptionID: UUID } + case scroll(ScrollPayload) + struct ScrollPayload: Codable, Sendable { + var requestID: UUID + var direction: ScrollDirection + var subscriptionID: UUID + } + case scrollResult(ScrollResultPayload) + struct ScrollResultPayload: Codable, Sendable { + var requestID: UUID + var sequence: UInt64 + var subscriptionID: UUID + } case history(HistoryPayload) struct HistoryPayload: Codable, Sendable { var historyID: UUID? @@ -113,6 +137,7 @@ nonisolated enum MirrorMessage: Codable, Sendable { struct FailurePayload: Codable, Sendable { var error: String var subscriptionID: UUID? + var requestID: UUID? } case ping case pong @@ -151,8 +176,11 @@ nonisolated enum MirrorMessage: Codable, Sendable { case .subscribed: .subscribed case .frame: .frame case .textFrame: .textFrame + case .viewport: .viewport case .acknowledge: .acknowledge case .input: .input + case .scroll: .scroll + case .scrollResult: .scrollResult case .history: .history case .historyPage: .historyPage case .failure: .failure @@ -208,8 +236,11 @@ nonisolated enum MirrorMessage: Codable, Sendable { case .subscribed(let payload): payload.subscriptionID case .frame(let payload): payload.subscriptionID case .textFrame(let payload): payload.subscriptionID + case .viewport(let payload): payload.subscriptionID case .acknowledge(let payload): payload.subscriptionID case .input(let payload): payload.subscriptionID + case .scroll(let payload): payload.subscriptionID + case .scrollResult(let payload): payload.subscriptionID case .history(let payload): payload.subscriptionID case .historyPage(let payload): payload.subscriptionID case .failure(let payload): payload.subscriptionID @@ -229,7 +260,9 @@ nonisolated enum MirrorMessage: Codable, Sendable { switch self { case .frame(let payload): payload.sequence case .textFrame(let payload): payload.sequence + case .viewport(let payload): payload.sequence case .acknowledge(let payload): payload.sequence + case .scrollResult(let payload): payload.sequence default: nil } } @@ -239,12 +272,38 @@ nonisolated enum MirrorMessage: Codable, Sendable { default: nil } } + var viewportText: String? { + switch self { + case .viewport(let payload): payload.text + default: nil + } + } + var includeViewportText: Bool? { + switch self { + case .subscribe(let payload): payload.includeViewportText + default: nil + } + } var bytes: Data? { switch self { case .input(let payload): payload.bytes default: nil } } + var scrollDirection: ScrollDirection? { + switch self { + case .scroll(let payload): payload.direction + default: nil + } + } + var scrollRequestID: UUID? { + switch self { + case .scroll(let payload): payload.requestID + case .scrollResult(let payload): payload.requestID + case .failure(let payload): payload.requestID + default: nil + } + } var historyID: UUID? { switch self { case .history(let payload): payload.historyID diff --git a/supacode/Features/RemoteMirror/MirrorScrollState.swift b/supacode/Features/RemoteMirror/MirrorScrollState.swift new file mode 100644 index 00000000..503ed421 --- /dev/null +++ b/supacode/Features/RemoteMirror/MirrorScrollState.swift @@ -0,0 +1,73 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class MirrorScrollState { + private(set) var requestID: UUID? + private(set) var error: String? + var isLoading: Bool { requestID != nil } + @ObservationIgnored private var presentedSequence: UInt64 = 0 + @ObservationIgnored private var baseline: UInt64 = 0 + @ObservationIgnored private var resultSequence: UInt64? + @ObservationIgnored private var timeout: Task? + @ObservationIgnored private let clock: any Clock + + init(clock: any Clock = ContinuousClock()) { self.clock = clock } + + func begin() -> UUID? { + guard requestID == nil else { return nil } + let id = UUID() + requestID = id + baseline = presentedSequence + resultSequence = nil + error = nil + let clock = clock + timeout = Task { [weak self] in + do { try await clock.sleep(for: .seconds(5)) } catch { return } + guard let self, self.requestID == id else { return } + self.fail( + String(localized: "Scrolling timed out. The Host may have moved; check the live view before trying again.")) + } + return id + } + + func receiveResult(requestID: UUID, sequence: UInt64) { + guard self.requestID == requestID else { return } + guard sequence > baseline else { + fail(String(localized: "Host returned an invalid scroll result.")) + return + } + resultSequence = sequence + finishIfPresented() + } + + func didPresent(sequence: UInt64) { + presentedSequence = max(presentedSequence, sequence) + finishIfPresented() + } + + func fail(_ message: String) { + cancel() + error = message + } + + func cancel() { + timeout?.cancel() + timeout = nil + requestID = nil + resultSequence = nil + } + + func reset() { + cancel() + presentedSequence = 0 + baseline = 0 + error = nil + } + + private func finishIfPresented() { + guard let resultSequence, presentedSequence >= resultSequence else { return } + cancel() + } +} diff --git a/supacode/Features/RemoteMirror/MirrorTerminalViewport.swift b/supacode/Features/RemoteMirror/MirrorTerminalViewport.swift index 92521ed5..c8ff4b18 100644 --- a/supacode/Features/RemoteMirror/MirrorTerminalViewport.swift +++ b/supacode/Features/RemoteMirror/MirrorTerminalViewport.swift @@ -1,17 +1,25 @@ import AppKit +import GhosttyKit import SwiftUI struct MirrorTerminalViewport: NSViewRepresentable { let surface: GhosttySurfaceView let displaySize: CGSize var fitsWindow = true + var viewportText: String? + var onRemoteScroll: ((MirrorMessage.ScrollDirection) -> Void)? func makeNSView(context: Context) -> MirrorTerminalScrollView { - MirrorTerminalScrollView(surface: surface, displaySize: displaySize, fitsWindow: fitsWindow) + let view = MirrorTerminalScrollView(surface: surface, displaySize: displaySize, fitsWindow: fitsWindow) + view.onRemoteScroll = onRemoteScroll + view.updateViewportText(viewportText) + return view } func updateNSView(_ view: MirrorTerminalScrollView, context: Context) { view.update(surface: surface, displaySize: displaySize, fitsWindow: fitsWindow) + view.onRemoteScroll = onRemoteScroll + view.updateViewportText(viewportText) } } @@ -20,11 +28,41 @@ final class MirrorTerminalScrollView: NSScrollView { override var isFlipped: Bool { true } } + private final class ViewportTextView: NSTextView { + weak var terminal: GhosttySurfaceView? + + override func scrollWheel(with event: NSEvent) { + enclosingScrollView?.scrollWheel(with: event) + } + + override func keyDown(with event: NSEvent) { + if event.modifierFlags.contains(.command) { + super.keyDown(with: event) + } else { + if let terminal { window?.makeFirstResponder(terminal) } + terminal?.keyDown(with: event) + } + } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + guard window?.firstResponder === self else { return super.performKeyEquivalent(with: event) } + if event.modifierFlags.contains(.command), ["a", "c"].contains(event.charactersIgnoringModifiers ?? "") { + return super.performKeyEquivalent(with: event) + } + guard let terminal else { return super.performKeyEquivalent(with: event) } + window?.makeFirstResponder(terminal) + return terminal.performKeyEquivalent(with: event) + } + } + private var surface: GhosttySurfaceView private let document = Document() + private let viewport = ViewportTextView(frame: .zero) private var fitsWindow: Bool private var resetOrigin = true private var isLayingOut = false + var onRemoteScroll: ((MirrorMessage.ScrollDirection) -> Void)? + private var scrollGesture = MirrorScrollGesture() var displaySize: CGSize { didSet { if displaySize != oldValue { needsLayout = true } } } @@ -45,13 +83,29 @@ final class MirrorTerminalScrollView: NSScrollView { // The surface retains its Host grid; magnification changes only presentation. documentView = document document.addSubview(surface) + viewport.isEditable = false + viewport.isSelectable = true + viewport.isRichText = false + viewport.isHidden = true + viewport.drawsBackground = true + viewport.backgroundColor = .textBackgroundColor + viewport.textColor = .textColor + viewport.textContainerInset = .zero + viewport.textContainer?.lineFragmentPadding = 0 + viewport.textContainer?.widthTracksTextView = false + viewport.textContainer?.containerSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + viewport.terminal = surface + viewport.setAccessibilityIdentifier("remote-mirror-viewport-text") + document.addSubview(viewport) } func update(surface: GhosttySurfaceView, displaySize: CGSize, fitsWindow: Bool = true) { if self.surface !== surface { self.surface.removeFromSuperview() self.surface = surface - document.addSubview(surface) + document.addSubview(surface, positioned: .below, relativeTo: viewport) + viewport.terminal = surface resetOrigin = true needsLayout = true } @@ -63,10 +117,34 @@ final class MirrorTerminalScrollView: NSScrollView { self.displaySize = displaySize } + func updateViewportText(_ text: String?) { + let wasHidden = viewport.isHidden + viewport.isHidden = text == nil + if let text, viewport.string != text { viewport.string = text } + if text == nil, window?.firstResponder === viewport { window?.makeFirstResponder(surface) } + if wasHidden != viewport.isHidden { resetOrigin = true } + needsLayout = true + } + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + override func scrollWheel(with event: NSEvent) { + guard let onRemoteScroll, !event.modifierFlags.contains(.option), + abs(event.scrollingDeltaY) >= abs(event.scrollingDeltaX) + else { + super.scrollWheel(with: event) + return + } + if let direction = scrollGesture.consume( + delta: event.scrollingDeltaY, precise: event.hasPreciseScrollingDeltas, + phase: event.phase, momentum: event.momentumPhase, timestamp: event.timestamp) + { + onRemoteScroll(direction) + } + } + override func layout() { guard !isLayingOut else { return } isLayingOut = true @@ -76,6 +154,8 @@ final class MirrorTerminalScrollView: NSScrollView { document.setFrameSize(displaySize) surface.setFrameSize(displaySize) surface.updateSurfaceSize() + viewport.setFrameSize(displaySize) + updateViewportFont() let scale = fitsWindow ? min(1, contentSize.width / max(1, displaySize.width), contentSize.height / max(1, displaySize.height)) @@ -91,4 +171,56 @@ final class MirrorTerminalScrollView: NSScrollView { reflectScrolledClipView(contentView) resetOrigin = false } + + private func updateViewportFont() { + guard !viewport.isHidden, let terminal = surface.surface else { return } + let size = ghostty_surface_size(terminal) + let scale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2 + let paragraph = NSMutableParagraphStyle() + paragraph.minimumLineHeight = CGFloat(size.cell_height_px) / scale + paragraph.maximumLineHeight = paragraph.minimumLineHeight + var attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: NSColor.textColor, .paragraphStyle: paragraph, + ] + if let rawFont = ghostty_surface_quicklook_font(terminal) { + let font = Unmanaged.fromOpaque(rawFont) + attributes[.font] = font.takeUnretainedValue() + viewport.textStorage?.setAttributes( + attributes, range: NSRange(location: 0, length: viewport.textStorage?.length ?? 0)) + font.release() + } else { + viewport.font = .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) + } + } +} + +struct MirrorScrollGesture { + private var accumulated: CGFloat = 0 + private var sentForGesture = false + private var lastWheelTime: TimeInterval = -.infinity + + mutating func consume( + delta: CGFloat, precise: Bool, phase: NSEvent.Phase, momentum: NSEvent.Phase, timestamp: TimeInterval + ) -> MirrorMessage.ScrollDirection? { + guard momentum.isEmpty else { return nil } + if phase.contains(.began) { + accumulated = 0 + sentForGesture = false + } + if phase.contains(.ended) || phase.contains(.cancelled) { + accumulated = 0 + sentForGesture = false + return nil + } + guard delta != 0, phase.isEmpty || !sentForGesture else { return nil } + if phase.isEmpty, timestamp - lastWheelTime < 0.3 { return nil } + if accumulated * delta < 0 { accumulated = 0 } + accumulated += delta + guard abs(accumulated) >= (precise ? 30 : 1) else { return nil } + let direction: MirrorMessage.ScrollDirection = accumulated > 0 ? .upward : .downward + accumulated = 0 + sentForGesture = true + lastWheelTime = timestamp + return direction + } } diff --git a/supacode/Features/RemoteMirror/MirrorViewportState.swift b/supacode/Features/RemoteMirror/MirrorViewportState.swift new file mode 100644 index 00000000..c76cc3f0 --- /dev/null +++ b/supacode/Features/RemoteMirror/MirrorViewportState.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Viewport text travels beside the VT frame and becomes visible with that frame's acknowledgement. +struct MirrorViewportState { + private(set) var text: String? + private var pending: MirrorMessage.ViewportPayload? + private var receivedSequence: UInt64? + private var presentedSequence: UInt64 = 0 + + mutating func stage(_ payload: MirrorMessage.ViewportPayload) throws { + guard pending == nil, payload.sequence > presentedSequence, + (payload.text?.utf8.count ?? 0) <= MirrorWire.maximumPayload / 8 + else { throw MirrorProtocolError.invalidMessage } + pending = payload + } + + mutating func receiveFrame(sequence: UInt64) throws { + guard pending?.sequence == sequence, receivedSequence == nil else { + throw MirrorProtocolError.invalidMessage + } + receivedSequence = sequence + } + + mutating func didPresent(sequence: UInt64) throws { + guard let pending, pending.sequence == sequence, receivedSequence == sequence else { + throw MirrorProtocolError.invalidMessage + } + text = pending.text + presentedSequence = sequence + self.pending = nil + receivedSequence = nil + } +} diff --git a/supacode/Features/RemoteMirror/RemoteMirrorPaneView.swift b/supacode/Features/RemoteMirror/RemoteMirrorPaneView.swift index 5bd30984..df3b23a9 100644 --- a/supacode/Features/RemoteMirror/RemoteMirrorPaneView.swift +++ b/supacode/Features/RemoteMirror/RemoteMirrorPaneView.swift @@ -58,11 +58,16 @@ struct RemoteMirrorPaneView: View { if let error = client.error { Text(error).font(.callout).foregroundStyle(.secondary).padding(10).textSelection(.enabled) } + if !client.showsHistory { scrollControls } ZStack { if let view = client.replica.view { - MirrorTerminalViewport(surface: view, displaySize: client.replica.displaySize, fitsWindow: fitsWindow) - .opacity(client.showsHistory ? 0 : 1) - .allowsHitTesting(!client.showsHistory) + MirrorTerminalViewport( + surface: view, displaySize: client.replica.displaySize, fitsWindow: fitsWindow, + viewportText: client.viewportState.text, + onRemoteScroll: client.supportsRemoteScroll && !client.showsHistory ? { client.scroll($0) } : nil + ) + .opacity(client.showsHistory ? 0 : 1) + .allowsHitTesting(!client.showsHistory) } else { ProgressView("Opening mirror…") } @@ -156,6 +161,31 @@ struct RemoteMirrorPaneView: View { private var endpoint: String { "\(client.address):\(String(client.port))" } + private var scrollControls: some View { + HStack(spacing: 12) { + if client.scrollState.isLoading { + ProgressView("Scrolling…").controlSize(.small) + } else if let error = client.scrollState.error { + Text(error).font(.caption).foregroundStyle(.secondary).textSelection(.enabled) + } else if client.viewportState.text != nil { + Text("Host scrollback · Plain text").font(.caption).foregroundStyle(.secondary) + } + Spacer(minLength: 0) + Button("Scroll Up", systemImage: "chevron.up") { client.scroll(.upward) } + .help("Scroll the Host terminal up; distance depends on the running program") + .accessibilityIdentifier("remote-mirror-scroll-up") + Button("Scroll Down", systemImage: "chevron.down") { client.scroll(.downward) } + .help("Scroll the Host terminal down; distance depends on the running program") + .accessibilityIdentifier("remote-mirror-scroll-down") + } + .disabled(!client.canScroll) + .padding(.horizontal).padding(.vertical, 6) + .help( + client.supportsRemoteScroll + ? "Scroll controls the Host terminal. Hold Option while scrolling to pan the local display." + : "Update Host to enable remote scrolling.") + } + private var history: some View { VStack(alignment: .leading) { HStack { diff --git a/supacodeTests/RemoteMirror/MirrorHostTests.swift b/supacodeTests/RemoteMirror/MirrorHostTests.swift index 45d20237..18b2c066 100644 --- a/supacodeTests/RemoteMirror/MirrorHostTests.swift +++ b/supacodeTests/RemoteMirror/MirrorHostTests.swift @@ -452,6 +452,252 @@ struct MirrorHostTests { #expect(issues == 0 && deliveries == 0) } + @Test(.timeLimit(.minutes(1)), arguments: [MirrorMessage.Representation.terminal, .text]) + func remoteScrollForcesFreshFrameAndDeduplicatesRequests(representation: MirrorMessage.Representation) async throws { + let source = Source() + let clock = TestClock() + let host = MirrorHost( + source: source, enabled: true, clock: clock, loadIdentity: { Self.identity }, saveIdentity: { _ in }) + host.address = "127.0.0.1" + host.port = String(try MirrorTestPort.unusedPort()) + defer { host.stop() } + try await MirrorTestPort.startHost(host) + let peer = try await Peer(port: UInt16(host.port)!, key: host.pairingKey) + defer { peer.connection.close() } + var messages = peer.messages.makeAsyncIterator() + peer.connection.send(.list) + #expect(await messages.next()?.capabilities?.contains("remote-scroll") == true) + peer.connection.send(.subscribe(.init(paneID: source.id, representation: representation, intent: .ifFree))) + let lease = try #require(await messages.next()?.subscriptionID) + let initial = try #require(await messages.next()) + let initialSequence = try #require(initial.sequence) + let request = MirrorMessage.ScrollPayload(requestID: UUID(), direction: .upward, subscriptionID: lease) + peer.connection.send(.scroll(request)) + peer.connection.send(.scroll(request)) + let busyID = UUID() + peer.connection.send(.scroll(.init(requestID: busyID, direction: .downward, subscriptionID: lease))) + let busy = try #require(await messages.next()) + #expect(busy.error?.hasPrefix("SCROLL_BUSY:") == true) + #expect(busy.scrollRequestID == busyID) + #expect(busy.subscriptionID == lease) + #expect(source.scrolls == [.upward]) + await clock.advance(by: .milliseconds(200)) + // Scroll completion must respect the original outstanding frame's ACK. + peer.connection.send(.list) + #expect(await messages.next()?.kind == .panes) + peer.connection.send(.acknowledge(.init(sequence: initialSequence, subscriptionID: lease))) + peer.connection.send(.list) + #expect(await messages.next()?.kind == .panes) + await clock.advance(by: .milliseconds(600)) + let changed = try #require(await messages.next()) + let result = try #require(await messages.next()) + #expect(changed.kind == initial.kind) + #expect(changed.sequence == initialSequence + 1) + #expect(changed.frame == initial.frame) + #expect(changed.text == initial.text) + #expect(result.kind == .scrollResult) + #expect(result.sequence == changed.sequence) + #expect(result.scrollRequestID == request.requestID) + #expect(result.subscriptionID == lease) + peer.connection.send(.scroll(request)) + let replay = try #require(await messages.next()) + #expect(replay.kind == .scrollResult) + #expect(replay.sequence == result.sequence) + #expect(source.scrolls == [.upward]) + peer.connection.send(.acknowledge(.init(sequence: try #require(changed.sequence), subscriptionID: lease))) + let downID = UUID() + peer.connection.send(.scroll(.init(requestID: downID, direction: .downward, subscriptionID: lease))) + peer.connection.send(.list) + #expect(await messages.next()?.kind == .panes) + await clock.advance(by: .milliseconds(800)) + let downFrame = try #require(await messages.next()) + let downResult = try #require(await messages.next()) + #expect(downFrame.sequence == initialSequence + 2) + #expect(downResult.scrollRequestID == downID) + #expect(downResult.sequence == downFrame.sequence) + #expect(source.scrolls == [.upward, .downward]) + #expect(source.input.isEmpty) + } + + @Test(.timeLimit(.minutes(1)), arguments: [false, true]) + func unavailableScrollReturnsCorrelatedFailureWithoutClosingConnection(fails: Bool) async throws { + let source = Source() + source.supportsRemoteScroll = fails + source.scrollUnavailable = fails + let host = MirrorHost(source: source, enabled: true, loadIdentity: { Self.identity }, saveIdentity: { _ in }) + host.address = "127.0.0.1" + host.port = String(try MirrorTestPort.unusedPort()) + defer { host.stop() } + try await MirrorTestPort.startHost(host) + let peer = try await Peer(port: UInt16(host.port)!, key: host.pairingKey) + defer { peer.connection.close() } + var messages = peer.messages.makeAsyncIterator() + peer.connection.send(.list) + #expect(await messages.next()?.capabilities?.contains("remote-scroll") == fails) + peer.connection.send(.subscribe(.init(paneID: source.id, representation: .text, intent: .ifFree))) + let lease = try #require(await messages.next()?.subscriptionID) + #expect(await messages.next()?.kind == .textFrame) + let request = MirrorMessage.ScrollPayload(requestID: UUID(), direction: .upward, subscriptionID: lease) + for _ in 0..<2 { + peer.connection.send(.scroll(request)) + let failure = try #require(await messages.next()) + #expect(failure.error?.hasPrefix("SCROLL_UNAVAILABLE:") == true) + #expect(failure.scrollRequestID == request.requestID) + #expect(failure.subscriptionID == lease) + } + #expect(source.scrolls.count == (fails ? 1 : 0)) + peer.connection.send(.history(.init(historyID: nil, offset: nil, subscriptionID: lease))) + #expect(await messages.next()?.lines == ["earlier", "now"]) + #expect(source.input.isEmpty) + } + + @Test(.timeLimit(.minutes(1))) func scrollRejectsStaleLeaseBeforeSendingInput() async throws { + let source = Source() + let host = MirrorHost(source: source, enabled: true, loadIdentity: { Self.identity }, saveIdentity: { _ in }) + host.address = "127.0.0.1" + host.port = String(try MirrorTestPort.unusedPort()) + defer { host.stop() } + try await MirrorTestPort.startHost(host) + let peer = try await Peer(port: UInt16(host.port)!, key: host.pairingKey) + defer { peer.connection.close() } + var messages = peer.messages.makeAsyncIterator() + peer.connection.send(.subscribe(.init(paneID: source.id, representation: .text, intent: .ifFree))) + #expect(await messages.next()?.kind == .subscribed) + #expect(await messages.next()?.kind == .textFrame) + peer.connection.send(.scroll(.init(requestID: UUID(), direction: .upward, subscriptionID: UUID()))) + #expect(await messages.next() == nil) + #expect(source.scrolls.isEmpty) + #expect(source.input.isEmpty) + } + + @Test(.timeLimit(.minutes(1)), arguments: [false, true]) + func viewportMetadataRequiresTerminalOptIn(optedIn: Bool) async throws { + let source = Source() + source.viewportText = "earlier" + let clock = TestClock() + let host = MirrorHost( + source: source, enabled: true, clock: clock, loadIdentity: { Self.identity }, saveIdentity: { _ in }) + host.address = "127.0.0.1" + host.port = String(try MirrorTestPort.unusedPort()) + defer { host.stop() } + try await MirrorTestPort.startHost(host) + let peer = try await Peer(port: UInt16(host.port)!, key: host.pairingKey) + defer { peer.connection.close() } + var messages = peer.messages.makeAsyncIterator() + peer.connection.send(.list) + #expect(await messages.next()?.capabilities?.contains("viewport-text-v1") == true) + peer.connection.send( + .subscribe(.init(paneID: source.id, representation: .terminal, intent: .ifFree, includeViewportText: optedIn))) + let lease = try #require(await messages.next()?.subscriptionID) + if optedIn { + let viewport = try #require(await messages.next()) + #expect(viewport.kind == .viewport) + #expect(viewport.viewportText == "earlier") + #expect(viewport.sequence == 1) + #expect(viewport.subscriptionID == lease) + } + #expect(await messages.next()?.kind == .frame) + peer.connection.send(.acknowledge(.init(sequence: 1, subscriptionID: lease))) + peer.connection.send(.list) + #expect(await messages.next()?.kind == .panes) + source.viewportText = "" + await clock.advance(by: .milliseconds(200)) + if optedIn { + #expect(await messages.next()?.viewportText == "") + #expect(await messages.next()?.sequence == 2) + peer.connection.send(.acknowledge(.init(sequence: 2, subscriptionID: lease))) + peer.connection.send(.list) + #expect(await messages.next()?.kind == .panes) + source.viewportText = nil + await clock.advance(by: .milliseconds(200)) + let clear = try #require(await messages.next()) + #expect(clear.kind == .viewport) + #expect(clear.viewportText == nil) + #expect(clear.sequence == 3) + #expect(await messages.next()?.sequence == 3) + } + // An old terminal client never receives the additive control, including + // when only native scrollback changes and the active VT bytes stay equal. + peer.connection.send(.list) + #expect(await messages.next()?.kind == .panes) + } + + @Test(.timeLimit(.minutes(1))) func textSubscriptionNeverReceivesViewportMetadata() async throws { + let source = Source() + source.viewportText = "earlier" + let host = MirrorHost(source: source, enabled: true, loadIdentity: { Self.identity }, saveIdentity: { _ in }) + host.address = "127.0.0.1" + host.port = String(try MirrorTestPort.unusedPort()) + defer { host.stop() } + try await MirrorTestPort.startHost(host) + let peer = try await Peer(port: UInt16(host.port)!, key: host.pairingKey) + defer { peer.connection.close() } + var messages = peer.messages.makeAsyncIterator() + peer.connection.send( + .subscribe(.init(paneID: source.id, representation: .text, intent: .ifFree, includeViewportText: true))) + #expect(await messages.next()?.kind == .subscribed) + #expect(await messages.next()?.kind == .textFrame) + peer.connection.send(.list) + #expect(await messages.next()?.kind == .panes) + } + + @Test(.timeLimit(.minutes(1)), arguments: [1, 20]) + func inconsistentCaptureRetriesAreBounded(failures: Int) async throws { + let source = Source() + source.unstableCaptures = failures + let clock = TestClock() + let host = MirrorHost( + source: source, enabled: true, clock: clock, loadIdentity: { Self.identity }, saveIdentity: { _ in }) + host.address = "127.0.0.1" + host.port = String(try MirrorTestPort.unusedPort()) + defer { host.stop() } + try await MirrorTestPort.startHost(host) + let peer = try await Peer(port: UInt16(host.port)!, key: host.pairingKey) + defer { peer.connection.close() } + var messages = peer.messages.makeAsyncIterator() + peer.connection.send(.subscribe(.init(paneID: source.id, representation: .terminal, intent: .ifFree))) + #expect(await messages.next()?.kind == .subscribed) + await clock.advance(by: .seconds(2)) + if failures == 1 { + #expect(await messages.next()?.kind == .frame) + } else { + #expect(await messages.next() == nil) + #expect(source.unstableCaptures == failures - 10) + } + } + + @Test(.timeLimit(.minutes(1))) func failedFrameSendDoesNotResurrectClosedLease() async throws { + let source = Source() + let clock = TestClock() + let host = MirrorHost( + source: source, enabled: true, clock: clock, loadIdentity: { Self.identity }, saveIdentity: { _ in }) + host.address = "127.0.0.1" + host.port = String(try MirrorTestPort.unusedPort()) + defer { host.stop() } + try await MirrorTestPort.startHost(host) + let peer = try await Peer(port: UInt16(host.port)!, key: host.pairingKey) + defer { peer.connection.close() } + var messages = peer.messages.makeAsyncIterator() + peer.connection.send(.subscribe(.init(paneID: source.id, representation: .terminal, intent: .ifFree))) + let lease = try #require(await messages.next()?.subscriptionID) + #expect(await messages.next()?.sequence == 1) + peer.connection.send(.acknowledge(.init(sequence: 1, subscriptionID: lease))) + peer.connection.send(.list) + #expect(await messages.next()?.kind == .panes) + source.frameBytes = Data(repeating: 65, count: MirrorWire.maximumPayload) + await clock.advance(by: .milliseconds(200)) + #expect(await messages.next() == nil) + #expect(host.subscriberCount == 0) + source.frameBytes = Data("thinking".utf8) + let replacement = try await Peer(port: UInt16(host.port)!, key: host.pairingKey) + defer { replacement.connection.close() } + var replacementMessages = replacement.messages.makeAsyncIterator() + replacement.connection.send(.subscribe(.init(paneID: source.id, representation: .terminal, intent: .ifFree))) + #expect(await replacementMessages.next()?.kind == .subscribed) + #expect(await replacementMessages.next()?.kind == .frame) + #expect(host.subscriberCount == 1) + } + @MainActor private final class Source: MirrorPaneSource { let id = UUID() let otherID = UUID() @@ -459,7 +705,19 @@ struct MirrorHostTests { var reads = 0 var input = Data() var text = "thinking" + var frameBytes = Data("thinking".utf8) var textUnavailable = false + var supportsViewportText = true + var viewportText: String? + var unstableCaptures = 0 + var supportsRemoteScroll = true + var scrollUnavailable = false + var scrolls: [MirrorMessage.ScrollDirection] = [] + func scroll(_ direction: MirrorMessage.ScrollDirection, to id: UUID) throws { + #expect(id == self.id) + scrolls.append(direction) + if scrollUnavailable { throw MirrorProtocolError.invalidMessage } + } func activeText(_ id: UUID) throws -> String { if textUnavailable { throw MirrorProtocolError.invalidMessage } reads += 1 @@ -472,7 +730,11 @@ struct MirrorHostTests { } func snapshot(_ id: UUID) throws -> MirrorFrame { reads += 1 - return MirrorFrame(columns: 80, rows: 24, bytes: Data("thinking".utf8)) + if unstableCaptures > 0 { + unstableCaptures -= 1 + throw MirrorPaneSourceError.captureChanged + } + return MirrorFrame(columns: 80, rows: 24, bytes: frameBytes, viewportText: viewportText) } func write(_ bytes: Data, to id: UUID) throws { input.append(bytes) } var supportsBoundedHistory: Bool { true } diff --git a/supacodeTests/RemoteMirror/MirrorProtocolTests.swift b/supacodeTests/RemoteMirror/MirrorProtocolTests.swift index 4bb04e40..61d0787a 100644 --- a/supacodeTests/RemoteMirror/MirrorProtocolTests.swift +++ b/supacodeTests/RemoteMirror/MirrorProtocolTests.swift @@ -4,6 +4,68 @@ import Testing @testable import supacode struct MirrorProtocolTests { + @Test func scrollMessagesKeepRequestAndLeaseAcrossJSONWire() throws { + let requestID = UUID() + let lease = UUID() + let messages: [MirrorMessage] = [ + .scroll(.init(requestID: requestID, direction: .upward, subscriptionID: lease)), + .scroll(.init(requestID: requestID, direction: .downward, subscriptionID: lease)), + .scrollResult(.init(requestID: requestID, sequence: 42, subscriptionID: lease)), + .failure(.init(error: "SCROLL_BUSY", subscriptionID: lease, requestID: requestID)), + ] + for message in messages { + let wire = try MirrorWire.encode(message) + #expect(wire[4] == 0) + let decoded = try MirrorWire.decode(wire.dropFirst(4)) + #expect(decoded.kind == message.kind) + #expect(decoded.scrollRequestID == requestID) + #expect(decoded.subscriptionID == lease) + #expect(decoded.scrollDirection == message.scrollDirection) + #expect(decoded.sequence == message.sequence) + } + let legacyFailure = try MirrorWire.decode(Data([0]) + Data(#"{"failure":{"_0":{"error":"old host"}}}"#.utf8)) + #expect(legacyFailure.error == "old host") + #expect(legacyFailure.scrollRequestID == nil) + let invalid = """ + {"scroll":{"_0":{"requestID":"\(requestID)","direction":"sideways","subscriptionID":"\(lease)"}}} + """ + #expect(throws: (any Error).self) { + try MirrorWire.decode(Data([0]) + Data(invalid.utf8)) + } + } + + @Test func viewportControlPreservesNilAndEmptyWithoutChangingBinaryFrames() throws { + let lease = UUID() + for text in [nil, "", "earlier\n屏幕"] as [String?] { + let message = MirrorMessage.viewport(.init(text: text, sequence: 9, subscriptionID: lease)) + let encoded = try MirrorWire.encode(message) + #expect(encoded[4] == 0) + let decoded = try MirrorWire.decode(encoded.dropFirst(4)) + #expect(decoded.kind == .viewport) + #expect(decoded.viewportText == text) + #expect(decoded.sequence == 9) + #expect(decoded.subscriptionID == lease) + } + let frame = MirrorFrame(columns: 80, rows: 24, bytes: Data("styled active screen".utf8)) + var overlay = frame + overlay.viewportText = "earlier" + #expect( + try MirrorWire.encode(.frame(.init(frame: frame, sequence: 9, subscriptionID: lease))) + == MirrorWire.encode(.frame(.init(frame: overlay, sequence: 9, subscriptionID: lease)))) + let legacy = MirrorMessage.subscribe(.init(paneID: UUID(), representation: .terminal, intent: .ifFree)) + let decoded = try MirrorWire.decode(MirrorWire.encode(legacy).dropFirst(4)) + #expect(decoded.includeViewportText == nil) + var gate = MirrorFrameGate() + #expect(gate.offer(frame) == 1) + try gate.acknowledge(1) + #expect(gate.offer(overlay) == 2) + try gate.acknowledge(2) + overlay.viewportText = "" + #expect(gate.offer(overlay) == 3) + try gate.acknowledge(3) + #expect(gate.offer(frame) == 4) + } + @Test func refreshPreservesBackpressureAndSequence() throws { var gate = MirrorTextFrameGate() #expect(gate.offer("unchanged") == 1) diff --git a/supacodeTests/RemoteMirror/MirrorScrollStateTests.swift b/supacodeTests/RemoteMirror/MirrorScrollStateTests.swift new file mode 100644 index 00000000..a320bc9a --- /dev/null +++ b/supacodeTests/RemoteMirror/MirrorScrollStateTests.swift @@ -0,0 +1,90 @@ +import AppKit +import Clocks +import Testing + +@testable import supacode + +@MainActor +struct MirrorScrollStateTests { + @Test func waitsForCorrelatedFramePresentationAndLimitsPendingInput() throws { + let state = MirrorScrollState() + state.didPresent(sequence: 4) + let request = try #require(state.begin()) + #expect(state.begin() == nil) + state.receiveResult(requestID: UUID(), sequence: 5) + #expect(state.isLoading) + state.receiveResult(requestID: request, sequence: 6) + state.didPresent(sequence: 5) + #expect(state.isLoading) + state.didPresent(sequence: 6) + #expect(!state.isLoading) + #expect(state.error == nil) + } + + @Test func resultCanArriveAfterItsFrameWasPresented() throws { + let state = MirrorScrollState() + let request = try #require(state.begin()) + state.didPresent(sequence: 1) + #expect(state.isLoading) + state.receiveResult(requestID: request, sequence: 1) + #expect(!state.isLoading) + } + + @Test func staleFrameCannotConfirmNewInput() throws { + let state = MirrorScrollState() + state.didPresent(sequence: 9) + let request = try #require(state.begin()) + state.receiveResult(requestID: request, sequence: 9) + #expect(!state.isLoading) + #expect(state.error != nil) + } + + @Test func timeoutDoesNotRetryAndLateReplyCannotCompleteNextRequest() async throws { + let clock = TestClock() + let state = MirrorScrollState(clock: clock) + let previous = try #require(state.begin()) + await clock.advance(by: .seconds(5)) + #expect(!state.isLoading) + #expect(state.error?.contains("timed out") == true) + let next = try #require(state.begin()) + state.receiveResult(requestID: previous, sequence: 1) + state.didPresent(sequence: 1) + #expect(state.requestID == next) + state.cancel() + await clock.run() + #expect(!state.isLoading) + } + + @Test func resetAllowsNewSubscriptionSequenceAndCancelsTimeout() async throws { + let clock = TestClock() + let state = MirrorScrollState(clock: clock) + state.didPresent(sequence: 100) + _ = state.begin() + state.reset() + await clock.run() + let next = try #require(state.begin()) + state.didPresent(sequence: 1) + state.receiveResult(requestID: next, sequence: 1) + #expect(!state.isLoading) + #expect(state.error == nil) + } +} + +struct MirrorScrollGestureTests { + @Test func onePagePerTrackpadGestureAndNoMomentumRequests() { + var gesture = MirrorScrollGesture() + #expect(gesture.consume(delta: 10, precise: true, phase: .began, momentum: [], timestamp: 1) == nil) + #expect(gesture.consume(delta: 25, precise: true, phase: .changed, momentum: [], timestamp: 2) == .upward) + #expect(gesture.consume(delta: 50, precise: true, phase: .changed, momentum: [], timestamp: 3) == nil) + #expect(gesture.consume(delta: 0, precise: true, phase: .ended, momentum: [], timestamp: 4) == nil) + #expect(gesture.consume(delta: 50, precise: true, phase: [], momentum: .changed, timestamp: 5) == nil) + #expect(gesture.consume(delta: -35, precise: true, phase: .began, momentum: [], timestamp: 6) == .downward) + } + + @Test func discreteWheelIsRateLimitedWithoutAccumulatingDelayedPages() { + var gesture = MirrorScrollGesture() + #expect(gesture.consume(delta: 1, precise: false, phase: [], momentum: [], timestamp: 1) == .upward) + #expect(gesture.consume(delta: 10, precise: false, phase: [], momentum: [], timestamp: 1.1) == nil) + #expect(gesture.consume(delta: -1, precise: false, phase: [], momentum: [], timestamp: 1.5) == .downward) + } +} diff --git a/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift b/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift index 6a52ec90..998d103c 100644 --- a/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift +++ b/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift @@ -14,6 +14,195 @@ import Testing @Suite(.serialized) @MainActor struct MirrorTerminalIntegrationTests { + @Test(.timeLimit(.minutes(1))) func remoteScrollMovesRealViewportAndPreservesFrozenHistory() async throws { + let fixture = try Fixture() + defer { fixture.close() } + try await fixture.wait("Host program ready") { fixture.hostText.contains("READY") } + try await fixture.startHost() + let client = try await fixture.connect() + try fixture.send("history") + try await fixture.waitForMirror(client, containing: "HISTORY:450") + try #require(client.supportsRemoteScroll) + client.loadHistory(refresh: true) + try await fixture.wait("Frozen history") { !client.isLoadingHistory } + let history = client.historyLines + let offset = client.historyOffset + client.showsHistory = false + let replica = try #require(client.replica.view) + let window = try #require(replica.window) + let store = RemoteMirrorStore(manager: fixture.manager, runtime: fixture.runtime) + let hosting = NSHostingView(rootView: RemoteMirrorPaneView(client: client).environment(store)) + let container = NSView(frame: try #require(window.contentView).bounds) + hosting.frame = container.bounds + hosting.autoresizingMask = [.width, .height] + container.addSubview(hosting) + window.contentView = container + func textView(_ view: NSView) -> NSTextView? { + if let text = view as? NSTextView, text.accessibilityIdentifier() == "remote-mirror-viewport-text" { return text } + return view.subviews.lazy.compactMap { textView($0) }.first + } + try await fixture.wait("Mounted viewport overlay") { textView(hosting) != nil } + let overlay = try #require(textView(hosting)) + #expect(overlay.isHidden) + client.scroll(.upward) + #expect(client.scrollState.isLoading) + try await fixture.wait("Earlier remote viewport") { + !client.scrollState.isLoading && client.viewportState.text != nil + } + #expect(client.scrollState.error == nil) + let earlier = try #require(client.viewportState.text) + #expect(earlier.contains("HISTORY:")) + #expect(!earlier.contains("HISTORY:450")) + // Original Ghostty snapshots remain the styled active screen. The paired + // overlay is what presents native scrollback on Mac, without replaying it. + #expect(fixture.replicaText(client).contains("HISTORY:450")) + #expect(earlier == (try fixture.source.snapshot(fixture.hostView.id)).viewportText) + #expect( + earlier.trimmingCharacters(in: .newlines) + == (try fixture.source.textSnapshot(fixture.hostView.id)).text.trimmingCharacters(in: .newlines)) + try await fixture.wait("Observable pane presents viewport overlay") { + !overlay.isHidden && overlay.string == earlier + } + try verifyOverlayPreservesOtherInputFocus(overlay, container: container, window: window) + hosting.layoutSubtreeIfNeeded() + let bitmap = try #require(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) + hosting.cacheDisplay(in: hosting.bounds, to: bitmap) + let image = try #require(bitmap.representation(using: .png, properties: [:])) + try image.write(to: URL(filePath: "/tmp/prowl-scroll-mac-native.png")) + #expect(client.historyLines == history) + #expect(client.historyOffset == offset) + client.scroll(.downward) + try await fixture.wait("Latest remote viewport") { + !client.scrollState.isLoading && client.viewportState.text == nil + } + try await fixture.wait("Observable pane restores styled active screen") { overlay.isHidden } + // A scrolled blank screen is still an overlay, not the nil clear signal. + let viewport = try #require(overlay.enclosingScrollView as? MirrorTerminalScrollView) + viewport.updateViewportText("") + try await fixture.wait("Blank overlay remains visible") { !overlay.isHidden && overlay.string.isEmpty } + client.scroll(.downward) + try await fixture.wait("No-op boundary scroll completes") { !client.scrollState.isLoading } + #expect(client.scrollState.error == nil) + #expect(client.isSubscribed) + #expect(client.historyLines == history) + } + + private func verifyOverlayPreservesOtherInputFocus(_ overlay: NSTextView, container: NSView, window: NSWindow) + throws + { + let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 180, height: 24)) + container.addSubview(input) + defer { input.removeFromSuperview() } + try #require(window.makeFirstResponder(input)) + let responder = try #require(window.firstResponder) + let shortcut = try #require( + NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: .command, timestamp: 0, + windowNumber: window.windowNumber, context: nil, characters: "f", charactersIgnoringModifiers: "f", + isARepeat: false, keyCode: 3)) + _ = overlay.performKeyEquivalent(with: shortcut) + #expect(window.firstResponder === responder) + } + + @Test(.timeLimit(.minutes(1))) func remoteScrollReachesAlternateScreenApplication() async throws { + let fixture = try Fixture() + defer { fixture.close() } + try await fixture.wait("Host program ready") { fixture.hostText.contains("READY") } + try await fixture.startHost() + let client = try await fixture.connect() + try fixture.send("scroll-tui") + try await fixture.waitForMirror(client, containing: "TUI-READY") + client.scroll(.upward) + try await fixture.waitForMirror(client, containing: "TUI-UP") + try await fixture.wait("TUI up completed") { !client.scrollState.isLoading } + #expect(client.scrollState.error == nil) + client.scroll(.downward) + try await fixture.waitForMirror(client, containing: "TUI-DOWN") + try await fixture.wait("TUI down completed") { !client.scrollState.isLoading } + #expect(client.scrollState.error == nil) + } + + @Test(.timeLimit(.minutes(1))) func textWireScrollsRealViewportAndKeepsHistorySnapshot() async throws { + let fixture = try Fixture() + defer { fixture.close() } + try await fixture.wait("Host program ready") { fixture.hostText.contains("READY") } + try fixture.send("history") + try await fixture.wait("Host history ready") { fixture.hostText.contains("HISTORY:450") } + try await fixture.startHost() + let connection = MirrorRemoteConnection( + configuration: .init( + address: "127.0.0.1", port: UInt16(fixture.host.port)!, pairingKey: "", credential: fixture.credential), + restore: { _ in nil }, persist: { _ in }) + defer { connection.close() } + var messages: [MirrorMessage] = [] + connection.onReady = { + connection.send(.subscribe(.init(paneID: fixture.hostView.id, representation: .text, intent: .ifFree))) + } + connection.onMessage = { message in + messages.append(message) + if case .textFrame(let frame) = message { + connection.send(.acknowledge(.init(sequence: frame.sequence, subscriptionID: frame.subscriptionID))) + } + } + connection.start() + try await fixture.wait("Mobile initial frame") { messages.last?.text?.contains("HISTORY:450") == true } + let lease = try #require(messages.first?.subscriptionID) + connection.send(.history(.init(historyID: nil, offset: nil, subscriptionID: lease))) + try await fixture.wait("Mobile frozen history") { messages.last?.kind == .historyPage } + let history = try #require(messages.last) + let upward = UUID() + connection.send(.scroll(.init(requestID: upward, direction: .upward, subscriptionID: lease))) + try await fixture.wait("Mobile earlier viewport result") { + messages.contains { $0.kind == .scrollResult && $0.scrollRequestID == upward } + } + let earlier = try #require(messages.last { $0.kind == .textFrame }) + #expect(earlier.text?.contains("HISTORY:450") == false) + #expect(earlier.text == (try fixture.source.textSnapshot(fixture.hostView.id)).text) + #expect(messages.last?.sequence == earlier.sequence) + let downward = UUID() + connection.send(.scroll(.init(requestID: downward, direction: .downward, subscriptionID: lease))) + try await fixture.wait("Mobile latest viewport result") { + messages.contains { $0.kind == .scrollResult && $0.scrollRequestID == downward } + } + #expect(messages.last { $0.kind == .textFrame }?.text?.contains("HISTORY:450") == true) + connection.send(.history(.init(historyID: history.historyID, offset: nil, subscriptionID: lease))) + try await fixture.wait("Mobile retained frozen history") { messages.last?.kind == .historyPage } + #expect(messages.last?.lines == history.lines) + #expect(messages.last?.historyID == history.historyID) + #expect(!messages.contains { $0.kind == .viewport || $0.kind == .frame || $0.kind == .failure }) + } + + @Test(.timeLimit(.minutes(1))) func viewportRowsPreserveWideCharacterWrapsAndResize() async throws { + let fixture = try Fixture() + defer { fixture.close() } + try await fixture.wait("Host program ready") { fixture.hostText.contains("READY") } + try fixture.send("wrapped") + try await fixture.wait("Wrapped output ready") { fixture.hostText.contains("W250") } + try fixture.source.scroll(.upward, to: fixture.hostView.id) + var captured: MirrorFrame? + try await fixture.wait("Wrapped viewport capture") { + do { + captured = try fixture.source.snapshot(fixture.hostView.id) + return captured?.viewportText != nil + } catch MirrorPaneSourceError.captureChanged { return false } + } + let first = try #require(captured) + let text = try #require(first.viewportText) + #expect(text.contains("界🙂X")) + #expect(text.split(separator: "\n", omittingEmptySubsequences: false).count == Int(first.rows)) + fixture.hostView.setFrameSize(NSSize(width: 480, height: 420)) + fixture.hostView.updateSurfaceSize() + try await fixture.wait("Reflow viewport capture") { + do { + captured = try fixture.source.snapshot(fixture.hostView.id) + return captured?.columns != first.columns && captured?.viewportText != nil + } catch MirrorPaneSourceError.captureChanged { return false } + } + let reflowed = try #require(captured) + let reflowedText = try #require(reflowed.viewportText) + #expect(reflowedText.split(separator: "\n", omittingEmptySubsequences: false).count == Int(reflowed.rows)) + } + @Test(.timeLimit(.minutes(1))) func initialHostStartRecoversFromOccupiedPort() async throws { let fixture = try Fixture() defer { fixture.close() } @@ -693,6 +882,24 @@ struct MirrorTerminalIntegrationTests { think) printf '\033[2J\033[H\033[31mTHINKING:思考中\033[0m\033[4;7H';; finish) printf '\033[2J\033[H\033[32mFINAL:结论\033[0m\033[2;3H';; history) for ((n=1; n<=450; n++)); do printf 'HISTORY:%03d\n' "$n"; done;; + wrapped) + columns=$(stty size); columns=${columns##* } + for ((n=1; n<=250; n++)); do + printf 'W%03d' "$n" + printf '%*s' "$((columns-5))" '' + printf '界🙂X\n' + done + ;; + scroll-tui) + stty raw -echo + printf '\033[?1049h\033[?1007h\033[2J\033[HTUI-READY' + while IFS= read -r -n 3 key; do + case "$key" in + $'\033[A') printf '\033[2J\033[HTUI-UP';; + $'\033[B') printf '\033[2J\033[HTUI-DOWN';; + esac + done + ;; bytes) stty raw -echo printf '\r\nBINARY_READY\r\n' diff --git a/supacodeTests/RemoteMirror/MirrorViewportStateTests.swift b/supacodeTests/RemoteMirror/MirrorViewportStateTests.swift new file mode 100644 index 00000000..49f47225 --- /dev/null +++ b/supacodeTests/RemoteMirror/MirrorViewportStateTests.swift @@ -0,0 +1,73 @@ +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct MirrorViewportStateTests { + @Test func textChangesOnlyWhenTheMatchingTerminalFrameIsPresented() throws { + var state = MirrorViewportState() + let lease = UUID() + try state.stage(.init(text: "earlier output", sequence: 1, subscriptionID: lease)) + #expect(state.text == nil) + try state.receiveFrame(sequence: 1) + #expect(state.text == nil) + try state.didPresent(sequence: 1) + #expect(state.text == "earlier output") + try state.stage(.init(text: nil, sequence: 2, subscriptionID: lease)) + try state.receiveFrame(sequence: 2) + #expect(state.text == "earlier output") + try state.didPresent(sequence: 2) + #expect(state.text == nil) + } + + @Test func blankViewportIsVisibleAndMultibyteTextIsPreserved() throws { + var state = MirrorViewportState() + let lease = UUID() + for (index, text) in ["", "中🌍e\u{301}\nwrapped row"].enumerated() { + let sequence = UInt64(index + 1) + try state.stage(.init(text: text, sequence: sequence, subscriptionID: lease)) + try state.receiveFrame(sequence: sequence) + try state.didPresent(sequence: sequence) + #expect(state.text == text) + } + } + + @Test func rejectsMissingOrMismatchedFrameAndOutOfOrderMetadata() throws { + var state = MirrorViewportState() + let lease = UUID() + #expect(throws: MirrorProtocolError.invalidMessage) { try state.receiveFrame(sequence: 1) } + try state.stage(.init(text: "first", sequence: 1, subscriptionID: lease)) + #expect(throws: MirrorProtocolError.invalidMessage) { try state.didPresent(sequence: 1) } + #expect(throws: MirrorProtocolError.invalidMessage) { + try state.stage(.init(text: "second", sequence: 2, subscriptionID: lease)) + } + #expect(throws: MirrorProtocolError.invalidMessage) { try state.receiveFrame(sequence: 2) } + try state.receiveFrame(sequence: 1) + #expect(throws: MirrorProtocolError.invalidMessage) { try state.receiveFrame(sequence: 1) } + #expect(throws: MirrorProtocolError.invalidMessage) { try state.didPresent(sequence: 2) } + try state.didPresent(sequence: 1) + #expect(throws: MirrorProtocolError.invalidMessage) { + try state.stage(.init(text: "stale", sequence: 1, subscriptionID: lease)) + } + } + + @Test func rejectsOversizedViewportAndNewConnectionStartsWithoutOldText() throws { + var state = MirrorViewportState() + let lease = UUID() + #expect(throws: MirrorProtocolError.invalidMessage) { + try state.stage( + .init( + text: String(repeating: "x", count: MirrorWire.maximumPayload / 8 + 1), sequence: 1, subscriptionID: lease)) + } + try state.stage(.init(text: "previous connection", sequence: 1, subscriptionID: lease)) + try state.receiveFrame(sequence: 1) + try state.didPresent(sequence: 1) + state = MirrorViewportState() + #expect(state.text == nil) + try state.stage(.init(text: nil, sequence: 1, subscriptionID: UUID())) + try state.receiveFrame(sequence: 1) + try state.didPresent(sequence: 1) + #expect(state.text == nil) + } +} -- 2.51.2 From 5caaee2cd4d17fbb7dbff0da4b992885644dc89b Mon Sep 17 00:00:00 2001 From: Awhisper <4347733+Awhisper@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:39:47 +0800 Subject: [PATCH 04/14] Use explicit mirror scroll controls and prevent replica report feedback --- MirrorClient/Android/README.md | 10 +- .../awhisper/prowlmirror/RemoteScrollTest.kt | 82 +++++++++--- .../com/awhisper/prowlmirror/MirrorApp.kt | 51 +------- .../com/awhisper/prowlmirror/RemoteScroll.kt | 19 +-- .../java/com/awhisper/prowlmirror/Session.kt | 45 ++++++- .../java/com/awhisper/prowlmirror/Wire.kt | 4 + .../java/com/awhisper/prowlmirror/CoreTest.kt | 27 ++-- .../com/awhisper/prowlmirror/SessionTest.kt | 88 ++++++++++++- MirrorClient/Android/docs/validation.md | 30 ++++- .../iOS/ProwlMirror-iOS/ContentView.swift | 63 +++------ .../Reading/MirrorRemoteScrollGesture.swift | 13 -- .../Features/Sessions/MirrorSession.swift | 52 +++++++- .../Infrastructure/MirrorProtocol.swift | 13 +- .../Infrastructure/MirrorUIFixture.swift | 16 ++- .../MirrorRemoteScrollTests.swift | 122 +++++++++++++++--- .../ProwlMirror_iOSUITests.swift | 69 +++++++++- MirrorClient/iOS/README.md | 18 ++- MirrorRelay/main.swift | 2 +- .../MirrorDisplayFrameTests.swift | 17 +++ docs-ai/067-remote-mirror/000-plan.md | 2 + .../067-remote-mirror/011-remote-scroll.md | 7 +- ...012-scroll-controls-and-report-feedback.md | 55 ++++++++ docs-ai/README.md | 2 +- docs/remote-mirror-wire.md | 20 +++ docs/remote-mirror.md | 18 ++- .../GhosttyMirrorPaneSource.swift | 20 ++- .../Features/RemoteMirror/MirrorClient.swift | 44 ++++++- .../Features/RemoteMirror/MirrorHost.swift | 25 +++- .../RemoteMirror/MirrorPaneSource.swift | 3 + .../RemoteMirror/MirrorProtocol.swift | 38 +++++- .../MirrorScrollBoundsState.swift | 32 +++++ .../RemoteMirror/MirrorTerminalViewport.swift | 51 -------- .../RelayWire/MirrorDisplayFrame.swift | 28 ++++ .../RemoteMirror/RemoteMirrorPaneView.swift | 8 +- .../RemoteMirror/MirrorHostTests.swift | 87 ++++++++++++- .../RemoteMirror/MirrorProtocolTests.swift | 31 +++++ .../MirrorReplicaInputTests.swift | 67 ++++++++++ .../MirrorScrollBoundsStateTests.swift | 45 +++++++ .../RemoteMirror/MirrorScrollStateTests.swift | 19 --- .../MirrorTerminalIntegrationTests.swift | 92 +++++++++++-- 40 files changed, 1131 insertions(+), 304 deletions(-) delete mode 100644 MirrorClient/iOS/ProwlMirror-iOS/Features/Reading/MirrorRemoteScrollGesture.swift create mode 100644 MirrorRelayTests/MirrorDisplayFrameTests.swift create mode 100644 docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md create mode 100644 supacode/Features/RemoteMirror/MirrorScrollBoundsState.swift create mode 100644 supacode/Features/RemoteMirror/RelayWire/MirrorDisplayFrame.swift create mode 100644 supacodeTests/RemoteMirror/MirrorReplicaInputTests.swift create mode 100644 supacodeTests/RemoteMirror/MirrorScrollBoundsStateTests.swift diff --git a/MirrorClient/Android/README.md b/MirrorClient/Android/README.md index 80de5db8..b597ba84 100644 --- a/MirrorClient/Android/README.md +++ b/MirrorClient/Android/README.md @@ -34,13 +34,17 @@ is no private AI console or dependency on personal shell wrappers. If another client claims a free pane before Mirror completes, Android reports it busy. - History is a bounded frozen snapshot with earlier-page loading. Returning to Live keeps receiving current output. Closing a mirror never stops the Host program. -- Hosts with `remote-scroll` also show **Scroll up / Scroll down** in Live. Pull - outward from the top or bottom of the current text to request a bounded scroll step; - ordinary local scrolling, horizontal gestures and History do not send remote input. +- Hosts with `remote-scroll` show **Scroll up / Scroll down** above the Live text. + Only these buttons send remote scroll input; swipes, edge pulls, selection and + History remain local. **Follow latest** stays below the text and follows new frames. These actions scroll the actual Host pane, including a terminal application's own scroll handling, so the distance depends on that application. One request runs at a time with a loading indicator; an unchanged screen also completes normally. Requests time out after five seconds and are never replayed after reconnecting. + Hosts with `scroll-state-v1` can confirm the top/bottom boundary: the matching + button is disabled until a newer frame permits scrolling again. Unknown bounds, + including applications that manage their own scrolling, stay enabled; unchanged + text alone never disables a direction. Older Hosts need no boundary metadata. After scrolling up, reading continues at the newly revealed top edge; scrolling down moves to the bottom edge. Automatic following is paused by remote scrolling. - Saving credentials does not persist terminal output or drafts across process death. diff --git a/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt b/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt index 9ce6b0da..cb5a6fd1 100644 --- a/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt +++ b/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt @@ -24,6 +24,7 @@ class RemoteScrollTest { private val sent = mutableListOf() private val lease = uuid() private var sequence = 1L + private var includesScrollState = false @After fun close() { @@ -33,7 +34,12 @@ class RemoteScrollTest { } } - private fun show(text: String = "Current viewport\nReady for remote scrolling.", capability: Boolean = true) { + private fun show( + text: String = "Current viewport\nReady for remote scrolling.", + capability: Boolean = true, + scrollState: Boolean = false, + ) { + includesScrollState = scrollState val host = Host("test-host") val pane = Pane(uuid(), "Scroll test", "/test", false) val run = uuid() @@ -51,13 +57,17 @@ class RemoteScrollTest { ready(host) receive(control("panes", obj( "panes" to listOf(pane), - "capabilities" to listOfNotNull("text-v1", "history", "remote-scroll".takeIf { capability }), + "capabilities" to listOfNotNull( + "text-v1", "history", "remote-scroll".takeIf { capability }, + "scroll-state-v1".takeIf { scrollState }, + ), "hostRunID" to run, ))) session.choose(pane) receive(control("subscribed", obj( "paneID" to pane.id, "subscriptionID" to lease, "hostRunID" to run, ))) + if (includesScrollState) bounds(sequence) receive(Packet.Text(lease, sequence, 80, 24, false, text)) session.setFollow(false) model = MirrorModel(rule.activity.application) @@ -67,9 +77,17 @@ class RemoteScrollTest { rule.setContent { MirrorApp(model) } } + private fun bounds(sequence: Long, atTop: Boolean? = null, atBottom: Boolean? = null) { + receive(control("scrollState", obj( + "subscriptionID" to lease, "sequence" to sequence, + "atTop" to atTop, "atBottom" to atBottom, + ))) + } + private fun complete(text: String = session.state.value.text) { rule.runOnIdle { val request = sent.last { it.kind == "scroll" }.payload() + if (includesScrollState) bounds(sequence + 1) receive(Packet.Text(lease, ++sequence, 80, 24, false, text)) receive(control("scrollResult", obj( "subscriptionID" to lease, @@ -90,6 +108,11 @@ class RemoteScrollTest { @Test fun buttonsShowLoadingAndUnchangedFrameFinishesWithoutDisconnect() { show() + val outputBounds = rule.onNodeWithTag("mirror-output").fetchSemanticsNode().boundsInRoot + val upBounds = rule.onNodeWithText("Scroll up").fetchSemanticsNode().boundsInRoot + val followBounds = rule.onNodeWithText("Follow latest").fetchSemanticsNode().boundsInRoot + assertTrue(upBounds.bottom <= outputBounds.top) + assertTrue(followBounds.top >= outputBounds.bottom) rule.onNodeWithText("Scroll up").assertIsEnabled().performClick() rule.onNodeWithText("Scroll up").assertIsNotEnabled() rule.onNodeWithText("Scroll down").assertIsNotEnabled() @@ -112,23 +135,46 @@ class RemoteScrollTest { } @Test - fun edgePullsSendOneRequestAndHorizontalSmallAndBusyGesturesDoNot() { + fun hostBoundsDisableOnlyTheirDirectionAndRestoreOnNewFrames() { + show(scrollState = true) + rule.onNodeWithText("Scroll up").assertIsEnabled() + rule.onNodeWithText("Scroll down").assertIsEnabled() + rule.runOnIdle { bounds(sequence + 1, atTop = true, atBottom = false) } + rule.onNodeWithText("Scroll up").assertIsEnabled() + rule.runOnIdle { + receive(Packet.Text(lease, ++sequence, 80, 24, false, session.state.value.text)) + } + rule.onNodeWithText("Scroll up").assertIsNotEnabled() + rule.onNodeWithText("Scroll down").assertIsEnabled() + screenshot("at-top") + rule.runOnIdle { + bounds(sequence + 1, atTop = false, atBottom = true) + receive(Packet.Text(lease, ++sequence, 80, 24, false, session.state.value.text)) + } + rule.onNodeWithText("Scroll up").assertIsEnabled() + rule.onNodeWithText("Scroll down").assertIsNotEnabled() + screenshot("at-bottom") + rule.runOnIdle { + bounds(sequence + 1) + receive(Packet.Text(lease, ++sequence, 80, 24, false, session.state.value.text)) + } + rule.onNodeWithText("Scroll down").assertIsEnabled() + rule.onNodeWithText("Scroll up").assertIsEnabled().performClick() + complete() + rule.onNodeWithText("Scroll up").assertIsEnabled() + rule.runOnIdle { assertEquals(1, sent.count { it.kind == "scroll" }) } + } + + @Test + fun edgePullsAndHorizontalGesturesNeverScrollTheRemotePane() { show() val output = rule.onNodeWithTag("mirror-output") output.performTouchInput { swipe(Offset(width * .8f, height * .5f), Offset(width * .2f, height * .5f)) } - output.performTouchInput { swipe(Offset(width * .5f, height * .4f), Offset(width * .5f, height * .4f + 8f)) } - rule.runOnIdle { assertEquals(0, sent.count { it.kind == "scroll" }) } - output.performTouchInput { swipe(Offset(width * .5f, height * .3f), Offset(width * .5f, height * .7f)) } - rule.runOnIdle { assertEquals("up", sent.last { it.kind == "scroll" }.payload().string("direction")) } + output.performTouchInput { swipeDown() } output.performTouchInput { swipeUp() } - rule.runOnIdle { assertEquals(1, sent.count { it.kind == "scroll" }) } - complete() - output.performTouchInput { swipe(Offset(width * .5f, height * .7f), Offset(width * .5f, height * .3f)) } - rule.runOnIdle { - assertEquals(2, sent.count { it.kind == "scroll" }) - assertEquals("down", sent.last { it.kind == "scroll" }.payload().string("direction")) - } - complete() + rule.runOnIdle { assertEquals(0, sent.count { it.kind == "scroll" }) } + rule.onNodeWithText("Scroll up").assertIsEnabled() + rule.onNodeWithText("Scroll down").assertIsEnabled() } @Test @@ -165,6 +211,12 @@ class RemoteScrollTest { assertTrue(session.liveScrollIndex > 0 || session.liveScrollOffset > 0) } screenshot("local-reading") + rule.onNodeWithText("Follow latest").performClick() + rule.onNodeWithText("Following latest").assertIsDisplayed() + rule.runOnIdle { + assertTrue(session.state.value.follow) + assertEquals(2, sent.count { it.kind == "scroll" }) + } } @Test diff --git a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt index 208ff3b5..25aef990 100644 --- a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt +++ b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt @@ -2,8 +2,6 @@ package com.awhisper.prowlmirror import android.view.KeyEvent as AndroidKeyEvent import androidx.compose.foundation.* -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.interaction.DragInteraction import androidx.compose.foundation.layout.* @@ -20,10 +18,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontFamily @@ -385,46 +380,6 @@ private fun Reading(session: Session, state: SessionState) { if (state.showsHistory) session.historyScrollIndex else session.liveScrollIndex, if (state.showsHistory) session.historyScrollOffset else session.liveScrollOffset, ) - val canScrollRemote by rememberUpdatedState(state.canScrollRemote) - val threshold = with(LocalDensity.current) { 56.dp.toPx() } - val remoteGesture = - if (!state.showsHistory && "remote-scroll" in state.capabilities) - Modifier.pointerInput(session.id, threshold) { - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) - val enabled = canScrollRemote - val atTop = !scroll.canScrollBackward - val atBottom = !scroll.canScrollForward - var position = down.position - var multiplePointers = false - var dragging = false - var longPressed = false - do { - val event = awaitPointerEvent(PointerEventPass.Initial) - if (event.changes.count { it.pressed } > 1) multiplePointers = true - val change = event.changes.firstOrNull { it.id == down.id } ?: break - position = change.position - if (!dragging) { - // A hold before moving belongs to native text selection. - if (change.uptimeMillis - down.uptimeMillis >= viewConfiguration.longPressTimeoutMillis) - longPressed = true - if ((position - down.position).getDistance() > viewConfiguration.touchSlop) - dragging = true - } - } while (change.pressed) - // Observe without consuming: local text scrolling and selection stay native. - if (enabled && canScrollRemote && !multiplePointers && !longPressed) { - remoteScrollDirection( - position.x - down.position.x, - position.y - down.position.y, - atTop, - atBottom, - threshold, - )?.let(session::scrollRemote) - } - } - } - else Modifier LaunchedEffect(scroll) { scroll.interactionSource.interactions.collect { if (it is DragInteraction.Start && !state.showsHistory) session.setFollow(false) @@ -476,11 +431,11 @@ private fun Reading(session: Session, state: SessionState) { Row(verticalAlignment = Alignment.CenterVertically) { TextButton( onClick = { session.scrollRemote(ScrollDirection.UP) }, - enabled = state.canScrollRemote, + enabled = state.canScrollRemote(ScrollDirection.UP), ) { Text("Scroll up") } TextButton( onClick = { session.scrollRemote(ScrollDirection.DOWN) }, - enabled = state.canScrollRemote, + enabled = state.canScrollRemote(ScrollDirection.DOWN), ) { Text("Scroll down") } if (state.scrolling != null) { CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) @@ -488,7 +443,7 @@ private fun Reading(session: Session, state: SessionState) { } } LazyColumn( - Modifier.weight(1f).fillMaxWidth().then(remoteGesture).testTag("mirror-output"), + Modifier.weight(1f).fillMaxWidth().testTag("mirror-output"), state = scroll, contentPadding = PaddingValues(16.dp), ) { diff --git a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/RemoteScroll.kt b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/RemoteScroll.kt index 1b5777fc..9cbc91e3 100644 --- a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/RemoteScroll.kt +++ b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/RemoteScroll.kt @@ -1,7 +1,5 @@ package com.awhisper.prowlmirror -import kotlin.math.abs - enum class ScrollDirection(val wireName: String) { UP("up"), DOWN("down"), @@ -9,18 +7,7 @@ enum class ScrollDirection(val wireName: String) { data class ScrollCompletion(val requestID: String, val direction: ScrollDirection) -internal fun remoteScrollDirection( - horizontal: Float, - vertical: Float, - startedAtTop: Boolean, - startedAtBottom: Boolean, - threshold: Float, -): ScrollDirection? { - if (!horizontal.isFinite() || !vertical.isFinite() || - abs(vertical) < threshold || abs(vertical) <= abs(horizontal)) return null - return when { - vertical > 0 && startedAtTop -> ScrollDirection.UP - vertical < 0 && startedAtBottom -> ScrollDirection.DOWN - else -> null - } +data class ScrollBounds(val atTop: Boolean? = null, val atBottom: Boolean? = null) { + fun allows(direction: ScrollDirection): Boolean = + (if (direction == ScrollDirection.UP) atTop else atBottom) != true } diff --git a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Session.kt b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Session.kt index 7489546c..45343d76 100644 --- a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Session.kt +++ b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Session.kt @@ -49,11 +49,15 @@ data class SessionState( val follow: Boolean = true, val scrolling: ScrollDirection? = null, val scrollCompletion: ScrollCompletion? = null, + val scrollBounds: ScrollBounds = ScrollBounds(), ) { val canScrollRemote: Boolean get() = status == Status.live && "remote-scroll" in capabilities && !showsHistory && scrolling == null + fun canScrollRemote(direction: ScrollDirection): Boolean = + canScrollRemote && scrollBounds.allows(direction) + val canSend: Boolean get() = status == Status.live && @@ -147,6 +151,9 @@ class Session( val sequence: Long, ) private var scrollRequest: ScrollRequest? = null + private data class PendingScrollState(val sequence: Long, val bounds: ScrollBounds) + private var pendingScrollState: PendingScrollState? = null + private var expectsScrollState = false private data class Submission( val id: String, @@ -228,6 +235,9 @@ class Session( private fun detach() { clearScroll() + pendingScrollState = null + expectsScrollState = false + state.update { it.copy(scrollBounds = ScrollBounds()) } historyTimeout?.cancel() state.update { it.copy(loadingHistory = false) } generation++ @@ -338,11 +348,16 @@ class Session( clearScroll() val pane = state.value.pane ?: return lease = null - state.update { it.copy(status = Status.subscribing, sequence = 0) } + pendingScrollState = null + expectsScrollState = "scroll-state-v1" in state.value.capabilities + state.update { it.copy(status = Status.subscribing, sequence = 0, scrollBounds = ScrollBounds()) } send( control( "subscribe", - obj("paneID" to pane.id, "representation" to "text-v1", "intent" to intent), + obj( + "paneID" to pane.id, "representation" to "text-v1", "intent" to intent, + "includeScrollState" to true.takeIf { expectsScrollState }, + ), ) ) } @@ -484,7 +499,7 @@ class Session( fun scrollRemote(direction: ScrollDirection) { val active = lease ?: return - if (!state.value.canScrollRemote) return + if (!state.value.canScrollRemote(direction)) return val request = ScrollRequest(uuid(), active, direction, state.value.sequence) scrollRequest = request state.update { @@ -525,6 +540,18 @@ class Session( state.update { it.copy(scrollCompletion = ScrollCompletion(request.id, request.direction)) } } + private fun receiveScrollState(p: JsonObject) { + require(expectsScrollState && state.value.status == Status.live && + canonical(p.string("subscriptionID")) == lease) { "Unexpected scroll state" } + val sequence = p.longInteger("sequence") + require(pendingScrollState == null && sequence > state.value.sequence) { + "Invalid scroll state sequence" + } + pendingScrollState = PendingScrollState( + sequence, ScrollBounds(p.optionalFlag("atTop"), p.optionalFlag("atBottom")), + ) + } + private fun receive(packet: Packet) { if (packet is Packet.Text) { require(packet.text.toByteArray().size <= 1024 * 1024) { @@ -537,8 +564,17 @@ class Session( ) { "Invalid frame lease or sequence" } + val bounds = if (expectsScrollState) { + val pending = pendingScrollState + require(pending != null && pending.sequence == packet.sequence) { + "Text frame has no matching scroll state" + } + pending.bounds + } else ScrollBounds() + pendingScrollState = null state.update { it.copy( + scrollBounds = bounds, text = packet.text, sequence = packet.sequence, truncated = packet.truncated, @@ -568,6 +604,7 @@ class Session( } "subscribed" -> { clearScroll() + pendingScrollState = null require(canonical(p.string("paneID")) == state.value.pane?.id) lease = canonical(p.string("subscriptionID")) runID = canonical(p.string("hostRunID")) @@ -578,6 +615,7 @@ class Session( it.copy( status = Status.live, error = null, + scrollBounds = ScrollBounds(), history = emptyList(), historyOffset = 0, historyTime = null, @@ -624,6 +662,7 @@ class Session( } } "scrollResult" -> scrollResult(p) + "scrollState" -> receiveScrollState(p) "historyPage" -> { require( canonical(p.string("subscriptionID")) == lease && state.value.loadingHistory diff --git a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Wire.kt b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Wire.kt index a9f9eb6a..a10c9c5f 100644 --- a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Wire.kt +++ b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Wire.kt @@ -30,6 +30,9 @@ fun JsonObject.flag(key: String): Boolean = get(key)?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isBoolean }?.asBoolean ?: throw IOException("Missing or invalid $key") +fun JsonObject.optionalFlag(key: String): Boolean? = + if (!has(key) || get(key).isJsonNull) null else flag(key) + fun JsonObject.optionalString(key: String): String? = if (!has(key) || get(key).isJsonNull) null else string(key) @@ -103,6 +106,7 @@ object Wire { "refresh", "scroll", "scrollResult", + "scrollState", "history", "historyPage", "command", diff --git a/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/CoreTest.kt b/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/CoreTest.kt index 59760c2d..c9f93d10 100644 --- a/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/CoreTest.kt +++ b/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/CoreTest.kt @@ -84,15 +84,24 @@ class CoreTest { } @Test - fun onlyOutwardVerticalPullsAtTheStartingEdgeRequestRemoteScrolling() { - assertEquals(ScrollDirection.UP, remoteScrollDirection(5f, 70f, true, false, 56f)) - assertEquals(ScrollDirection.DOWN, remoteScrollDirection(5f, -70f, false, true, 56f)) - assertNull(remoteScrollDirection(5f, 30f, true, true, 56f)) - assertNull(remoteScrollDirection(80f, 70f, true, true, 56f)) - assertNull(remoteScrollDirection(0f, 70f, false, true, 56f)) - assertNull(remoteScrollDirection(0f, -70f, true, false, 56f)) - assertNull(remoteScrollDirection(0f, 100f, false, false, 56f)) - assertNull(remoteScrollDirection(Float.NaN, 70f, true, true, 56f)) + fun scrollStateUsesSwiftEnvelopeAndStrictOptionalBounds() { + val lease = "11111111-2222-3333-4444-555555555555" + val packet = control("scrollState", obj( + "atTop" to true, "atBottom" to false, "sequence" to 12, "subscriptionID" to lease, + )) + val encoded = Wire.encode(packet) + assertEquals( + "{\"scrollState\":{\"_0\":{\"atTop\":true,\"atBottom\":false,\"sequence\":12,\"subscriptionID\":\"$lease\"}}}", + String(encoded.drop(5).toByteArray()), + ) + val payload = (Wire.read(ByteArrayInputStream(encoded)) as Packet.Control).payload() + assertEquals(true, payload.optionalFlag("atTop")) + assertEquals(false, payload.optionalFlag("atBottom")) + assertNull(payload.optionalFlag("missing")) + payload.add("atTop", com.google.gson.JsonNull.INSTANCE) + assertNull(payload.optionalFlag("atTop")) + assertThrows(Exception::class.java) { obj("atTop" to "true").optionalFlag("atTop") } + assertThrows(Exception::class.java) { obj("atTop" to 1).optionalFlag("atTop") } } @Test diff --git a/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/SessionTest.kt b/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/SessionTest.kt index 96be5bb2..981e87e1 100644 --- a/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/SessionTest.kt +++ b/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/SessionTest.kt @@ -41,7 +41,7 @@ class SessionTest { val peer get() = peers.last() - fun ready(interactive: Boolean = true, remoteScroll: Boolean = false) { + fun ready(interactive: Boolean = true, remoteScroll: Boolean = false, scrollState: Boolean = false) { peer.ready(host) peer.receive( control( @@ -50,16 +50,17 @@ class SessionTest { "panes" to listOf(pane), "capabilities" to (listOf("text-v1", "launch-profile", "history", "refresh") + (if (interactive) listOf("agent-input") else emptyList()) + - (if (remoteScroll) listOf("remote-scroll") else emptyList())), + (if (remoteScroll) listOf("remote-scroll") else emptyList()) + + (if (scrollState) listOf("scroll-state-v1") else emptyList())), "hostRunID" to run, ), ) ) } - fun live(remoteScroll: Boolean = false) { + fun live(remoteScroll: Boolean = false, scrollState: Boolean = false) { session.connect() - ready(remoteScroll = remoteScroll) + ready(remoteScroll = remoteScroll, scrollState = scrollState) session.choose(pane) subscribed() } @@ -103,6 +104,83 @@ class SessionTest { } } + @Test + fun scrollBoundsAreOptedInAndCommitWithTheirFrame() = runTest { + val f = Fixture(backgroundScope) + f.live(remoteScroll = true, scrollState = true) + assertTrue(f.peer.sent.last { it.kind == "subscribe" }.payload().flag("includeScrollState")) + f.peer.receive(control("scrollState", obj( + "subscriptionID" to f.lease, "sequence" to 1, "atTop" to true, "atBottom" to false, + ))) + assertTrue(f.session.state.value.canScrollRemote(ScrollDirection.UP)) + f.peer.receive(Packet.Text(f.lease, 1, 80, 24, false, "same viewport")) + assertFalse(f.session.state.value.canScrollRemote(ScrollDirection.UP)) + assertTrue(f.session.state.value.canScrollRemote(ScrollDirection.DOWN)) + f.session.scrollRemote(ScrollDirection.UP) + assertFalse(f.peer.sent.any { it.kind == "scroll" }) + f.peer.receive(control("scrollState", obj( + "subscriptionID" to f.lease, "sequence" to 2, "atTop" to false, "atBottom" to true, + ))) + assertFalse(f.session.state.value.canScrollRemote(ScrollDirection.UP)) + f.peer.receive(Packet.Text(f.lease, 2, 80, 24, false, "same viewport")) + assertTrue(f.session.state.value.canScrollRemote(ScrollDirection.UP)) + assertFalse(f.session.state.value.canScrollRemote(ScrollDirection.DOWN)) + f.session.scrollRemote(ScrollDirection.DOWN) + assertFalse(f.peer.sent.any { it.kind == "scroll" }) + // Missing/null bounds mean unknown, never a boundary inferred from identical text. + val unknown = obj("subscriptionID" to f.lease, "sequence" to 3) + unknown.add("atBottom", com.google.gson.JsonNull.INSTANCE) + f.peer.receive(control("scrollState", unknown)) + f.peer.receive(Packet.Text(f.lease, 3, 80, 24, false, "same viewport")) + assertTrue(f.session.state.value.canScrollRemote(ScrollDirection.UP)) + assertTrue(f.session.state.value.canScrollRemote(ScrollDirection.DOWN)) + f.session.close() + } + + @Test + fun malformedOrUnpairedScrollStateDisconnects() = runTest { + for (failure in listOf("missing", "wrong-lease", "wrong-sequence", "duplicate", "invalid-flag", "stale")) { + val f = Fixture(backgroundScope) + f.live(remoteScroll = true, scrollState = true) + val payload = obj("subscriptionID" to f.lease, "sequence" to 1, "atTop" to true) + when (failure) { + "wrong-lease" -> payload.addProperty("subscriptionID", uuid()) + "wrong-sequence" -> payload.addProperty("sequence", 2) + "invalid-flag" -> payload.addProperty("atTop", "true") + "stale" -> payload.addProperty("sequence", 0) + } + if (failure != "missing") f.peer.receive(control("scrollState", payload)) + if (failure == "duplicate") f.peer.receive(control("scrollState", payload)) + f.peer.receive(Packet.Text(f.lease, 1, 80, 24, false, "screen")) + assertEquals(failure, Status.disconnected, f.session.state.value.status) + assertTrue(f.peer.stopped) + f.session.close() + } + } + + @Test + fun scrollBoundsResetOnReconnectAndOlderHostsNeedNoMetadata() = runTest { + val f = Fixture(backgroundScope) + f.live(remoteScroll = true, scrollState = true) + f.peer.receive(control("scrollState", obj( + "subscriptionID" to f.lease, "sequence" to 1, "atTop" to true, "atBottom" to true, + ))) + f.peer.receive(Packet.Text(f.lease, 1, 80, 24, false, "screen")) + assertFalse(f.session.state.value.canScrollRemote(ScrollDirection.UP)) + f.peer.receive(control("scrollState", obj("subscriptionID" to f.lease, "sequence" to 2))) + f.peer.closed("Network lost") + assertEquals(ScrollBounds(), f.session.state.value.scrollBounds) + f.session.retry() + f.ready(remoteScroll = true) + assertFalse(f.peer.sent.last { it.kind == "subscribe" }.payload().has("includeScrollState")) + f.subscribed() + f.peer.receive(Packet.Text(f.lease, 1, 80, 24, false, "screen")) + assertEquals(Status.live, f.session.state.value.status) + assertTrue(f.session.state.value.canScrollRemote(ScrollDirection.UP)) + assertTrue(f.session.state.value.canScrollRemote(ScrollDirection.DOWN)) + f.session.close() + } + @Test fun remoteScrollRequiresCapabilityAndLiveView() = runTest { val f = Fixture(backgroundScope) @@ -141,6 +219,8 @@ class SessionTest { ))) assertNull(f.session.state.value.scrolling) assertEquals(ScrollDirection.UP, f.session.state.value.scrollCompletion?.direction) + assertTrue(f.session.state.value.canScrollRemote(ScrollDirection.UP)) + assertTrue(f.session.state.value.canScrollRemote(ScrollDirection.DOWN)) assertFalse(f.session.state.value.follow) assertEquals(Status.live, f.session.state.value.status) advanceTimeBy(6_000) diff --git a/MirrorClient/Android/docs/validation.md b/MirrorClient/Android/docs/validation.md index a6abf717..92e1d38a 100644 --- a/MirrorClient/Android/docs/validation.md +++ b/MirrorClient/Android/docs/validation.md @@ -39,7 +39,7 @@ Ghostty, existing remote debug apps, or any physical device. selection cancellation, session-specific dialogs, session-owned launch state, and Unicode-safe detail chunking. -## Remote scrolling validation (2026-10-01) +## Initial remote scrolling validation (2026-10-01, before button-only revision) - Debug APK, instrumentation APK and lint succeeded with JDK 17 / Android SDK 34. - 37 JVM tests ran: 36 passed; the optional native TLS fixture was not running, @@ -61,6 +61,34 @@ Ghostty, existing remote debug apps, or any physical device. History and error screenshots were inspected under `/tmp/prowl-android-scroll-screenshots/`. +## Button-only scrolling and boundaries (2026-10-01) + +- Removed remote gesture recognition and its classifier. Live remote input is now + limited to the two top buttons; ordinary reading, edge pulls and selection remain + local. The bottom Follow latest control is unchanged. +- Added opt-in `scroll-state-v1` metadata pairing. Only a matching lease/sequence + commits Host boundary flags with its text frame. Missing/null flags mean unknown; + an unchanged frame does not establish a boundary. New frames can re-enable either + direction, and disconnect/reconnect clears old flags and pending metadata. +- JDK 17 / Android SDK 34: `:app:testDebugUnitTest :app:assembleDebug + :app:assembleDebugAndroidTest :app:lintDebug` succeeded. 40 JVM tests ran: + 39 passed; the optional native TLS fixture test was skipped. Lint reported no + errors and 14 existing dependency/library warnings. +- Unit coverage includes unknown/known bounds, direction-specific send guards, + capability opt-in, frame pairing, malformed flags, duplicate/stale/wrong-lease + metadata, missing/mismatched metadata, reconnect and older Hosts. +- All 13 instrumentation tests passed on the isolated Android 16 phone emulator, + including eight scrolling cases. Actual Compose UI checks verify buttons above + the reading area, Follow latest below it, top/bottom disabled states and recovery, + local-only edge pulls/horizontal swipes/selection, loading/no-op completion, + local reading anchors, older Hosts and unchanged History. +- The UI fixture injects Host messages through a fake transport; it does not claim + real Host/TLS or physical-device validation. The emulator ran read-only without + snapshots and was stopped afterward. No physical phone or real Host was touched. +- Logs: `/tmp/prowl-android-buttons-final-build.log` and + `/tmp/prowl-android-buttons-ui.log` (`OK (13 tests)`). Boundary, loading and + long-text screenshots were inspected under `/tmp/prowl-android-buttons-screenshots/`. + ## Not represented as passed - Physical phone/tablet, vendor-specific IME, actual Bluetooth/USB keyboard. diff --git a/MirrorClient/iOS/ProwlMirror-iOS/ContentView.swift b/MirrorClient/iOS/ProwlMirror-iOS/ContentView.swift index 57ae30e9..b24d5b1b 100644 --- a/MirrorClient/iOS/ProwlMirror-iOS/ContentView.swift +++ b/MirrorClient/iOS/ProwlMirror-iOS/ContentView.swift @@ -285,8 +285,6 @@ private struct MirrorReadingView: View { @State private var position: ScrollPosition @State private var observedInitialPosition = false @State private var isEditing = false - @State private var scrollGeometry = MirrorRemoteScrollGesture(offset: 0, maximumOffset: 0) - @State private var dragStart: MirrorRemoteScrollGesture? init(session: MirrorSession) { self.session = session @@ -342,10 +340,7 @@ private struct MirrorReadingView: View { } .scrollPosition($position) .accessibilityIdentifier("mirror-live-scroll") - .onDisappear { - observedInitialPosition = false - dragStart = nil - } + .onDisappear { observedInitialPosition = false } .onAppear { if session.followsLatest { position.scrollTo(edge: .bottom) @@ -353,37 +348,15 @@ private struct MirrorReadingView: View { position.scrollTo(y: session.liveReadingOffset) } } - .onScrollGeometryChange(for: MirrorRemoteScrollGesture.self) { geometry in - MirrorRemoteScrollGesture( - offset: max(0, geometry.contentOffset.y + geometry.contentInsets.top), - maximumOffset: max( - 0, - geometry.contentSize.height + geometry.contentInsets.top + geometry.contentInsets.bottom - - geometry.containerSize.height)) - } action: { _, geometry in - scrollGeometry = geometry - if observedInitialPosition { session.liveReadingOffset = geometry.offset } + .onScrollGeometryChange(for: CGFloat.self) { geometry in + max(0, geometry.contentOffset.y + geometry.contentInsets.top) + } action: { _, offset in + if observedInitialPosition { session.liveReadingOffset = offset } observedInitialPosition = true } .onScrollPhaseChange { _, phase in - if phase == .tracking { dragStart = scrollGeometry } if phase == .interacting { session.followsLatest = false } } - .simultaneousGesture( - // A long press belongs to text selection, even if its finger later crosses a scroll edge. - LongPressGesture() - .exclusively(before: DragGesture(minimumDistance: 16)) - .onChanged { value in - if case .second = value, dragStart == nil { dragStart = scrollGeometry } - } - .onEnded { value in - defer { dragStart = nil } - guard case .second(let gesture) = value else { return } - if let direction = dragStart?.direction(translation: gesture.translation) { - session.scroll(direction) - } - } - ) .onChange(of: session.revision) { _, _ in if session.followsLatest { proxy.scrollTo("latest", anchor: .bottom) } } @@ -391,10 +364,11 @@ private struct MirrorReadingView: View { guard let completion else { return } position.scrollTo(edge: completion.direction == .upward ? .top : .bottom) } - .safeAreaInset(edge: .bottom) { + .safeAreaInset(edge: .top) { VStack(alignment: .leading, spacing: 8) { HStack { Button("Scroll Up", systemImage: "arrow.up") { session.scroll(.upward) } + .disabled(!session.canScroll(.upward)) .accessibilityIdentifier("mirror-scroll-up") .help("Scroll the Host pane toward earlier output") Spacer() @@ -403,27 +377,28 @@ private struct MirrorReadingView: View { } Spacer() Button("Scroll Down", systemImage: "arrow.down") { session.scroll(.downward) } + .disabled(!session.canScroll(.downward)) .accessibilityIdentifier("mirror-scroll-down") .help("Scroll the Host pane toward later output") } - .disabled(!session.canScroll) if let error = session.scrollError { Text(error).foregroundStyle(.secondary) } else if session.status == .live && !session.supportsRemoteScroll { Text("Update Host to enable remote scrolling.").foregroundStyle(.secondary) - } else if session.status == .live { - Text("Pull past either text edge to scroll the Host pane.").foregroundStyle(.secondary) } - HStack { - Toggle("Follow latest", isOn: $session.followsLatest).toggleStyle(.button) - Spacer() - Button("Latest") { - session.followsLatest = true - proxy.scrollTo("latest", anchor: .bottom) - } + } + .font(.caption).padding(.horizontal).padding(.vertical, 8).background(.bar) + } + .safeAreaInset(edge: .bottom) { + HStack { + Toggle("Follow latest", isOn: $session.followsLatest).toggleStyle(.button) + Spacer() + Button("Latest") { + session.followsLatest = true + proxy.scrollTo("latest", anchor: .bottom) } - .disabled(session.isScrolling) } + .disabled(session.isScrolling) .font(.caption).padding(.horizontal).padding(.vertical, 8).background(.bar) } } diff --git a/MirrorClient/iOS/ProwlMirror-iOS/Features/Reading/MirrorRemoteScrollGesture.swift b/MirrorClient/iOS/ProwlMirror-iOS/Features/Reading/MirrorRemoteScrollGesture.swift deleted file mode 100644 index e6864528..00000000 --- a/MirrorClient/iOS/ProwlMirror-iOS/Features/Reading/MirrorRemoteScrollGesture.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation - -nonisolated struct MirrorRemoteScrollGesture: Equatable { - let offset: CGFloat - let maximumOffset: CGFloat - - func direction(translation: CGSize) -> MirrorMessage.ScrollDirection? { - guard abs(translation.height) >= 64, abs(translation.height) > abs(translation.width) * 1.5 else { return nil } - if offset <= 1, translation.height > 0 { return .upward } - if offset >= maximumOffset - 1, translation.height < 0 { return .downward } - return nil - } -} diff --git a/MirrorClient/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift b/MirrorClient/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift index d66258b6..e22343f8 100644 --- a/MirrorClient/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift +++ b/MirrorClient/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift @@ -39,6 +39,10 @@ final class MirrorSession: Identifiable { private(set) var isScrolling = false private(set) var scrollError: String? private(set) var completedScroll: ScrollCompletion? + private(set) var scrollAtTop: Bool? + private(set) var scrollAtBottom: Bool? + @ObservationIgnored private var supportsScrollState = false + @ObservationIgnored private var pendingScrollState: MirrorMessage.ScrollStatePayload? @ObservationIgnored private var pendingScroll: PendingScroll? @ObservationIgnored private var scrollTimeout: Task? @ObservationIgnored private var supportsShellSend = false @@ -64,6 +68,9 @@ final class MirrorSession: Identifiable { var canScroll: Bool { status == .live && supportsRemoteScroll && subscriptionID != nil && !isScrolling && !showsHistory } + func canScroll(_ direction: MirrorMessage.ScrollDirection) -> Bool { + canScroll && (direction == .upward ? scrollAtTop != true : scrollAtBottom != true) + } private enum CommandFailure: LocalizedError { case unavailable, disconnected, timedOut, shellUnavailable, agentInputUnavailable var errorDescription: String? { @@ -151,6 +158,8 @@ final class MirrorSession: Identifiable { supportsShellSend = false supportsAgentInput = false supportsRemoteScroll = false + supportsScrollState = false + clearScrollState() clearScroll() scrollError = nil completedScroll = nil @@ -178,6 +187,7 @@ final class MirrorSession: Identifiable { self.finishCommand(.failure(CommandFailure.disconnected)) self.subscriptionID = nil self.clearScroll() + self.clearScrollState() self.markDeliveryUncertain() self.isLoadingHistory = false if self.status == .live || self.status == .connecting || self.status == .subscribing @@ -256,6 +266,7 @@ final class MirrorSession: Identifiable { old?.close(nil) subscriptionID = nil clearScroll() + clearScrollState() markDeliveryUncertain() isLoadingHistory = false status = .disconnected @@ -421,7 +432,7 @@ final class MirrorSession: Identifiable { } func scroll(_ direction: MirrorMessage.ScrollDirection) { - guard canScroll, let subscriptionID else { return } + guard canScroll(direction), let subscriptionID else { return } let request = PendingScroll(id: UUID(), direction: direction, subscriptionID: subscriptionID, baseline: revision) pendingScroll = request isScrolling = true @@ -445,6 +456,34 @@ final class MirrorSession: Identifiable { isScrolling = false } + private func clearScrollState() { + pendingScrollState = nil + scrollAtTop = nil + scrollAtBottom = nil + } + + private func receiveScrollState(_ message: MirrorMessage) { + guard case .scrollState(let state) = message, + state.subscriptionID == subscriptionID, state.sequence > revision + else { return } + guard supportsScrollState else { + invalidMessage() + return + } + pendingScrollState = state + } + + private func commitScrollState(sequence: UInt64) -> Bool { + guard supportsScrollState else { return true } + guard let state = pendingScrollState, state.sequence == sequence, + state.subscriptionID == subscriptionID + else { return false } + pendingScrollState = nil + scrollAtTop = state.atTop + scrollAtBottom = state.atBottom + return true + } + private func receiveScrollResult(_ message: MirrorMessage) { guard let pendingScroll, message.scrollRequestID == pendingScroll.id, message.subscriptionID == pendingScroll.subscriptionID, subscriptionID == pendingScroll.subscriptionID @@ -476,8 +515,12 @@ final class MirrorSession: Identifiable { revision = 0 subscriptionID = nil clearScroll() + clearScrollState() send( - .subscribe(.init(paneID: pane.id, representation: .text, intent: intent))) + .subscribe( + .init( + paneID: pane.id, representation: .text, intent: intent, + includeScrollState: supportsScrollState ? true : nil))) } private func send(_ message: MirrorMessage) { transport?.send(message, closeAfterSending: false) } @@ -497,6 +540,7 @@ final class MirrorSession: Identifiable { supportsRefresh = message.capabilities?.contains("refresh") == true supportsHistory = message.capabilities?.contains("history") == true supportsRemoteScroll = message.capabilities?.contains("remote-scroll") == true + supportsScrollState = message.capabilities?.contains("scroll-state-v1") == true if supportsSubmission { querySubmission() } panes = message.panes ?? [] onVerifiedConnection?(configuration) @@ -541,7 +585,7 @@ final class MirrorSession: Identifiable { case .textFrame: guard let lease = subscriptionID, message.subscriptionID == lease, let sequence = message.sequence, sequence > revision, let replacement = message.text, - replacement.utf8.count <= MirrorWire.maximumPayload / 8 + replacement.utf8.count <= MirrorWire.maximumPayload / 8, commitScrollState(sequence: sequence) else { invalidMessage() return @@ -553,6 +597,8 @@ final class MirrorSession: Identifiable { send(.acknowledge(.init(sequence: sequence, subscriptionID: lease))) case .scrollResult: receiveScrollResult(message) + case .scrollState: + receiveScrollState(message) case .historyPage: guard isLoadingHistory, subscriptionID != nil, message.subscriptionID == subscriptionID, let id = message.historyID, diff --git a/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorProtocol.swift b/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorProtocol.swift index 22e7086d..0fe4fb21 100644 --- a/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorProtocol.swift +++ b/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorProtocol.swift @@ -51,7 +51,7 @@ nonisolated enum MirrorMessage: Codable, Sendable { enum Kind: String, Codable { case challenge, authenticate, pair, paired, authenticated, list, panes, subscribe, subscribed, frame, textFrame, acknowledge, input, history, historyPage, failure, ping, pong, ended, - refresh, command, commandResult, commandReceipt, scroll, scrollResult + refresh, command, commandResult, commandReceipt, scroll, scrollResult, scrollState } case list case panes(PanesPayload) @@ -65,6 +65,7 @@ nonisolated enum MirrorMessage: Codable, Sendable { var paneID: UUID var representation: Representation var intent: Intent + var includeScrollState: Bool? } case subscribed(SubscribedPayload) struct SubscribedPayload: Codable, Sendable { @@ -141,6 +142,13 @@ nonisolated enum MirrorMessage: Codable, Sendable { var sequence: UInt64 var subscriptionID: UUID } + case scrollState(ScrollStatePayload) + struct ScrollStatePayload: Codable, Sendable { + var atTop: Bool? + var atBottom: Bool? + var sequence: UInt64 + var subscriptionID: UUID + } case command(CommandPayload) struct CommandPayload: Codable, Sendable { var subscriptionID: UUID? @@ -179,6 +187,7 @@ nonisolated enum MirrorMessage: Codable, Sendable { case .refresh: .refresh case .scroll: .scroll case .scrollResult: .scrollResult + case .scrollState: .scrollState case .command: .command case .commandResult: .commandResult case .commandReceipt: .commandReceipt @@ -235,6 +244,7 @@ nonisolated enum MirrorMessage: Codable, Sendable { case .refresh(let payload): payload.subscriptionID case .scroll(let payload): payload.subscriptionID case .scrollResult(let payload): payload.subscriptionID + case .scrollState(let payload): payload.subscriptionID case .command(let payload): payload.subscriptionID case .commandReceipt(let payload): payload.subscriptionID default: nil @@ -252,6 +262,7 @@ nonisolated enum MirrorMessage: Codable, Sendable { case .textFrame(let payload): payload.sequence case .acknowledge(let payload): payload.sequence case .scrollResult(let payload): payload.sequence + case .scrollState(let payload): payload.sequence default: nil } } diff --git a/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorUIFixture.swift b/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorUIFixture.swift index 7fbb35c2..99801e8b 100644 --- a/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorUIFixture.swift +++ b/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorUIFixture.swift @@ -58,6 +58,7 @@ private var sequence: UInt64 = 0 private var scrollPage = 0 private var scrollTask: Task? + private var includesScrollState = false func start() { onReady?() } func close(_ reason: String?) { @@ -74,9 +75,12 @@ panes: [pane], capabilities: [ "text-v1", "history", "refresh", "launch-profile", "agents-dispatch", "agent-input", - "shell-send", "remote-scroll", - ], hostRunID: UUID()))) + "shell-send", + ] + (CommandLine.arguments.contains("--mirror-ui-no-scroll-fixture") ? [] : ["remote-scroll"]) + + (CommandLine.arguments.contains("--mirror-ui-scroll-boundary-fixture") ? ["scroll-state-v1"] : []), + hostRunID: UUID()))) case .subscribe: + if case .subscribe(let payload) = message { includesScrollState = payload.includeScrollState == true } onMessage?( .subscribed(.init(paneID: pane.id, subscriptionID: lease, hostRunID: run))) frame() @@ -157,17 +161,23 @@ private func completeScroll(_ message: MirrorMessage) { guard let id = message.scrollRequestID, let direction = message.scrollDirection else { return } scrollPage += direction == .upward ? -1 : 1 + if includesScrollState { scrollPage = min(0, max(-2, scrollPage)) } frame() onMessage?(.scrollResult(.init(requestID: id, sequence: sequence, subscriptionID: lease))) } private func frame() { sequence += 1 + if includesScrollState { + onMessage?( + .scrollState( + .init(atTop: scrollPage == -2, atBottom: scrollPage == 0, sequence: sequence, subscriptionID: lease))) + } if CommandLine.arguments.contains("--mirror-ui-scroll-fixture") { let text = CommandLine.arguments.contains("--mirror-ui-scroll-long-fixture") ? (1...40).map { "```text\nRemote \(scrollPage) marker \($0)\n```" }.joined(separator: "\n") - : "Remote page \(scrollPage)\nPull past the text edge to scroll the Host." + : "Remote page \(scrollPage)\nUse the buttons above to scroll the Host." onMessage?( .textFrame( .init( diff --git a/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorRemoteScrollTests.swift b/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorRemoteScrollTests.swift index 8e703e19..b29cd4eb 100644 --- a/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorRemoteScrollTests.swift +++ b/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorRemoteScrollTests.swift @@ -6,6 +6,76 @@ import Testing @MainActor struct MirrorRemoteScrollTests { + @Test func boundariesCommitOnlyWithTheirFrameAndOnlyDisableTheKnownDirection() throws { + let channel = Channel(supportsScrollState: true) + let session = makeSession(channel) + #expect(channel.includedScrollState == true) + #expect(session.canScroll(.upward) && session.canScroll(.downward)) + + channel.state(2, atTop: true, atBottom: false) + #expect(session.canScroll(.upward)) + channel.frame(2, includeState: false) + #expect(!session.canScroll(.upward) && session.canScroll(.downward)) + session.scroll(.upward) + #expect(channel.requests.isEmpty) + session.scroll(.downward) + channel.atTop = false + channel.atBottom = true + channel.frame(3) + channel.result(try #require(channel.requests.last), sequence: 3) + #expect(session.canScroll(.upward) && !session.canScroll(.downward)) + session.scroll(.downward) + #expect(channel.requests.count == 1) + + channel.atTop = nil + channel.atBottom = nil + channel.frame(4) + #expect(session.text == "current") + #expect(session.canScroll(.upward) && session.canScroll(.downward)) + } + + @Test func staleBoundaryUpdatesCannotReplaceTheNextFrameOrSurviveReconnect() { + let channel = Channel(supportsScrollState: true) + let session = makeSession(channel) + let oldLease = channel.lease + channel.state(2, atTop: true, atBottom: true) + channel.state(1, atTop: false, atBottom: false) + channel.state(2, atTop: false, atBottom: false, lease: UUID()) + channel.frame(2, includeState: false) + #expect(!session.canScroll(.upward) && !session.canScroll(.downward)) + + session.disconnect() + #expect(session.scrollAtTop == nil && session.scrollAtBottom == nil) + channel.supportsScrollState = false + session.retry() + #expect(channel.includedScrollState == nil) + #expect(session.canScroll(.upward) && session.canScroll(.downward)) + channel.state(2, atTop: true, atBottom: true, lease: oldLease) + channel.frame(2) + #expect(session.canScroll(.upward) && session.canScroll(.downward)) + #expect(session.status == .live) + } + + @Test(arguments: [false, true]) func negotiatedHostMustSupplyMatchingStateBeforeEachFrame(mismatched: Bool) { + let channel = Channel(supportsScrollState: true) + let session = makeSession(channel) + if mismatched { channel.state(3, atTop: true) } + channel.frame(2, includeState: false) + #expect(session.status == .disconnected) + #expect(session.scrollAtTop == nil && session.scrollAtBottom == nil) + #expect(channel.closes == 1) + } + + @Test func oldHostWithRemoteScrollKeepsBothDirectionsAvailable() throws { + let channel = Channel() + let session = makeSession(channel) + #expect(channel.includedScrollState == nil) + session.scroll(.upward) + channel.frame(2) + channel.result(try #require(channel.requests.last), sequence: 2) + #expect(session.canScroll(.upward) && session.canScroll(.downward)) + } + @Test func oldHostsAndHistoryNeverReceiveRemoteScroll() { let old = Channel(supportsScroll: false) let oldSession = makeSession(old) @@ -177,21 +247,6 @@ struct MirrorRemoteScrollTests { #expect(session.completedScroll?.direction == .upward) } - @Test func edgeGestureLeavesInteriorHorizontalAndSmallDragsLocal() { - let top = MirrorRemoteScrollGesture(offset: 0, maximumOffset: 500) - let middle = MirrorRemoteScrollGesture(offset: 250, maximumOffset: 500) - let bottom = MirrorRemoteScrollGesture(offset: 500, maximumOffset: 500) - #expect(top.direction(translation: CGSize(width: 0, height: 80)) == .upward) - #expect(bottom.direction(translation: CGSize(width: 0, height: -80)) == .downward) - #expect(top.direction(translation: CGSize(width: 0, height: -80)) == nil) - #expect(bottom.direction(translation: CGSize(width: 0, height: 80)) == nil) - #expect(middle.direction(translation: CGSize(width: 0, height: 300)) == nil) - #expect(top.direction(translation: CGSize(width: 0, height: 40)) == nil) - #expect(top.direction(translation: CGSize(width: 100, height: 80)) == nil) - let short = MirrorRemoteScrollGesture(offset: 0, maximumOffset: 0) - #expect(short.direction(translation: CGSize(width: 0, height: -80)) == .downward) - } - @Test func scrollJSONRoundTripsAndRejectsUnknownDirection() throws { let request = UUID() let lease = UUID() @@ -211,6 +266,22 @@ struct MirrorRemoteScrollTests { #expect(decoded.sequence == 4) let failure = MirrorMessage.failure(.init(error: "SCROLL_BUSY", subscriptionID: lease, requestID: request)) #expect(try MirrorWire.decode(MirrorWire.encode(failure).dropFirst(4)).scrollRequestID == request) + let state = MirrorMessage.scrollState(.init(atTop: true, atBottom: nil, sequence: 4, subscriptionID: lease)) + let stateMessage = try MirrorWire.decode(MirrorWire.encode(state).dropFirst(4)) + guard case .scrollState(let decodedState) = stateMessage else { + Issue.record("Expected scroll state") + return + } + #expect(decodedState.atTop == true && decodedState.atBottom == nil) + #expect(stateMessage.sequence == 4 && stateMessage.subscriptionID == lease) + let subscribe = MirrorMessage.subscribe( + .init(paneID: request, representation: .text, intent: .ifFree, includeScrollState: true)) + let subscription = try MirrorWire.decode(MirrorWire.encode(subscribe).dropFirst(4)) + guard case .subscribe(let payload) = subscription else { + Issue.record("Expected subscription") + return + } + #expect(payload.includeScrollState == true) let invalid = """ {"scroll":{"_0":{"requestID":"\(request)","direction":"left","subscriptionID":"\(lease)"}}} """ @@ -245,11 +316,18 @@ struct MirrorRemoteScrollTests { var onMessage: ((MirrorMessage) -> Void)? var onClose: ((String?) -> Void)? let supportsScroll: Bool + var supportsScrollState: Bool + var includedScrollState: Bool? + var atTop: Bool? + var atBottom: Bool? let pane = MirrorPaneDescriptor(id: UUID(), title: "Scroll", directory: "/", busy: false) var lease = UUID() var requests: [UUID] = [] var closes = 0 - init(supportsScroll: Bool = true) { self.supportsScroll = supportsScroll } + init(supportsScroll: Bool = true, supportsScrollState: Bool = false) { + self.supportsScroll = supportsScroll + self.supportsScrollState = supportsScrollState + } func start() { onReady?() } func close(_ reason: String?) { closes += 1 @@ -261,9 +339,12 @@ struct MirrorRemoteScrollTests { onMessage?( .panes( .init( - panes: [pane], capabilities: ["text-v1", "history"] + (supportsScroll ? ["remote-scroll"] : []), + panes: [pane], + capabilities: ["text-v1", "history"] + (supportsScroll ? ["remote-scroll"] : []) + + (supportsScrollState ? ["scroll-state-v1"] : []), hostRunID: UUID()))) case .subscribe: + if case .subscribe(let payload) = message { includedScrollState = payload.includeScrollState } lease = UUID() onMessage?(.subscribed(.init(paneID: pane.id, subscriptionID: lease, hostRunID: UUID()))) frame(1) @@ -272,9 +353,14 @@ struct MirrorRemoteScrollTests { default: break } } - func frame(_ sequence: UInt64) { + func frame(_ sequence: UInt64, includeState: Bool = true) { + if supportsScrollState && includeState { state(sequence, atTop: atTop, atBottom: atBottom) } onMessage?(.textFrame(.init(sequence: sequence, text: "current", subscriptionID: lease))) } + func state(_ sequence: UInt64, atTop: Bool? = nil, atBottom: Bool? = nil, lease: UUID? = nil) { + onMessage?( + .scrollState(.init(atTop: atTop, atBottom: atBottom, sequence: sequence, subscriptionID: lease ?? self.lease))) + } func result(_ id: UUID, sequence: UInt64) { onMessage?(.scrollResult(.init(requestID: id, sequence: sequence, subscriptionID: lease))) } diff --git a/MirrorClient/iOS/ProwlMirror-iOSUITests/ProwlMirror_iOSUITests.swift b/MirrorClient/iOS/ProwlMirror-iOSUITests/ProwlMirror_iOSUITests.swift index a0fe4786..4855d9b5 100644 --- a/MirrorClient/iOS/ProwlMirror-iOSUITests/ProwlMirror_iOSUITests.swift +++ b/MirrorClient/iOS/ProwlMirror-iOSUITests/ProwlMirror_iOSUITests.swift @@ -2,6 +2,68 @@ import UIKit import XCTest final class ProwlMirror_iOSUITests: XCTestCase { + @MainActor + func testHostBoundariesDisableOnlyTheReachedDirection() { + let app = XCUIApplication() + app.launchArguments = ["--mirror-ui-fixture", "--mirror-ui-scroll-fixture", "--mirror-ui-scroll-boundary-fixture"] + XCUIDevice.shared.orientation = .portrait + app.launch() + let scrollUp = app.buttons["mirror-scroll-up"] + let scrollDown = app.buttons["mirror-scroll-down"] + XCTAssertTrue(scrollUp.waitForExistence(timeout: 10)) + func page(_ number: Int) -> XCUIElement { + app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "Remote page \(number)\n")).firstMatch + } + XCTAssertTrue(scrollUp.isEnabled) + XCTAssertFalse(scrollDown.isEnabled) + XCTAssertLessThan(scrollUp.frame.maxY, page(0).frame.minY) + scrollUp.tap() + XCTAssertTrue(page(-1).waitForExistence(timeout: 5)) + XCTAssertTrue(scrollUp.isEnabled && scrollDown.isEnabled) + scrollUp.tap() + XCTAssertTrue(page(-2).waitForExistence(timeout: 5)) + XCTAssertFalse(scrollUp.isEnabled) + XCTAssertTrue(scrollDown.isEnabled) + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + attachment.name = "Top controls at Host scroll boundary" + attachment.lifetime = .keepAlways + add(attachment) + scrollDown.tap() + XCTAssertTrue(page(-1).waitForExistence(timeout: 5)) + XCTAssertTrue(scrollUp.isEnabled && scrollDown.isEnabled) + scrollDown.tap() + XCTAssertTrue(page(0).waitForExistence(timeout: 5)) + XCTAssertTrue(scrollUp.isEnabled) + XCTAssertFalse(scrollDown.isEnabled) + app.buttons["History"].tap() + XCTAssertTrue(app.buttons["Load Earlier 200 Lines"].waitForExistence(timeout: 5)) + XCTAssertFalse(scrollUp.exists) + app.buttons["Live Output"].tap() + XCTAssertTrue(scrollUp.waitForExistence(timeout: 5)) + XCTAssertTrue(scrollUp.isEnabled) + XCTAssertFalse(scrollDown.isEnabled) + } + + @MainActor + func testOldHostKeepsScrollButtonsDisabledAndLocalReaderAvailable() { + let app = XCUIApplication() + app.launchArguments = ["--mirror-ui-fixture", "--mirror-ui-scroll-fixture", "--mirror-ui-no-scroll-fixture"] + XCUIDevice.shared.orientation = .portrait + app.launch() + let scrollUp = app.buttons["mirror-scroll-up"] + let scrollDown = app.buttons["mirror-scroll-down"] + XCTAssertTrue(scrollUp.waitForExistence(timeout: 10)) + XCTAssertFalse(scrollUp.isEnabled) + XCTAssertFalse(scrollDown.isEnabled) + let text = app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "Remote page 0\n")).firstMatch + XCTAssertTrue(text.exists) + XCTAssertLessThan(scrollUp.frame.maxY, text.frame.minY) + app.scrollViews["mirror-live-scroll"].swipeDown() + app.scrollViews["mirror-live-scroll"].swipeUp() + XCTAssertTrue(text.exists) + XCTAssertTrue(app.buttons["Latest"].isEnabled) + } + @MainActor func testSelectingTextDoesNotScrollTheHost() { let app = XCUIApplication() @@ -92,7 +154,7 @@ final class ProwlMirror_iOSUITests: XCTestCase { } @MainActor - func testRemoteScrollButtonsAndEdgeGesturesLeaveHistoryUnchanged() { + func testRemoteScrollButtonsLeaveLocalGesturesAndHistoryUnchanged() { let app = XCUIApplication() app.launchArguments = ["--mirror-ui-fixture", "--mirror-ui-scroll-fixture"] XCUIDevice.shared.orientation = .portrait @@ -108,11 +170,12 @@ final class ProwlMirror_iOSUITests: XCTestCase { XCTAssertTrue(page(-1).waitForExistence(timeout: 5)) down.tap() XCTAssertTrue(page(0).waitForExistence(timeout: 5)) + XCTAssertLessThan(scrollUp.frame.maxY, page(0).frame.minY) let reading = app.scrollViews["mirror-live-scroll"] reading.swipeDown() - XCTAssertTrue(page(-1).waitForExistence(timeout: 5)) + XCTAssertTrue(page(0).exists) reading.swipeUp() - XCTAssertTrue(page(0).waitForExistence(timeout: 5)) + XCTAssertTrue(page(0).exists) let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) attachment.name = "Remote scroll controls" attachment.lifetime = .keepAlways diff --git a/MirrorClient/iOS/README.md b/MirrorClient/iOS/README.md index dddb488f..af1b570a 100644 --- a/MirrorClient/iOS/README.md +++ b/MirrorClient/iOS/README.md @@ -49,10 +49,11 @@ Each pane keeps separate live/history reading positions. Erased transient output is not archived. Ghostty is not embedded in this client. On Hosts advertising `remote-scroll`, **Scroll Up** and **Scroll Down** move the -Host pane by a small, bounded scroll amount. Pull downward past the top of the live text, or -upward past its bottom, for the same action. Ordinary scrolling within the local -text remains local, and long presses keep native text selection. These controls -operate the Host's current terminal/application view; they are separate from History, +Host pane by a small, bounded scroll amount. The two buttons sit above the live +screen. Swipes only move the local reader, including at either edge, and long +presses keep native text selection. The bottom Follow latest controls remain +local reading controls. The remote scroll buttons operate the Host's current +terminal/application view; they are separate from History, whose frozen snapshots and paging are unchanged. Applications decide how terminal scroll input is handled, so the exact distance can vary. Remote scrolling changes the view seen on Host as well. @@ -63,7 +64,14 @@ scrolls are never replayed automatically. A fresh capture does not prove that th application moved, including when it is already at the beginning or end. Following latest text pauses during remote scrolling. After scrolling up, the local reader starts at the top of the returned screen; after scrolling down, it starts at the -bottom. Older Hosts keep working with remote scroll controls disabled. +bottom. Hosts without `remote-scroll` keep those two controls disabled. + +Hosts advertising `scroll-state-v1` send explicit scroll boundaries with each +screen after the client opts in. The matching screen commits those boundaries: +Scroll Up is disabled at a known top, and Scroll Down at a known bottom. Unknown +boundaries, including application-managed TUI scrolling, leave both directions +available. Unchanged screen text is never treated as proof of a boundary. Older +Hosts with `remote-scroll` but no boundary capability still support both buttons. The composer is one line when unfocused, expands while editing, then scrolls at its height limit. Send is explicit; Return inserts a newline. Two physical Returns diff --git a/MirrorRelay/main.swift b/MirrorRelay/main.swift index 1dbdf31a..b3c4fbad 100644 --- a/MirrorRelay/main.swift +++ b/MirrorRelay/main.swift @@ -74,7 +74,7 @@ enum MirrorRelay { // The formatter emits modes only when they differ from defaults. A full // reset prevents a prior frame's paste/cursor modes from leaking forward. var output = Data("\u{1b}c\u{1b}[?2026h".utf8) - output.append(payload.dropFirst(8)) + output.append(MirrorDisplayFrame.passiveSnapshot(Data(payload.dropFirst(8)))) output.append(Data("\u{1b}[?2026l".utf8)) try writeAll(output, to: STDOUT_FILENO) try writeAll( diff --git a/MirrorRelayTests/MirrorDisplayFrameTests.swift b/MirrorRelayTests/MirrorDisplayFrameTests.swift new file mode 100644 index 00000000..8145b93e --- /dev/null +++ b/MirrorRelayTests/MirrorDisplayFrameTests.swift @@ -0,0 +1,17 @@ +import Foundation +import MirrorRelayProtocol +import Testing + +struct MirrorDisplayFrameTests { + @Test func stripsOnlyUnsolicitedReportModesFromCanonicalSnapshots() { + let retained = "\u{1b}[?1000h\u{1b}[?1006h\u{1b}[?2004h\u{1b}[>11u你好🌍\u{1b}[9;5H" + let reports = "\u{1b}[?1004h\u{1b}[?2031h\u{1b}[?2048h" + #expect(MirrorDisplayFrame.passiveSnapshot(Data((reports + retained + reports).utf8)) == Data(retained.utf8)) + } + + @Test func preservesEmptyPartialAndBinaryData() { + for bytes in [Data(), Data([0, 255, 27]), Data("\u{1b}[?1004".utf8), Data("\u{1b}[?1004l".utf8)] { + #expect(MirrorDisplayFrame.passiveSnapshot(bytes) == bytes) + } + } +} diff --git a/docs-ai/067-remote-mirror/000-plan.md b/docs-ai/067-remote-mirror/000-plan.md index 980c053a..18b8cb74 100644 --- a/docs-ai/067-remote-mirror/000-plan.md +++ b/docs-ai/067-remote-mirror/000-plan.md @@ -71,3 +71,5 @@ capability needed here. Browser clients are outside this implementation. - Updated 2026-10-01: Separate mobile interactive input from task receipts — see [009](009-interactive-input.md). - Updated 2026-10-01: QR enrollment for mobile clients — see [010-qr-pairing.md](010-qr-pairing.md). - Updated 2026-10-01: Preserve History and add direct remote viewport scrolling — see [011-remote-scroll.md](011-remote-scroll.md). + +- Updated 2026-10-01: Button-only scrolling, frame-matched boundaries and passive replica report handling — see [012](012-scroll-controls-and-report-feedback.md). diff --git a/docs-ai/067-remote-mirror/011-remote-scroll.md b/docs-ai/067-remote-mirror/011-remote-scroll.md index 9d0887ac..bfaadaa4 100644 --- a/docs-ai/067-remote-mirror/011-remote-scroll.md +++ b/docs-ai/067-remote-mirror/011-remote-scroll.md @@ -32,11 +32,10 @@ direct, bounded up/down interaction with the live Host terminal on all clients. - A `scrollResult` identifies a fresh frame sequence after scrolling; this confirms terminal input and capture, not application-level page position or completion. - Clients allow one scroll at a time and provide loading, bounded timeout, - cancellation on disconnect/takeover, and up/down buttons as gesture fallback. + cancellation on disconnect/takeover, and explicit up/down buttons. No automatic retry of input after uncertain delivery. -- Mac wheel gestures scroll the remote terminal. Mobile text first scrolls its - local readable content; an outward drag at a reading boundary requests a remote - step. Directions follow ordinary scroll semantics; History is unaffected. +- Superseded interaction: remote scrolling now uses explicit top-row buttons only. + Wheel and touch gestures remain local; see [012](012-scroll-controls-and-report-feedback.md). - Local Host and remote client share the live viewport. Page distance depends on application handling and configured wheel multiplier. This is not an independent archive. diff --git a/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md b/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md new file mode 100644 index 00000000..8ed05694 --- /dev/null +++ b/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md @@ -0,0 +1,55 @@ +# 067.012 — Explicit scroll controls and replica report feedback + +Status: Implemented and locally verified (2026-10-01). + +## Context + +Device acceptance found that Mac scroll buttons briefly showed earlier output, +then returned to the active screen. Users also preferred explicit controls over +edge-triggered remote gestures and wanted the mobile controls above the reader. + +## Root cause and approach + +Replaying a styled snapshot enables Host DEC focus/color/size report modes in the +local terminal. Real Ghostty integration reproduced unsolicited input after each +frame. The relay forwarded these reports to Host, where the text binding moves +the viewport to the bottom. The display relay omits canonical mode enables +1004, 2031 and 2048, while preserving user input and keyboard/paste/mouse modes. +Ghostty itself and its bundled framework remain unchanged. + +All clients remove remote scroll gesture recognition. Native local panning, +reading and selection remain. Only top-row buttons request remote scrolling. + +A negotiated `scroll-state-v1` control precedes each matching frame for clients +that subscribe with `includeScrollState`. Optional `atTop` / `atBottom` values +commit with that frame; true disables the corresponding button. Native scrollback +geometry proves boundaries where possible. Application-owned TUI history remains +unknown; unchanged content is not evidence of a boundary. Metadata-only changes +refresh button state without changing existing binary formats or old clients. + +History remains unchanged. No Agent identity checks, Ghostty fork changes, +remote restarts, physical installs or PR publication are part of this revision. + +## Verification + +- The report regression failed before the fix with repeated unsolicited focus, + color and size reports. It passes afterward while genuine bracketed paste is + still forwarded unchanged. +- 59 Mac tests passed across protocol, Host, frame state and real Ghostty/TLS + integration; eight relay tests passed. A real Host scroll wrapper with focus + reporting enabled remains scrolled across subsequent polling. Native top/middle/ + bottom state, unknown TUI ranges and metadata-only updates are covered. +- iPhone: 58 Swift Testing cases, three XCTest cases and six UI tests passed. + iPad: eight UI tests passed, including existing reading-position regressions. + The unsigned iPhoneOS arm64 Debug build passed. +- Android: 39 JVM tests passed, one existing optional TLS fixture test skipped; + all 13 emulator UI tests passed. Debug APK and Android lint passed. +- Actual Mac windows at normal/narrow widths and mobile screenshots verified the + top controls and disabled direction. Gesture tests verify local-only reading. +- Mac Debug build, repository formatting, 210 script tests, performance-script + checks, workflow naming and string-catalog validation pass. Full `make check` + is blocked by five pre-existing + `RepositoryIconImage.swift` aspect-ratio lint violations. The relay's unchanged + `run` method also retains its baseline complexity warning under explicit lint. +- Ghostty framework SHA-256 and submodule state remain unchanged. Physical-device + and remote real-Agent acceptance of this revision remain pending. diff --git a/docs-ai/README.md b/docs-ai/README.md index 1fd62982..5509cbc0 100644 --- a/docs-ai/README.md +++ b/docs-ai/README.md @@ -126,7 +126,7 @@ agent-facing manual for that). | 064 | [agent-completion-signals](064-agent-completion-signals/000-plan.md) | 2026-08-22 | Layered agent signal bus (cooperative / launch-scoped hooks / transcript+process+OSC / heuristic), `prowl agents signal` + `agents wait` with source/confidence, per-runtime hook research, [T1 contract-test plan](064-agent-completion-signals/016-t1-contract-test-plan.md), [planned log/screen state decision](064-agent-completion-signals/017-agent-state-decision.md), [foreground/subagent findings](064-agent-completion-signals/018-foreground-and-subagent-findings.md), [foreground identity contract](064-agent-completion-signals/019-foreground-identity-contract.md), [selection channels/fallback](064-agent-completion-signals/020-selection-channel-research.md), [provider/state-machine design](064-agent-completion-signals/021-provider-state-machine-design.md), and [implementation/acceptance](064-agent-completion-signals/022-provider-implementation.md), and [continuity hardening](064-agent-completion-signals/023-provider-continuity-hardening.md), and [capture/emission corrections](064-agent-completion-signals/024-observation-order-and-emission.md) | | 065 | [bundled-agent-skills](065-bundled-agent-skills/000-plan.md) | 2026-08-22 | Bundle Prowl's official agent skills into the app, `prowl skills` install/uninstall via symlinks into agent skill folders, Agent Skills section on Settings › CLI & Skills, shared registry for 063 | | 066 | [agent-island](066-agent-island/000-plan.md) | 2026-09-01 | Notch-aware Active Agents island, global keyboard entry, Agents Display settings, and hover-revealed floating placement | -| 067 | [remote-mirror](067-remote-mirror/000-plan.md) | 2026-09-09 | App-owned Host and native remote terminal mirrors; [macOS UX and viewport](067-remote-mirror/006-macos-ux.md), [Mac pane creation](067-remote-mirror/008-client-pane-launch.md), [QR pairing](067-remote-mirror/010-qr-pairing.md) | +| 067 | [remote-mirror](067-remote-mirror/000-plan.md) | 2026-09-09 | App-owned Host and native remote terminal mirrors; [macOS UX and viewport](067-remote-mirror/006-macos-ux.md), [Mac pane creation](067-remote-mirror/008-client-pane-launch.md), [QR pairing](067-remote-mirror/010-qr-pairing.md), [explicit scrolling](067-remote-mirror/012-scroll-controls-and-report-feedback.md) | | 068 | [agent-state-providers](068-agent-state-providers/000-plan.md) | 2026-09-14 | Shared [architecture](068-agent-state-providers/architecture.md), released [Codex provider](068-agent-state-providers/codex.md), and [Claude native adapter](068-agent-state-providers/claude.md); [staged acceptance](068-agent-state-providers/002-native-runtime-implementation.md), [acceptance complete](068-agent-state-providers/001-action.md); fullscreen case excluded by owner | | 069 | [undo-close-terminal](069-undo-close-terminal/000-plan.md) | 2026-09-16 | Undo a pane or tab close with ⌘Z within Ghostty’s `undo-timeout`: closed surfaces stay alive off-tree and restore into their original tab and split position | | 070 | [app-localization](070-app-localization/000-plan.md) | 2026-09-18 | Simplified Chinese UI from a String Catalog; translations sync at release time through the `sync-l10n` skill (nothing blocks everyday work); compiler-based coverage audit plus a baseline for unlocalized copy; living [glossary](070-app-localization/glossary.md); language choice stored only in the per-app `AppleLanguages` default | diff --git a/docs/remote-mirror-wire.md b/docs/remote-mirror-wire.md index 2b42fb2d..076a4a78 100644 --- a/docs/remote-mirror-wire.md +++ b/docs/remote-mirror-wire.md @@ -99,6 +99,26 @@ takeover and a replacement subscription clear pending local waits. Duplicate IDs for the current pending or most recently completed scroll never inject input twice. No Agent dispatch or Agent-generated completion receipt participates in scrolling. +## Scroll boundary metadata + +`scroll-state-v1` is additive and requires `includeScrollState: true` on subscribe. +Only opted-in clients receive `scrollState` before every corresponding VT/text +frame, carrying `subscriptionID`, `sequence`, and optional `atTop` / `atBottom`. +Only `true` disables the relevant direction; null/missing means unknown. Clients +commit state with the matching frame (after display relay acknowledgement on Mac), +validate lease and sequence, and reset on replacement subscriptions. Boundary-only +changes participate in frame change detection. Binary frame formats are unchanged. + +The Host derives native scrollback boundaries from existing SCREEN/ACTIVE geometry. +Mouse-captured TUI input and ranges that cannot be proven return unknown. No-op +scrolling is never treated as proof of reaching an application-owned boundary. + +The Mac display relay suppresses canonical DEC mode enables 1004, 2031 and 2048 +from snapshot replay. Focus, color and size reports belong to the original Host +terminal; enabling them in a replica would manufacture input sent back to Host. +Keyboard, paste and mouse input modes remain intact. This changes presentation, +not remote frame encoding or the original terminal state. + ## Native scrollback text for terminal clients `viewport-text-v1` is a separate additive capability. A terminal client opts in diff --git a/docs/remote-mirror.md b/docs/remote-mirror.md index d4c75971..4cb644f1 100644 --- a/docs/remote-mirror.md +++ b/docs/remote-mirror.md @@ -99,18 +99,22 @@ A project that has never opened a terminal is not yet a pane, but its worktree i available in **New Pane…** on a current Host. The Host terminal owns the grid. Mac clients default to **Fit to Window**, shrinking the complete terminal to fit without changing the Host PTY. **Original Size** restores readable native-size -glyphs with local panning (hold Option while scrolling when remote scrolling is available). It starts at the top; subsequent window resizes preserve +glyphs with local panning. It starts at the top; subsequent window resizes preserve the manual scroll position within the available bounds. Large Host grids can make Fit to Window text small. iOS renders replacement text with local reflow. Cleared output is not an archive. On a Host advertising remote scrolling, **Scroll Up** and **Scroll Down** move -the live Host terminal by a small wheel step. Mac vertical wheel/trackpad gestures -use the same controls; one trackpad gesture requests one step. On phones, scroll -the current text normally, then pull outward at the top for earlier content or -at the bottom for later content. The new view stays at the corresponding edge. -Buttons remain available -when a gesture is inconvenient. Horizontal scrolling remains local. +the live Host terminal by a small wheel step. These buttons sit above the live +view on all clients. Wheel and touch gestures only move the local readable view; +they never request remote scrolling. Follow latest remains below the mobile reader. + +A current Host sends frame-matched boundary state. **Scroll Up** is disabled at +a known top and **Scroll Down** at a known bottom. Native terminal scrollback can +provide these boundaries. Application-owned TUI history, or a screen whose range +cannot be established, leaves them unknown and the buttons available. An unchanged +frame does not prove a boundary. Older Hosts without boundary metadata keep both +directions available when remote scrolling is supported. **Scrolling…** remains visible until a fresh Host frame confirms the request, or a five-second timeout ends the wait. A boundary that does not change the frame diff --git a/supacode/Features/RemoteMirror/GhosttyMirrorPaneSource.swift b/supacode/Features/RemoteMirror/GhosttyMirrorPaneSource.swift index 60c35344..f8f4e0b9 100644 --- a/supacode/Features/RemoteMirror/GhosttyMirrorPaneSource.swift +++ b/supacode/Features/RemoteMirror/GhosttyMirrorPaneSource.swift @@ -38,6 +38,7 @@ final class GhosttyMirrorPaneSource: MirrorPaneSource { } var supportsViewportText: Bool { true } + var supportsScrollState: Bool { true } func snapshot(_ id: UUID) throws -> MirrorFrame { guard let terminal = view(id)?.surface else { throw MirrorProtocolError.invalidMessage } @@ -56,7 +57,8 @@ final class GhosttyMirrorPaneSource: MirrorPaneSource { guard try captureGeometry(terminal) == geometry else { throw MirrorPaneSourceError.captureChanged } return MirrorFrame( columns: geometry.columns, rows: geometry.rows, - bytes: Data(bytes: bytes, count: Int(text.text_len)), viewportText: viewportText) + bytes: Data(bytes: bytes, count: Int(text.text_len)), viewportText: viewportText, + scrollBounds: geometry.scrollBounds) } func write(_ bytes: Data, to id: UUID) throws { @@ -118,7 +120,7 @@ final class GhosttyMirrorPaneSource: MirrorPaneSource { let geometry = try captureGeometry(terminal) let text = try viewportText(terminal, columns: geometry.columns, rows: geometry.rows, preserveRows: false) guard try captureGeometry(terminal) == geometry else { throw MirrorPaneSourceError.captureChanged } - return .init(text: text, columns: geometry.columns, rows: geometry.rows) + return .init(text: text, columns: geometry.columns, rows: geometry.rows, scrollBounds: geometry.scrollBounds) } private struct Probe: Equatable { @@ -131,6 +133,15 @@ final class GhosttyMirrorPaneSource: MirrorPaneSource { let columns: UInt32 let rows: UInt32 let active: Probe + let screen: Probe + let mouseCaptured: Bool + + var scrollBounds: MirrorScrollBounds? { + // Only native scrollback has a provable range. An unscrolled screen with + // no retained rows may be an alternate-screen TUI with its own history. + guard !mouseCaptured, active.isScrolled || screen.pixelY < 0 else { return nil } + return .init(atTop: screen.pixelY >= 0 && screen.offset == 0, atBottom: !active.isScrolled) + } } private func captureGeometry(_ terminal: ghostty_surface_t) throws -> CaptureGeometry { @@ -139,13 +150,16 @@ final class GhosttyMirrorPaneSource: MirrorPaneSource { throw MirrorProtocolError.invalidMessage } let active = try probe(terminal, tag: GHOSTTY_POINT_ACTIVE, coordinate: GHOSTTY_POINT_COORD_EXACT) + let screen = try probe(terminal, tag: GHOSTTY_POINT_SCREEN, coordinate: GHOSTTY_POINT_COORD_EXACT) let bottom = try probe(terminal, tag: GHOSTTY_POINT_VIEWPORT, coordinate: GHOSTTY_POINT_COORD_BOTTOM_RIGHT) let columns = UInt32(size.columns) let rows = UInt32(size.rows) // Surface dimensions can advance before the terminal IO thread applies a // resize. The viewport's final cell verifies the terminal grid itself. guard bottom.pixelY >= 0, bottom.offset == columns * rows - 1 else { throw MirrorPaneSourceError.captureChanged } - return .init(columns: columns, rows: rows, active: active) + return .init( + columns: columns, rows: rows, active: active, screen: screen, + mouseCaptured: ghostty_surface_mouse_captured(terminal)) } private func probe( diff --git a/supacode/Features/RemoteMirror/MirrorClient.swift b/supacode/Features/RemoteMirror/MirrorClient.swift index 594a22db..c91474c8 100644 --- a/supacode/Features/RemoteMirror/MirrorClient.swift +++ b/supacode/Features/RemoteMirror/MirrorClient.swift @@ -23,6 +23,8 @@ final class MirrorClient: Identifiable { private(set) var supportsShellLaunch = false private(set) var supportsHistory = true private(set) var supportsRemoteScroll = false + private(set) var supportsScrollState = false + private(set) var scrollBoundsState = MirrorScrollBoundsState() private(set) var supportsViewportText = false private(set) var viewportState = MirrorViewportState() let scrollState = MirrorScrollState() @@ -83,7 +85,9 @@ final class MirrorClient: Identifiable { scrollState.reset() supportsRemoteScroll = false supportsViewportText = false + supportsScrollState = false viewportState = MirrorViewportState() + scrollBoundsState = MirrorScrollBoundsState() isSubscribed = false isConnecting = true let peer = makeConnection(configuration) @@ -153,6 +157,12 @@ final class MirrorClient: Identifiable { return } } + if self.supportsScrollState { + do { try self.scrollBoundsState.didPresent(sequence: sequence) } catch { + self.peer?.close(String(localized: "Invalid Host scroll state.")) + return + } + } self.scrollState.didPresent(sequence: sequence) } self.peer?.send(message) @@ -163,7 +173,8 @@ final class MirrorClient: Identifiable { .subscribe( .init( paneID: pane.id, representation: .terminal, intent: resumeIntent, - includeViewportText: supportsViewportText ? true : nil)) + includeViewportText: supportsViewportText ? true : nil, + includeScrollState: supportsScrollState ? true : nil)) ) } catch { peer?.close(error.localizedDescription) } } @@ -179,6 +190,7 @@ final class MirrorClient: Identifiable { isLoadingHistory = false scrollState.reset() viewportState = MirrorViewportState() + scrollBoundsState = MirrorScrollBoundsState() isSubscribed = false subscriptionID = nil historyLines = [] @@ -203,12 +215,13 @@ final class MirrorClient: Identifiable { subscriptionID: subscriptionID))) } - var canScroll: Bool { + func canScroll(_ direction: MirrorMessage.ScrollDirection) -> Bool { isSubscribed && supportsRemoteScroll && !showsHistory && !scrollState.isLoading + && scrollBoundsState.canScroll(direction) } func scroll(_ direction: MirrorMessage.ScrollDirection) { - guard canScroll, let subscriptionID, let requestID = scrollState.begin() else { return } + guard canScroll(direction), let subscriptionID, let requestID = scrollState.begin() else { return } peer?.send(.scroll(.init(requestID: requestID, direction: direction, subscriptionID: subscriptionID))) } @@ -231,6 +244,7 @@ final class MirrorClient: Identifiable { subscriptionID = id scrollState.reset() viewportState = MirrorViewportState() + scrollBoundsState = MirrorScrollBoundsState() historyID = nil historyLines = [] historyOffset = 0 @@ -241,6 +255,8 @@ final class MirrorClient: Identifiable { receiveFrame(message) case .viewport: receiveViewport(message) + case .scrollState: + receiveScrollBounds(message) case .historyPage: if historyID == nil { historyPageGate = MirrorHistoryPageGate() } guard isLoadingHistory, message.subscriptionID == subscriptionID, @@ -279,6 +295,15 @@ final class MirrorClient: Identifiable { return } } + if supportsScrollState { + do { + guard let sequence = message.sequence else { throw MirrorProtocolError.invalidMessage } + try scrollBoundsState.receiveFrame(sequence: sequence) + } catch { + peer?.close(String(localized: "Invalid Host scroll state.")) + return + } + } isSubscribed = true replica.display(message) } @@ -295,6 +320,18 @@ final class MirrorClient: Identifiable { } } + private func receiveScrollBounds(_ message: MirrorMessage) { + guard supportsScrollState, subscriptionID != nil, message.subscriptionID == subscriptionID, + case .scrollState(let payload) = message + else { + peer?.close(String(localized: "Unexpected Host scroll state.")) + return + } + do { try scrollBoundsState.stage(payload) } catch { + peer?.close(String(localized: "Invalid Host scroll state.")) + } + } + private func receiveScrollResult(_ message: MirrorMessage) { guard message.subscriptionID == subscriptionID, let requestID = message.scrollRequestID, let sequence = message.sequence @@ -346,6 +383,7 @@ final class MirrorClient: Identifiable { supportsShellLaunch = message.capabilities?.contains("launch-shell") == true supportsHistory = message.capabilities?.contains("history") == true supportsRemoteScroll = message.capabilities?.contains("remote-scroll") == true + supportsScrollState = message.capabilities?.contains("scroll-state-v1") == true supportsViewportText = message.capabilities?.contains("viewport-text-v1") == true supportsTakeover = message.capabilities?.contains("takeover") == true isConnecting = false diff --git a/supacode/Features/RemoteMirror/MirrorHost.swift b/supacode/Features/RemoteMirror/MirrorHost.swift index 205e29eb..b2a5500b 100644 --- a/supacode/Features/RemoteMirror/MirrorHost.swift +++ b/supacode/Features/RemoteMirror/MirrorHost.swift @@ -153,6 +153,7 @@ final class MirrorHost { let id = UUID() var representation: MirrorMessage.Representation = .terminal var includeViewportText = false + var includeScrollState = false var unstableCaptures = 0 var gate = MirrorFrameGate() var textGate = MirrorTextFrameGate() @@ -508,7 +509,8 @@ final class MirrorHost { ? ["launch-profile", "launch-shell", "agents-dispatch", "agent-input"] : []) + (source.supportsBoundedHistory ? ["history"] : []) + (source.supportsRemoteScroll ? ["remote-scroll"] : []) - + (source.supportsViewportText ? ["viewport-text-v1"] : []), hostRunID: hostRunID))) + + (source.supportsViewportText ? ["viewport-text-v1"] : []) + + (source.supportsScrollState ? ["scroll-state-v1"] : []), hostRunID: hostRunID))) case .command: try handleCommand(message, peer: peer) case .commandReceipt: @@ -700,7 +702,8 @@ final class MirrorHost { var next = Subscription( paneID: paneID, representation: representation, includeViewportText: representation == .terminal && message.includeViewportText == true - && source.supportsViewportText) + && source.supportsViewportText, + includeScrollState: message.includeScrollState == true && source.supportsScrollState) // Prepare and encode before revoking the old lease. Capture failure leaves it intact. let first = try capture(&next) if let first { _ = try MirrorWire.encode(first) } @@ -747,18 +750,20 @@ final class MirrorHost { guard subscription.textGate.outstanding == nil else { return nil } let captured = try source.textSnapshot(subscription.paneID) let text = captured.text + let bounds = subscription.includeScrollState ? captured.scrollBounds : nil guard let sequence = subscription.textGate.offer( - text, columns: captured.columns, rows: captured.rows, truncated: captured.truncated) + text, columns: captured.columns, rows: captured.rows, truncated: captured.truncated, scrollBounds: bounds) else { return nil } return .textFrame( .init( columns: captured.columns, rows: captured.rows, truncated: captured.truncated, - sequence: sequence, text: text, subscriptionID: subscription.id)) + sequence: sequence, text: text, subscriptionID: subscription.id, scrollBounds: bounds)) } guard subscription.gate.outstanding == nil else { return nil } var frame = try source.snapshot(subscription.paneID) if !subscription.includeViewportText { frame.viewportText = nil } + if !subscription.includeScrollState { frame.scrollBounds = nil } guard let sequence = subscription.gate.offer(frame) else { return nil } return .frame(.init(frame: frame, sequence: sequence, subscriptionID: subscription.id)) } @@ -768,6 +773,18 @@ final class MirrorHost { peer.send( .viewport(.init(text: payload.frame.viewportText, sequence: payload.sequence, subscriptionID: subscription.id))) } + if subscription.includeScrollState, let sequence = message.sequence { + let bounds: MirrorScrollBounds? + switch message { + case .frame(let payload): bounds = payload.frame.scrollBounds + case .textFrame(let payload): bounds = payload.scrollBounds + default: bounds = nil + } + peer.send( + .scrollState( + .init( + atTop: bounds?.atTop, atBottom: bounds?.atBottom, sequence: sequence, subscriptionID: subscription.id))) + } peer.send(message) } diff --git a/supacode/Features/RemoteMirror/MirrorPaneSource.swift b/supacode/Features/RemoteMirror/MirrorPaneSource.swift index 1b5de753..a76affde 100644 --- a/supacode/Features/RemoteMirror/MirrorPaneSource.swift +++ b/supacode/Features/RemoteMirror/MirrorPaneSource.swift @@ -8,6 +8,7 @@ protocol MirrorPaneSource { func textSnapshot(_ id: UUID) throws -> MirrorTextSnapshot func activeText(_ id: UUID) throws -> String var supportsViewportText: Bool { get } + var supportsScrollState: Bool { get } var supportsRemoteScroll: Bool { get } func scroll(_ direction: MirrorMessage.ScrollDirection, to id: UUID) throws var supportsBoundedHistory: Bool { get } @@ -19,11 +20,13 @@ struct MirrorTextSnapshot { var columns: UInt32 = 0 var rows: UInt32 = 0 var truncated = false + var scrollBounds: MirrorScrollBounds? } extension MirrorPaneSource { func textSnapshot(_ id: UUID) throws -> MirrorTextSnapshot { .init(text: try activeText(id)) } var supportsViewportText: Bool { false } + var supportsScrollState: Bool { false } var supportsRemoteScroll: Bool { false } func scroll(_ direction: MirrorMessage.ScrollDirection, to id: UUID) throws { throw MirrorProtocolError.invalidMessage diff --git a/supacode/Features/RemoteMirror/MirrorProtocol.swift b/supacode/Features/RemoteMirror/MirrorProtocol.swift index 796d9c77..54085dd2 100644 --- a/supacode/Features/RemoteMirror/MirrorProtocol.swift +++ b/supacode/Features/RemoteMirror/MirrorProtocol.swift @@ -15,6 +15,12 @@ nonisolated struct MirrorFrame: Codable, Equatable, Sendable { let rows: UInt32 let bytes: Data var viewportText: String? + var scrollBounds: MirrorScrollBounds? +} + +nonisolated struct MirrorScrollBounds: Codable, Equatable, Sendable { + var atTop: Bool? + var atBottom: Bool? } nonisolated enum MirrorMessage: Codable, Sendable { @@ -52,7 +58,7 @@ nonisolated enum MirrorMessage: Codable, Sendable { enum Kind: String, Codable { case challenge, authenticate, pair, paired, authenticated, list, panes, subscribe, subscribed, frame, textFrame, viewport, acknowledge, input, history, historyPage, failure, ping, pong, ended, - refresh, scroll, scrollResult, command, commandResult, commandReceipt + refresh, scroll, scrollResult, scrollState, command, commandResult, commandReceipt } case list case panes(PanesPayload) @@ -67,6 +73,7 @@ nonisolated enum MirrorMessage: Codable, Sendable { var representation: Representation var intent: Intent var includeViewportText: Bool? + var includeScrollState: Bool? } case subscribed(SubscribedPayload) struct SubscribedPayload: Codable, Sendable { @@ -88,6 +95,7 @@ nonisolated enum MirrorMessage: Codable, Sendable { var sequence: UInt64 var text: String var subscriptionID: UUID + var scrollBounds: MirrorScrollBounds? } case viewport(ViewportPayload) struct ViewportPayload: Codable, Sendable { @@ -117,6 +125,13 @@ nonisolated enum MirrorMessage: Codable, Sendable { var sequence: UInt64 var subscriptionID: UUID } + case scrollState(ScrollStatePayload) + struct ScrollStatePayload: Codable, Sendable { + var atTop: Bool? + var atBottom: Bool? + var sequence: UInt64 + var subscriptionID: UUID + } case history(HistoryPayload) struct HistoryPayload: Codable, Sendable { var historyID: UUID? @@ -181,6 +196,7 @@ nonisolated enum MirrorMessage: Codable, Sendable { case .input: .input case .scroll: .scroll case .scrollResult: .scrollResult + case .scrollState: .scrollState case .history: .history case .historyPage: .historyPage case .failure: .failure @@ -241,6 +257,7 @@ nonisolated enum MirrorMessage: Codable, Sendable { case .input(let payload): payload.subscriptionID case .scroll(let payload): payload.subscriptionID case .scrollResult(let payload): payload.subscriptionID + case .scrollState(let payload): payload.subscriptionID case .history(let payload): payload.subscriptionID case .historyPage(let payload): payload.subscriptionID case .failure(let payload): payload.subscriptionID @@ -263,6 +280,7 @@ nonisolated enum MirrorMessage: Codable, Sendable { case .viewport(let payload): payload.sequence case .acknowledge(let payload): payload.sequence case .scrollResult(let payload): payload.sequence + case .scrollState(let payload): payload.sequence default: nil } } @@ -284,6 +302,18 @@ nonisolated enum MirrorMessage: Codable, Sendable { default: nil } } + var includeScrollState: Bool? { + switch self { + case .subscribe(let payload): payload.includeScrollState + default: nil + } + } + var scrollBounds: MirrorScrollBounds? { + switch self { + case .scrollState(let payload): .init(atTop: payload.atTop, atBottom: payload.atBottom) + default: nil + } + } var bytes: Data? { switch self { case .input(let payload): payload.bytes @@ -499,10 +529,12 @@ nonisolated struct MirrorTextFrameGate { var outstanding: UInt64? { gate.outstanding } mutating func offer( - _ text: String, columns: UInt32 = 0, rows: UInt32 = 0, truncated: Bool = false + _ text: String, columns: UInt32 = 0, rows: UInt32 = 0, truncated: Bool = false, + scrollBounds: MirrorScrollBounds? = nil ) -> UInt64? { gate.offer( - MirrorFrame(columns: columns, rows: rows, bytes: Data([truncated ? 1 : 0]) + Data(text.utf8))) + MirrorFrame( + columns: columns, rows: rows, bytes: Data([truncated ? 1 : 0]) + Data(text.utf8), scrollBounds: scrollBounds)) } mutating func acknowledge(_ sequence: UInt64) throws { diff --git a/supacode/Features/RemoteMirror/MirrorScrollBoundsState.swift b/supacode/Features/RemoteMirror/MirrorScrollBoundsState.swift new file mode 100644 index 00000000..b9a4451c --- /dev/null +++ b/supacode/Features/RemoteMirror/MirrorScrollBoundsState.swift @@ -0,0 +1,32 @@ +import Foundation + +struct MirrorScrollBoundsState { + private(set) var bounds: MirrorScrollBounds? + private var pending: MirrorMessage.ScrollStatePayload? + private var receivedSequence: UInt64? + private var presentedSequence: UInt64 = 0 + + func canScroll(_ direction: MirrorMessage.ScrollDirection) -> Bool { + (direction == .upward ? bounds?.atTop : bounds?.atBottom) != true + } + + mutating func stage(_ payload: MirrorMessage.ScrollStatePayload) throws { + guard pending == nil, payload.sequence > presentedSequence else { throw MirrorProtocolError.invalidMessage } + pending = payload + } + + mutating func receiveFrame(sequence: UInt64) throws { + guard pending?.sequence == sequence, receivedSequence == nil else { throw MirrorProtocolError.invalidMessage } + receivedSequence = sequence + } + + mutating func didPresent(sequence: UInt64) throws { + guard let pending, pending.sequence == sequence, receivedSequence == sequence else { + throw MirrorProtocolError.invalidMessage + } + bounds = .init(atTop: pending.atTop, atBottom: pending.atBottom) + presentedSequence = sequence + self.pending = nil + receivedSequence = nil + } +} diff --git a/supacode/Features/RemoteMirror/MirrorTerminalViewport.swift b/supacode/Features/RemoteMirror/MirrorTerminalViewport.swift index c8ff4b18..c21fe74f 100644 --- a/supacode/Features/RemoteMirror/MirrorTerminalViewport.swift +++ b/supacode/Features/RemoteMirror/MirrorTerminalViewport.swift @@ -7,18 +7,15 @@ struct MirrorTerminalViewport: NSViewRepresentable { let displaySize: CGSize var fitsWindow = true var viewportText: String? - var onRemoteScroll: ((MirrorMessage.ScrollDirection) -> Void)? func makeNSView(context: Context) -> MirrorTerminalScrollView { let view = MirrorTerminalScrollView(surface: surface, displaySize: displaySize, fitsWindow: fitsWindow) - view.onRemoteScroll = onRemoteScroll view.updateViewportText(viewportText) return view } func updateNSView(_ view: MirrorTerminalScrollView, context: Context) { view.update(surface: surface, displaySize: displaySize, fitsWindow: fitsWindow) - view.onRemoteScroll = onRemoteScroll view.updateViewportText(viewportText) } } @@ -61,8 +58,6 @@ final class MirrorTerminalScrollView: NSScrollView { private var fitsWindow: Bool private var resetOrigin = true private var isLayingOut = false - var onRemoteScroll: ((MirrorMessage.ScrollDirection) -> Void)? - private var scrollGesture = MirrorScrollGesture() var displaySize: CGSize { didSet { if displaySize != oldValue { needsLayout = true } } } @@ -130,21 +125,6 @@ final class MirrorTerminalScrollView: NSScrollView { fatalError("init(coder:) is not supported") } - override func scrollWheel(with event: NSEvent) { - guard let onRemoteScroll, !event.modifierFlags.contains(.option), - abs(event.scrollingDeltaY) >= abs(event.scrollingDeltaX) - else { - super.scrollWheel(with: event) - return - } - if let direction = scrollGesture.consume( - delta: event.scrollingDeltaY, precise: event.hasPreciseScrollingDeltas, - phase: event.phase, momentum: event.momentumPhase, timestamp: event.timestamp) - { - onRemoteScroll(direction) - } - } - override func layout() { guard !isLayingOut else { return } isLayingOut = true @@ -193,34 +173,3 @@ final class MirrorTerminalScrollView: NSScrollView { } } } - -struct MirrorScrollGesture { - private var accumulated: CGFloat = 0 - private var sentForGesture = false - private var lastWheelTime: TimeInterval = -.infinity - - mutating func consume( - delta: CGFloat, precise: Bool, phase: NSEvent.Phase, momentum: NSEvent.Phase, timestamp: TimeInterval - ) -> MirrorMessage.ScrollDirection? { - guard momentum.isEmpty else { return nil } - if phase.contains(.began) { - accumulated = 0 - sentForGesture = false - } - if phase.contains(.ended) || phase.contains(.cancelled) { - accumulated = 0 - sentForGesture = false - return nil - } - guard delta != 0, phase.isEmpty || !sentForGesture else { return nil } - if phase.isEmpty, timestamp - lastWheelTime < 0.3 { return nil } - if accumulated * delta < 0 { accumulated = 0 } - accumulated += delta - guard abs(accumulated) >= (precise ? 30 : 1) else { return nil } - let direction: MirrorMessage.ScrollDirection = accumulated > 0 ? .upward : .downward - accumulated = 0 - sentForGesture = true - lastWheelTime = timestamp - return direction - } -} diff --git a/supacode/Features/RemoteMirror/RelayWire/MirrorDisplayFrame.swift b/supacode/Features/RemoteMirror/RelayWire/MirrorDisplayFrame.swift new file mode 100644 index 00000000..ccbd492e --- /dev/null +++ b/supacode/Features/RemoteMirror/RelayWire/MirrorDisplayFrame.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Host snapshots contain canonical, individually encoded DEC modes. A display +/// replica must not enable unsolicited reports: its PTY input goes back to Host. +public nonisolated enum MirrorDisplayFrame { + public static func passiveSnapshot(_ snapshot: Data) -> Data { + let bytes = Array(snapshot) + var output = Data() + var start = 0 + var index = 0 + while index + 8 <= bytes.count { + if bytes[index] == 0x1B, bytes[index + 1] == 0x5B, bytes[index + 2] == 0x3F, + bytes[index + 7] == 0x68, + let digits = String(bytes: bytes[(index + 3)..<(index + 7)], encoding: .ascii), + let mode = Int(digits), + mode == 1004 || mode == 2031 || mode == 2048 + { + output.append(contentsOf: bytes[start.. MirrorTextSnapshot { + .init(text: try activeText(id), scrollBounds: scrollBounds) + } func panes() -> [MirrorPaneDescriptor] { let primary = MirrorPaneDescriptor(id: id, title: "Fixture", directory: "/", busy: false) return includesOtherPane @@ -734,7 +818,8 @@ struct MirrorHostTests { unstableCaptures -= 1 throw MirrorPaneSourceError.captureChanged } - return MirrorFrame(columns: 80, rows: 24, bytes: frameBytes, viewportText: viewportText) + return MirrorFrame( + columns: 80, rows: 24, bytes: frameBytes, viewportText: viewportText, scrollBounds: scrollBounds) } func write(_ bytes: Data, to id: UUID) throws { input.append(bytes) } var supportsBoundedHistory: Bool { true } diff --git a/supacodeTests/RemoteMirror/MirrorProtocolTests.swift b/supacodeTests/RemoteMirror/MirrorProtocolTests.swift index 61d0787a..ae08c57b 100644 --- a/supacodeTests/RemoteMirror/MirrorProtocolTests.swift +++ b/supacodeTests/RemoteMirror/MirrorProtocolTests.swift @@ -66,6 +66,37 @@ struct MirrorProtocolTests { #expect(gate.offer(frame) == 4) } + @Test func scrollStatePreservesUnknownAndLeavesBinaryRepresentationsUnchanged() throws { + let lease = UUID() + for bounds in [MirrorScrollBounds(), .init(atTop: true, atBottom: false), .init(atTop: false, atBottom: true)] { + let message = MirrorMessage.scrollState( + .init( + atTop: bounds.atTop, atBottom: bounds.atBottom, sequence: 7, subscriptionID: lease)) + let wire = try MirrorWire.encode(message) + let decoded = try MirrorWire.decode(wire.dropFirst(4)) + #expect(decoded.kind == .scrollState) + #expect(decoded.subscriptionID == lease) + #expect(decoded.sequence == 7) + #expect(decoded.scrollBounds == bounds) + } + let frame = MirrorFrame(columns: 80, rows: 24, bytes: Data("screen".utf8)) + var bounded = frame + bounded.scrollBounds = .init(atTop: false, atBottom: true) + #expect( + try MirrorWire.encode(.frame(.init(frame: frame, sequence: 1, subscriptionID: lease))) + == MirrorWire.encode(.frame(.init(frame: bounded, sequence: 1, subscriptionID: lease)))) + let text = MirrorMessage.TextFramePayload(sequence: 1, text: "screen", subscriptionID: lease) + var boundedText = text + boundedText.scrollBounds = bounded.scrollBounds + #expect(try MirrorWire.encode(.textFrame(text)) == MirrorWire.encode(.textFrame(boundedText))) + let legacy = MirrorMessage.subscribe(.init(paneID: UUID(), representation: .text, intent: .ifFree)) + #expect(try MirrorWire.decode(MirrorWire.encode(legacy).dropFirst(4)).includeScrollState == nil) + var gate = MirrorTextFrameGate() + #expect(gate.offer("screen") == 1) + try gate.acknowledge(1) + #expect(gate.offer("screen", scrollBounds: bounded.scrollBounds) == 2) + } + @Test func refreshPreservesBackpressureAndSequence() throws { var gate = MirrorTextFrameGate() #expect(gate.offer("unchanged") == 1) diff --git a/supacodeTests/RemoteMirror/MirrorReplicaInputTests.swift b/supacodeTests/RemoteMirror/MirrorReplicaInputTests.swift new file mode 100644 index 00000000..9a10957e --- /dev/null +++ b/supacodeTests/RemoteMirror/MirrorReplicaInputTests.swift @@ -0,0 +1,67 @@ +import AppKit +import Foundation +import Testing + +@testable import supacode + +@Suite(.serialized) +@MainActor +struct MirrorReplicaInputTests { + private static let runtime = GhosttyRuntime() + + @Test(.timeLimit(.minutes(1))) func replayDoesNotForwardAutomaticTerminalReports() async throws { + let replica = MirrorReplica(runtime: Self.runtime) + let lease = UUID() + var input = Data() + var acknowledged: UInt64 = 0 + replica.onMessage = { message in + if case .input(let payload) = message { input.append(payload.bytes) } + if case .acknowledge(let payload) = message { acknowledged = payload.sequence } + } + defer { replica.stop() } + try replica.start() + try await wait { replica.view != nil } + let view = try #require(replica.view) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = view + defer { window.close() } + for sequence in 1...3 { + replica.display( + .frame( + .init( + frame: .init( + columns: 80, rows: 24, + bytes: Data("\u{1b}[?1004h\u{1b}[?2031h\u{1b}[?2048h\u{1b}[?2004hSCREEN \(sequence)".utf8)), + sequence: UInt64(sequence), subscriptionID: lease))) + try await wait { acknowledged == UInt64(sequence) } + } + // Reports are generated asynchronously after the helper writes and ACKs a frame. + let deadline = ContinuousClock.now.advanced(by: .milliseconds(500)) + try await wait { ContinuousClock.now >= deadline } + #expect(input.isEmpty, "Frame replay generated Host input: \(Array(input))") + + view.insertText("用户输入", replacementRange: NSRange(location: NSNotFound, length: 0)) + try await wait { (String(data: input, encoding: .utf8) ?? "").contains("用户输入") } + #expect((String(data: input, encoding: .utf8) ?? "").contains("\u{1b}[200~用户输入\u{1b}[201~")) + } + + private func wait(until condition: @MainActor () -> Bool) async throws { + let (ticks, continuation) = AsyncStream.makeStream() + let timer = Timer.scheduledTimer(withTimeInterval: 0.025, repeats: true) { _ in continuation.yield(()) } + defer { + timer.invalidate() + continuation.finish() + } + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + for await _ in ticks { + if condition() { return } + if ContinuousClock.now >= deadline { throw Timeout() } + } + throw Timeout() + } + + private struct Timeout: Error {} +} diff --git a/supacodeTests/RemoteMirror/MirrorScrollBoundsStateTests.swift b/supacodeTests/RemoteMirror/MirrorScrollBoundsStateTests.swift new file mode 100644 index 00000000..5522f4b7 --- /dev/null +++ b/supacodeTests/RemoteMirror/MirrorScrollBoundsStateTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct MirrorScrollBoundsStateTests { + @Test func boundariesCommitOnlyWithMatchingPresentedFrame() throws { + var state = MirrorScrollBoundsState() + let lease = UUID() + #expect(state.canScroll(.upward) && state.canScroll(.downward)) + try state.stage(.init(atTop: true, atBottom: false, sequence: 1, subscriptionID: lease)) + try state.receiveFrame(sequence: 1) + #expect(state.canScroll(.upward)) + try state.didPresent(sequence: 1) + #expect(!state.canScroll(.upward) && state.canScroll(.downward)) + try state.stage(.init(atTop: false, atBottom: true, sequence: 2, subscriptionID: lease)) + try state.receiveFrame(sequence: 2) + try state.didPresent(sequence: 2) + #expect(state.canScroll(.upward) && !state.canScroll(.downward)) + try state.stage(.init(atTop: nil, atBottom: nil, sequence: 3, subscriptionID: lease)) + try state.receiveFrame(sequence: 3) + try state.didPresent(sequence: 3) + #expect(state.canScroll(.upward) && state.canScroll(.downward)) + } + + @Test func rejectsMissingDuplicateAndMismatchedMetadata() throws { + var state = MirrorScrollBoundsState() + #expect(throws: MirrorProtocolError.self) { try state.receiveFrame(sequence: 1) } + let payload = MirrorMessage.ScrollStatePayload(atTop: true, atBottom: true, sequence: 1, subscriptionID: UUID()) + try state.stage(payload) + #expect(throws: MirrorProtocolError.self) { try state.stage(payload) } + #expect(throws: MirrorProtocolError.self) { try state.receiveFrame(sequence: 2) } + #expect(throws: MirrorProtocolError.self) { try state.didPresent(sequence: 1) } + try state.receiveFrame(sequence: 1) + #expect(throws: MirrorProtocolError.self) { try state.receiveFrame(sequence: 1) } + #expect(throws: MirrorProtocolError.self) { try state.didPresent(sequence: 2) } + try state.didPresent(sequence: 1) + #expect(!state.canScroll(.upward) && !state.canScroll(.downward)) + #expect(throws: MirrorProtocolError.self) { try state.stage(payload) } + state = MirrorScrollBoundsState() + #expect(state.canScroll(.upward) && state.canScroll(.downward)) + try state.stage(payload) + } +} diff --git a/supacodeTests/RemoteMirror/MirrorScrollStateTests.swift b/supacodeTests/RemoteMirror/MirrorScrollStateTests.swift index a320bc9a..07427380 100644 --- a/supacodeTests/RemoteMirror/MirrorScrollStateTests.swift +++ b/supacodeTests/RemoteMirror/MirrorScrollStateTests.swift @@ -69,22 +69,3 @@ struct MirrorScrollStateTests { #expect(state.error == nil) } } - -struct MirrorScrollGestureTests { - @Test func onePagePerTrackpadGestureAndNoMomentumRequests() { - var gesture = MirrorScrollGesture() - #expect(gesture.consume(delta: 10, precise: true, phase: .began, momentum: [], timestamp: 1) == nil) - #expect(gesture.consume(delta: 25, precise: true, phase: .changed, momentum: [], timestamp: 2) == .upward) - #expect(gesture.consume(delta: 50, precise: true, phase: .changed, momentum: [], timestamp: 3) == nil) - #expect(gesture.consume(delta: 0, precise: true, phase: .ended, momentum: [], timestamp: 4) == nil) - #expect(gesture.consume(delta: 50, precise: true, phase: [], momentum: .changed, timestamp: 5) == nil) - #expect(gesture.consume(delta: -35, precise: true, phase: .began, momentum: [], timestamp: 6) == .downward) - } - - @Test func discreteWheelIsRateLimitedWithoutAccumulatingDelayedPages() { - var gesture = MirrorScrollGesture() - #expect(gesture.consume(delta: 1, precise: false, phase: [], momentum: [], timestamp: 1) == .upward) - #expect(gesture.consume(delta: 10, precise: false, phase: [], momentum: [], timestamp: 1.1) == nil) - #expect(gesture.consume(delta: -1, precise: false, phase: [], momentum: [], timestamp: 1.5) == .downward) - } -} diff --git a/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift b/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift index 998d103c..5c483269 100644 --- a/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift +++ b/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift @@ -18,9 +18,13 @@ struct MirrorTerminalIntegrationTests { let fixture = try Fixture() defer { fixture.close() } try await fixture.wait("Host program ready") { fixture.hostText.contains("READY") } + let hostWindow = try #require(fixture.hostView.window) + let wrapper = GhosttySurfaceScrollView(surfaceView: fixture.hostView, hostKind: .terminal) + hostWindow.contentView = wrapper + wrapper.layoutSubtreeIfNeeded() try await fixture.startHost() let client = try await fixture.connect() - try fixture.send("history") + try fixture.send("focus-history") try await fixture.waitForMirror(client, containing: "HISTORY:450") try #require(client.supportsRemoteScroll) client.loadHistory(refresh: true) @@ -56,13 +60,21 @@ struct MirrorTerminalIntegrationTests { // Original Ghostty snapshots remain the styled active screen. The paired // overlay is what presents native scrollback on Mac, without replaying it. #expect(fixture.replicaText(client).contains("HISTORY:450")) - #expect(earlier == (try fixture.source.snapshot(fixture.hostView.id)).viewportText) + #expect(earlier == (try fixture.sourceFrame()).viewportText) #expect( earlier.trimmingCharacters(in: .newlines) == (try fixture.source.textSnapshot(fixture.hostView.id)).text.trimmingCharacters(in: .newlines)) try await fixture.wait("Observable pane presents viewport overlay") { !overlay.isHidden && overlay.string == earlier } + var jumpedBack = false + let stableUntil = ContinuousClock.now.advanced(by: .seconds(2)) + try await fixture.wait("Viewport stays scrolled across later polls") { + if client.viewportState.text != earlier { jumpedBack = true } + return ContinuousClock.now >= stableUntil + } + #expect(!jumpedBack) + #expect(try fixture.source.snapshot(fixture.hostView.id).scrollBounds == .init(atTop: false, atBottom: false)) try verifyOverlayPreservesOtherInputFocus(overlay, container: container, window: window) hosting.layoutSubtreeIfNeeded() let bitmap = try #require(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) @@ -76,6 +88,8 @@ struct MirrorTerminalIntegrationTests { !client.scrollState.isLoading && client.viewportState.text == nil } try await fixture.wait("Observable pane restores styled active screen") { overlay.isHidden } + #expect(!client.canScroll(.downward)) + #expect(client.canScroll(.upward)) // A scrolled blank screen is still an overlay, not the nil clear signal. let viewport = try #require(overlay.enclosingScrollView as? MirrorTerminalScrollView) viewport.updateViewportText("") @@ -112,10 +126,12 @@ struct MirrorTerminalIntegrationTests { let client = try await fixture.connect() try fixture.send("scroll-tui") try await fixture.waitForMirror(client, containing: "TUI-READY") + #expect(try fixture.source.snapshot(fixture.hostView.id).scrollBounds == nil) client.scroll(.upward) try await fixture.waitForMirror(client, containing: "TUI-UP") try await fixture.wait("TUI up completed") { !client.scrollState.isLoading } #expect(client.scrollState.error == nil) + #expect(try fixture.source.snapshot(fixture.hostView.id).scrollBounds == nil) client.scroll(.downward) try await fixture.waitForMirror(client, containing: "TUI-DOWN") try await fixture.wait("TUI down completed") { !client.scrollState.isLoading } @@ -136,7 +152,10 @@ struct MirrorTerminalIntegrationTests { defer { connection.close() } var messages: [MirrorMessage] = [] connection.onReady = { - connection.send(.subscribe(.init(paneID: fixture.hostView.id, representation: .text, intent: .ifFree))) + connection.send( + .subscribe( + .init( + paneID: fixture.hostView.id, representation: .text, intent: .ifFree, includeScrollState: true))) } connection.onMessage = { message in messages.append(message) @@ -147,6 +166,7 @@ struct MirrorTerminalIntegrationTests { connection.start() try await fixture.wait("Mobile initial frame") { messages.last?.text?.contains("HISTORY:450") == true } let lease = try #require(messages.first?.subscriptionID) + #expect(messages.last { $0.kind == .scrollState }?.scrollBounds == .init(atTop: false, atBottom: true)) connection.send(.history(.init(historyID: nil, offset: nil, subscriptionID: lease))) try await fixture.wait("Mobile frozen history") { messages.last?.kind == .historyPage } let history = try #require(messages.last) @@ -159,12 +179,16 @@ struct MirrorTerminalIntegrationTests { #expect(earlier.text?.contains("HISTORY:450") == false) #expect(earlier.text == (try fixture.source.textSnapshot(fixture.hostView.id)).text) #expect(messages.last?.sequence == earlier.sequence) + let earlierBounds = try #require(messages.last { $0.kind == .scrollState }) + #expect(earlierBounds.scrollBounds == .init(atTop: false, atBottom: false)) + #expect(earlierBounds.sequence == earlier.sequence) let downward = UUID() connection.send(.scroll(.init(requestID: downward, direction: .downward, subscriptionID: lease))) try await fixture.wait("Mobile latest viewport result") { messages.contains { $0.kind == .scrollResult && $0.scrollRequestID == downward } } #expect(messages.last { $0.kind == .textFrame }?.text?.contains("HISTORY:450") == true) + #expect(messages.last { $0.kind == .scrollState }?.scrollBounds == .init(atTop: false, atBottom: true)) connection.send(.history(.init(historyID: history.historyID, offset: nil, subscriptionID: lease))) try await fixture.wait("Mobile retained frozen history") { messages.last?.kind == .historyPage } #expect(messages.last?.lines == history.lines) @@ -172,6 +196,35 @@ struct MirrorTerminalIntegrationTests { #expect(!messages.contains { $0.kind == .viewport || $0.kind == .frame || $0.kind == .failure }) } + @Test(.timeLimit(.minutes(1))) func nativeBoundsUseGeometryAndStayUnknownForMouseApplications() async throws { + let fixture = try Fixture() + defer { fixture.close() } + try await fixture.wait("Host program ready") { fixture.hostText.contains("READY") } + #expect(try fixture.source.snapshot(fixture.hostView.id).scrollBounds == nil) + try fixture.send("history") + try await fixture.wait("Host history ready") { fixture.hostText.contains("HISTORY:450") } + #expect(try fixture.source.snapshot(fixture.hostView.id).scrollBounds == .init(atTop: false, atBottom: true)) + fixture.hostView.performBindingAction("scroll_to_top") + try await fixture.wait("Native scrollback top") { + try fixture.source.snapshot(fixture.hostView.id).scrollBounds?.atTop == true + } + #expect(try fixture.source.textSnapshot(fixture.hostView.id).scrollBounds == .init(atTop: true, atBottom: false)) + try fixture.source.scroll(.downward, to: fixture.hostView.id) + try await fixture.wait("Native scrollback middle") { + try fixture.source.snapshot(fixture.hostView.id).scrollBounds == .init(atTop: false, atBottom: false) + } + fixture.hostView.performBindingAction("scroll_to_bottom") + try await fixture.wait("Native bottom restored") { + try fixture.source.snapshot(fixture.hostView.id).scrollBounds?.atBottom == true + } + try fixture.send("mouse-mode") + try await fixture.wait("Application negotiated mouse reporting") { fixture.hostText.contains("MOUSE-READY") } + // Even with retained native rows, a mouse-reporting application owns wheel + // behavior, so native scrollbar positions cannot describe its boundaries. + #expect(try fixture.source.snapshot(fixture.hostView.id).scrollBounds == nil) + #expect(try fixture.source.textSnapshot(fixture.hostView.id).scrollBounds == nil) + } + @Test(.timeLimit(.minutes(1))) func viewportRowsPreserveWideCharacterWrapsAndResize() async throws { let fixture = try Fixture() defer { fixture.close() } @@ -182,7 +235,7 @@ struct MirrorTerminalIntegrationTests { var captured: MirrorFrame? try await fixture.wait("Wrapped viewport capture") { do { - captured = try fixture.source.snapshot(fixture.hostView.id) + captured = try fixture.sourceFrame() return captured?.viewportText != nil } catch MirrorPaneSourceError.captureChanged { return false } } @@ -194,7 +247,7 @@ struct MirrorTerminalIntegrationTests { fixture.hostView.updateSurfaceSize() try await fixture.wait("Reflow viewport capture") { do { - captured = try fixture.source.snapshot(fixture.hostView.id) + captured = try fixture.sourceFrame() return captured?.columns != first.columns && captured?.viewportText != nil } catch MirrorPaneSourceError.captureChanged { return false } } @@ -381,7 +434,7 @@ struct MirrorTerminalIntegrationTests { try await fixture.wait("Host program ready") { fixture.hostText.contains("READY") } try fixture.send("history") try await fixture.wait("history output") { fixture.hostText.contains("HISTORY:450") } - let before = try fixture.source.snapshot(fixture.hostView.id) + let before = try fixture.sourceFrame() let surface = try #require(fixture.hostView.surface) var captured = ghostty_text_s() var truncated = false @@ -395,7 +448,7 @@ struct MirrorTerminalIntegrationTests { #expect(truncated) #expect(text.contains("HISTORY:450")) #expect(!text.contains("HISTORY:001")) - #expect(try fixture.source.snapshot(fixture.hostView.id) == before) + #expect(try fixture.sourceFrame() == before) var untouched = ghostty_text_s() untouched.offset_start = 123 untouched.text_len = 456 @@ -406,7 +459,7 @@ struct MirrorTerminalIntegrationTests { #expect(untouched.text_len == 456) #expect(untouched.text == nil) #expect(untouchedTruncated) - #expect(try fixture.source.snapshot(fixture.hostView.id) == before) + #expect(try fixture.sourceFrame() == before) #expect(try fixture.source.activeText(fixture.hostView.id).contains("HISTORY:450")) } @@ -472,10 +525,10 @@ struct MirrorTerminalIntegrationTests { fixture.hostView.insertText("local", replacementRange: NSRange(location: NSNotFound, length: 0)) #expect(fixture.hostView.sendCLIKeyToken("enter")) try await fixture.waitForMirror(client, containing: "INPUT:local") - let original = try fixture.source.snapshot(fixture.hostView.id) + let original = try fixture.sourceFrame() replica.setFrameSize(NSSize(width: 320, height: 240)) replica.updateSurfaceSize() - #expect(try fixture.source.snapshot(fixture.hostView.id) == original) + #expect(try fixture.sourceFrame() == original) #expect(try fixture.frame(replica) == original) fixture.hostView.setFrameSize(NSSize(width: 720, height: 480)) fixture.hostView.updateSurfaceSize() @@ -536,7 +589,7 @@ struct MirrorTerminalIntegrationTests { replica.scrollWheel(with: try #require(NSEvent(cgEvent: event))) try await fixture.wait("mirror viewport scroll") { viewport.contentView.bounds.minY > initialTop + 1 } #expect(try fixture.frame(replica) == original) - #expect(try fixture.source.snapshot(fixture.hostView.id) == original) + #expect(try fixture.sourceFrame() == original) let manualOffset = viewport.contentView.bounds.origin window.setContentSize(NSSize(width: 280, height: 200)) viewport.layoutSubtreeIfNeeded() @@ -798,6 +851,14 @@ struct MirrorTerminalIntegrationTests { try source.write(Data((command + "\n").utf8), to: hostView.id) } + func sourceFrame() throws -> MirrorFrame { + let frame = try source.snapshot(hostView.id) + // Display replicas omit Host-only report modes and separately delivered metadata. + return .init( + columns: frame.columns, rows: frame.rows, + bytes: MirrorDisplayFrame.passiveSnapshot(frame.bytes), viewportText: frame.viewportText) + } + func frame(_ view: GhosttySurfaceView) throws -> MirrorFrame { let surface = try #require(view.surface) var text = ghostty_text_s() @@ -817,10 +878,10 @@ struct MirrorTerminalIntegrationTests { guard self.hostText.contains(text), self.replicaText(client).contains(text), let replica = client.replica.view else { return false } - return try self.source.snapshot(self.hostView.id) == self.frame(replica) + return try self.sourceFrame() == self.frame(replica) } } catch { - let host = try source.snapshot(hostView.id) + let host = try sourceFrame() let replica = try client.replica.view.map { try frame($0) } let hostTail = Data(host.bytes.suffix(192)).base64EncodedString() let replicaTail = replica.map { Data($0.bytes.suffix(192)).base64EncodedString() } ?? "none" @@ -881,7 +942,10 @@ struct MirrorTerminalIntegrationTests { draft) printf '\033[2J\033[HDRAFT\r\n› Ask Codex to do anything\r\n gpt-5.6 · ~/work';; think) printf '\033[2J\033[H\033[31mTHINKING:思考中\033[0m\033[4;7H';; finish) printf '\033[2J\033[H\033[32mFINAL:结论\033[0m\033[2;3H';; - history) for ((n=1; n<=450; n++)); do printf 'HISTORY:%03d\n' "$n"; done;; + history|focus-history) + if [ "$action" = focus-history ]; then printf '\033[?1004h'; fi + for ((n=1; n<=450; n++)); do printf 'HISTORY:%03d\n' "$n"; done;; + mouse-mode) printf '\033[?1000h\033[?1006hMOUSE-READY';; wrapped) columns=$(stty size); columns=${columns##* } for ((n=1; n<=250; n++)); do -- 2.51.2 From 4665ebf2392adda0b93b6f759526553b0afd859a Mon Sep 17 00:00:00 2001 From: Awhisper <4347733+Awhisper@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:13:25 +0800 Subject: [PATCH 05/14] Scroll mirrors by pane height and stabilize Mac controls --- MirrorClient/Android/README.md | 4 ++- MirrorClient/iOS/README.md | 4 ++- ...012-scroll-controls-and-report-feedback.md | 22 +++++++++++++++ docs/remote-mirror.md | 5 +++- .../GhosttyMirrorPaneSource.swift | 19 +++++++++++-- .../RemoteMirror/RemoteMirrorPaneView.swift | 28 ++++++++++++------- .../MirrorTerminalIntegrationTests.swift | 13 ++++++++- 7 files changed, 78 insertions(+), 17 deletions(-) diff --git a/MirrorClient/Android/README.md b/MirrorClient/Android/README.md index b597ba84..7d50fa8e 100644 --- a/MirrorClient/Android/README.md +++ b/MirrorClient/Android/README.md @@ -38,7 +38,9 @@ is no private AI console or dependency on personal shell wrappers. Only these buttons send remote scroll input; swipes, edge pulls, selection and History remain local. **Follow latest** stays below the text and follows new frames. These actions scroll the actual Host pane, including a terminal application's own - scroll handling, so the distance depends on that application. One request runs at + scroll handling. Host targets its pane height minus three rows (at least one row), + with exact native scrollback movement and equivalent precision wheel input for + applications; Host precision-scroll settings and the application can affect the distance. One request runs at a time with a loading indicator; an unchanged screen also completes normally. Requests time out after five seconds and are never replayed after reconnecting. Hosts with `scroll-state-v1` can confirm the top/bottom boundary: the matching diff --git a/MirrorClient/iOS/README.md b/MirrorClient/iOS/README.md index af1b570a..1c15c01f 100644 --- a/MirrorClient/iOS/README.md +++ b/MirrorClient/iOS/README.md @@ -49,7 +49,9 @@ Each pane keeps separate live/history reading positions. Erased transient output is not archived. Ghostty is not embedded in this client. On Hosts advertising `remote-scroll`, **Scroll Up** and **Scroll Down** move the -Host pane by a small, bounded scroll amount. The two buttons sit above the live +Host pane by its current height minus three rows (at least one row). Native +scrollback moves exactly that far; application-managed scrolling uses an equivalent +precision wheel distance and may vary with Host settings and the application. The two buttons sit above the live screen. Swipes only move the local reader, including at either edge, and long presses keep native text selection. The bottom Follow latest controls remain local reading controls. The remote scroll buttons operate the Host's current diff --git a/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md b/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md index 8ed05694..42a6e17e 100644 --- a/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md +++ b/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md @@ -53,3 +53,25 @@ remote restarts, physical installs or PR publication are part of this revision. `run` method also retains its baseline complexity warning under explicit lint. - Ghostty framework SHA-256 and submodule state remain unchanged. Physical-device and remote real-Agent acceptance of this revision remain pending. + +## Page-sized scroll follow-up (2026-10-02) + +Device acceptance requested a useful page step and stable Mac viewport sizing. +Host now computes `max(1, paneRows - 3)` for each request, so resizing the Host +pane changes the next step and three rows provide context. Proven native +scrollback uses Ghostty's existing `scroll_page_lines` binding. Otherwise Host +sends the equivalent precision wheel pixel distance, retaining application-owned +scroll handling without Agent-specific keys or Ghostty changes. The Host's +precision multiplier and the running application's wheel policy can change the +actual TUI distance. + +Mac places the two buttons at opposite ends of the same row. Its center reserves +a constant height for a horizontal spinner and label, error text, or scrollback +status, so loading does not change the terminal's fit-to-window height. + +Verification: all 16 real Ghostty integration tests pass, including exact native +page distance, stable viewport height throughout loading, alternate-screen wheel +input, text clients, and retained-history preservation. Debug build succeeds +with no warnings; changed-file SwiftLint and formatting pass. Full `make check` +still stops on the five pre-existing RepositoryIconImage aspect-ratio violations. +The current iOS Debug was installed successfully on the connected iPhone 16. diff --git a/docs/remote-mirror.md b/docs/remote-mirror.md index 4cb644f1..4445fd14 100644 --- a/docs/remote-mirror.md +++ b/docs/remote-mirror.md @@ -105,7 +105,10 @@ Fit to Window text small. iOS renders replacement text with local reflow. Cleared output is not an archive. On a Host advertising remote scrolling, **Scroll Up** and **Scroll Down** move -the live Host terminal by a small wheel step. These buttons sit above the live +the live Host terminal by its current pane height minus three rows (at least one +row). Native scrollback moves by that exact number of rows. Application-owned +scrolling receives the equivalent precision wheel distance; Host precision-scroll +settings and the application can change the resulting distance. These buttons sit above the live view on all clients. Wheel and touch gestures only move the local readable view; they never request remote scrolling. Follow latest remains below the mobile reader. diff --git a/supacode/Features/RemoteMirror/GhosttyMirrorPaneSource.swift b/supacode/Features/RemoteMirror/GhosttyMirrorPaneSource.swift index f8f4e0b9..1f8ea7e4 100644 --- a/supacode/Features/RemoteMirror/GhosttyMirrorPaneSource.swift +++ b/supacode/Features/RemoteMirror/GhosttyMirrorPaneSource.swift @@ -92,7 +92,18 @@ final class GhosttyMirrorPaneSource: MirrorPaneSource { func scroll(_ direction: MirrorMessage.ScrollDirection, to id: UUID) throws { guard let view = view(id), let terminal = view.surface else { throw MirrorProtocolError.invalidMessage } let size = ghostty_surface_size(terminal) - guard size.columns > 0, size.rows > 0 else { throw MirrorProtocolError.invalidMessage } + guard size.columns > 0, (1...1000).contains(size.rows), size.cell_height_px > 0 else { + throw MirrorProtocolError.invalidMessage + } + let rows = max(1, Int(size.rows) - 3) + if try captureGeometry(terminal).scrollBounds != nil { + let lines = direction == .upward ? -rows : rows + let action = "scroll_page_lines:\(lines)" + guard ghostty_surface_binding_action(terminal, action, UInt(action.utf8.count)) else { + throw MirrorProtocolError.invalidMessage + } + return + } let localPoint = view.window.map { view.convert($0.mouseLocationOutsideOfEventStream, from: nil) } let localMods = view.ghosttyMods(NSEvent.modifierFlags) defer { @@ -103,10 +114,12 @@ final class GhosttyMirrorPaneSource: MirrorPaneSource { } } // Move through the outside position so an unchanged center cannot retain - // local modifier keys. One notch honors Host's configured wheel multiplier. + // local modifier keys. Precision pixels avoid the discrete wheel multiplier; + // applications still decide how many rows each resulting mouse event moves. ghostty_surface_mouse_pos(terminal, -1, -1, GHOSTTY_MODS_NONE) ghostty_surface_mouse_pos(terminal, view.bounds.midX, view.bounds.midY, GHOSTTY_MODS_NONE) - ghostty_surface_mouse_scroll(terminal, 0, direction == .upward ? 1 : -1, 0) + let pixels = Double(rows) * Double(size.cell_height_px) + ghostty_surface_mouse_scroll(terminal, 0, direction == .upward ? pixels : -pixels, 1) } var supportsBoundedHistory: Bool { true } diff --git a/supacode/Features/RemoteMirror/RemoteMirrorPaneView.swift b/supacode/Features/RemoteMirror/RemoteMirrorPaneView.swift index 465493bc..c785c197 100644 --- a/supacode/Features/RemoteMirror/RemoteMirrorPaneView.swift +++ b/supacode/Features/RemoteMirror/RemoteMirrorPaneView.swift @@ -162,20 +162,28 @@ struct RemoteMirrorPaneView: View { private var scrollControls: some View { HStack(spacing: 12) { - if client.scrollState.isLoading { - ProgressView("Scrolling…").controlSize(.small) - } else if let error = client.scrollState.error { - Text(error).font(.caption).foregroundStyle(.secondary).textSelection(.enabled) - } else if client.viewportState.text != nil { - Text("Host scrollback · Plain text").font(.caption).foregroundStyle(.secondary) - } - Spacer(minLength: 0) Button("Scroll Up", systemImage: "chevron.up") { client.scroll(.upward) } - .help("Scroll the Host terminal up; distance depends on the running program") + .help("Scroll the Host terminal up by about a page, keeping three rows of overlap") .accessibilityIdentifier("remote-mirror-scroll-up") .disabled(!client.canScroll(.upward)) + ZStack { + if client.scrollState.isLoading { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Scrolling…").font(.caption) + } + } else if let error = client.scrollState.error { + Text(error).font(.caption).foregroundStyle(.secondary).textSelection(.enabled) + .help(error) + } else if client.viewportState.text != nil { + Text("Host scrollback · Plain text").font(.caption).foregroundStyle(.secondary) + } + } + .lineLimit(1) + .frame(maxWidth: .infinity) + .frame(height: 20) Button("Scroll Down", systemImage: "chevron.down") { client.scroll(.downward) } - .help("Scroll the Host terminal down; distance depends on the running program") + .help("Scroll the Host terminal down by about a page, keeping three rows of overlap") .accessibilityIdentifier("remote-mirror-scroll-down") .disabled(!client.canScroll(.downward)) } diff --git a/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift b/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift index 5c483269..bdf1b741 100644 --- a/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift +++ b/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift @@ -48,13 +48,24 @@ struct MirrorTerminalIntegrationTests { try await fixture.wait("Mounted viewport overlay") { textView(hosting) != nil } let overlay = try #require(textView(hosting)) #expect(overlay.isHidden) + hosting.layoutSubtreeIfNeeded() + let viewportHeight = try #require(overlay.enclosingScrollView).frame.height + let initialText = try fixture.source.textSnapshot(fixture.hostView.id).text + let pageRows = max(1, Int(ghostty_surface_size(try #require(fixture.hostView.surface)).rows) - 3) + func firstHistoryRow(_ text: String) throws -> Int { + let row = try #require(text.split(separator: "\n").first { $0.hasPrefix("HISTORY:") }) + return try #require(Int(row.dropFirst("HISTORY:".count))) + } client.scroll(.upward) #expect(client.scrollState.isLoading) try await fixture.wait("Earlier remote viewport") { - !client.scrollState.isLoading && client.viewportState.text != nil + hosting.layoutSubtreeIfNeeded() + #expect(overlay.enclosingScrollView?.frame.height == viewportHeight) + return !client.scrollState.isLoading && client.viewportState.text != nil } #expect(client.scrollState.error == nil) let earlier = try #require(client.viewportState.text) + #expect(try firstHistoryRow(initialText) - firstHistoryRow(earlier) == pageRows) #expect(earlier.contains("HISTORY:")) #expect(!earlier.contains("HISTORY:450")) // Original Ghostty snapshots remain the styled active screen. The paired -- 2.51.2 From 00160dbcda31f2da302652b30fab038d147e5f25 Mon Sep 17 00:00:00 2001 From: Awhisper <4347733+Awhisper@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:44:20 +0800 Subject: [PATCH 06/14] Account for Agent wheel units and read mirror pages from the top --- MirrorClient/Android/README.md | 8 ++-- .../awhisper/prowlmirror/RemoteScrollTest.kt | 5 ++- .../com/awhisper/prowlmirror/MirrorApp.kt | 10 +---- .../iOS/ProwlMirror-iOS/ContentView.swift | 4 +- .../ProwlMirror_iOSUITests.swift | 4 +- MirrorClient/iOS/README.md | 10 +++-- ...012-scroll-controls-and-report-feedback.md | 28 +++++++++++++ docs/remote-mirror.md | 12 ++++-- .../GhosttyMirrorPaneSource.swift | 16 +++++++- .../RemoteMirror/MirrorScrollState.swift | 3 ++ .../RemoteMirror/MirrorTerminalViewport.swift | 12 ++++++ .../RemoteMirror/RemoteMirrorPaneView.swift | 2 +- .../RemoteMirror/MirrorScrollStateTests.swift | 11 ++++++ .../MirrorTerminalIntegrationTests.swift | 39 +++++++++++++++++++ 14 files changed, 138 insertions(+), 26 deletions(-) diff --git a/MirrorClient/Android/README.md b/MirrorClient/Android/README.md index 7d50fa8e..ac8c737e 100644 --- a/MirrorClient/Android/README.md +++ b/MirrorClient/Android/README.md @@ -40,15 +40,17 @@ is no private AI console or dependency on personal shell wrappers. These actions scroll the actual Host pane, including a terminal application's own scroll handling. Host targets its pane height minus three rows (at least one row), with exact native scrollback movement and equivalent precision wheel input for - applications; Host precision-scroll settings and the application can affect the distance. One request runs at + applications. Internal Host profiles adjust known Agent wheel steps (Codex: three + rows per event, reserving eight composer/status rows). Host precision-scroll + settings and the application can affect the distance. One request runs at a time with a loading indicator; an unchanged screen also completes normally. Requests time out after five seconds and are never replayed after reconnecting. Hosts with `scroll-state-v1` can confirm the top/bottom boundary: the matching button is disabled until a newer frame permits scrolling again. Unknown bounds, including applications that manage their own scrolling, stay enabled; unchanged text alone never disables a direction. Older Hosts need no boundary metadata. - After scrolling up, reading continues at the newly revealed top edge; - scrolling down moves to the bottom edge. Automatic following is paused by remote scrolling. + Both directions resume reading at the top of the returned screen. Automatic + following is paused by remote scrolling. - Saving credentials does not persist terminal output or drafts across process death. Rotation retains the current ViewModel; reopening the app offers saved Hosts. diff --git a/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt b/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt index cb5a6fd1..dbfa8055 100644 --- a/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt +++ b/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt @@ -205,10 +205,13 @@ class RemoteScrollTest { assertEquals(0, session.liveScrollIndex) assertEquals(0, session.liveScrollOffset) } + output.performTouchInput { swipeUp() } + rule.runOnIdle { assertTrue(session.liveScrollIndex > 0 || session.liveScrollOffset > 0) } rule.onNodeWithText("Scroll down").performClick() complete(text) rule.runOnIdle { - assertTrue(session.liveScrollIndex > 0 || session.liveScrollOffset > 0) + assertEquals(0, session.liveScrollIndex) + assertEquals(0, session.liveScrollOffset) } screenshot("local-reading") rule.onNodeWithText("Follow latest").performClick() diff --git a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt index 25aef990..33339edd 100644 --- a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt +++ b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt @@ -2,7 +2,6 @@ package com.awhisper.prowlmirror import android.view.KeyEvent as AndroidKeyEvent import androidx.compose.foundation.* -import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.interaction.DragInteraction import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -404,14 +403,7 @@ private fun Reading(session: Session, state: SessionState) { LaunchedEffect(state.scrollCompletion) { val completion = state.scrollCompletion if (!state.showsHistory && completion != null && blocks.isNotEmpty()) { - if (completion.direction == ScrollDirection.DOWN) { - scroll.scrollToItem(blocks.lastIndex) - val layout = scroll.layoutInfo - layout.visibleItemsInfo.lastOrNull { it.index == blocks.lastIndex }?.let { - val remaining = it.offset + it.size + layout.afterContentPadding - layout.viewportEndOffset - scroll.scrollBy(remaining.coerceAtLeast(0).toFloat()) - } - } else scroll.scrollToItem(0) + scroll.scrollToItem(0) } } Column(Modifier.fillMaxSize()) { diff --git a/MirrorClient/iOS/ProwlMirror-iOS/ContentView.swift b/MirrorClient/iOS/ProwlMirror-iOS/ContentView.swift index b24d5b1b..978db87b 100644 --- a/MirrorClient/iOS/ProwlMirror-iOS/ContentView.swift +++ b/MirrorClient/iOS/ProwlMirror-iOS/ContentView.swift @@ -361,8 +361,8 @@ private struct MirrorReadingView: View { if session.followsLatest { proxy.scrollTo("latest", anchor: .bottom) } } .onChange(of: session.completedScroll) { _, completion in - guard let completion else { return } - position.scrollTo(edge: completion.direction == .upward ? .top : .bottom) + guard completion != nil else { return } + position.scrollTo(edge: .top) } .safeAreaInset(edge: .top) { VStack(alignment: .leading, spacing: 8) { diff --git a/MirrorClient/iOS/ProwlMirror-iOSUITests/ProwlMirror_iOSUITests.swift b/MirrorClient/iOS/ProwlMirror-iOSUITests/ProwlMirror_iOSUITests.swift index 4855d9b5..578117aa 100644 --- a/MirrorClient/iOS/ProwlMirror-iOSUITests/ProwlMirror_iOSUITests.swift +++ b/MirrorClient/iOS/ProwlMirror-iOSUITests/ProwlMirror_iOSUITests.swift @@ -86,7 +86,7 @@ final class ProwlMirror_iOSUITests: XCTestCase { } @MainActor - func testRemoteScrollRevealsTheNewEdgeWhileInteriorDragStaysLocal() { + func testRemoteScrollAlwaysRevealsTheTopWhileInteriorDragStaysLocal() { let app = XCUIApplication() app.launchArguments = [ "--mirror-ui-fixture", "--mirror-ui-scroll-fixture", "--mirror-ui-scroll-long-fixture", @@ -108,7 +108,7 @@ final class ProwlMirror_iOSUITests: XCTestCase { let sameScreen = app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "Remote -1 marker ")) XCTAssertTrue(sameScreen.allElementsBoundByIndex.contains { $0.isHittable }) app.buttons["mirror-scroll-down"].tap() - let later = app.staticTexts["Remote 0 marker 40"] + let later = app.staticTexts["Remote 0 marker 1"] expectation( for: NSPredicate { _, _ in later.exists && later.isHittable && later.frame.minY >= reading.frame.minY diff --git a/MirrorClient/iOS/README.md b/MirrorClient/iOS/README.md index 1c15c01f..5441267b 100644 --- a/MirrorClient/iOS/README.md +++ b/MirrorClient/iOS/README.md @@ -51,7 +51,9 @@ is not archived. Ghostty is not embedded in this client. On Hosts advertising `remote-scroll`, **Scroll Up** and **Scroll Down** move the Host pane by its current height minus three rows (at least one row). Native scrollback moves exactly that far; application-managed scrolling uses an equivalent -precision wheel distance and may vary with Host settings and the application. The two buttons sit above the live +precision wheel distance adjusted by Host internal Agent profiles (Codex: three +rows per event with eight rows reserved for composer/status). Host settings and +the application can still change the distance. The two buttons sit above the live screen. Swipes only move the local reader, including at either edge, and long presses keep native text selection. The bottom Follow latest controls remain local reading controls. The remote scroll buttons operate the Host's current @@ -64,9 +66,9 @@ One scroll waits for a fresh screen and matching Host acknowledgement; a spinner ends after five seconds if no confirmation arrives. Timed-out or disconnected scrolls are never replayed automatically. A fresh capture does not prove that the application moved, including when it is already at the beginning or end. Following -latest text pauses during remote scrolling. After scrolling up, the local reader -starts at the top of the returned screen; after scrolling down, it starts at the -bottom. Hosts without `remote-scroll` keep those two controls disabled. +latest text pauses during remote scrolling. After either scrolling up or down, +the local reader starts at the top of the returned screen. Hosts without +`remote-scroll` keep those two controls disabled. Hosts advertising `scroll-state-v1` send explicit scroll boundaries with each screen after the client opts in. The matching screen commits those boundaries: diff --git a/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md b/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md index 42a6e17e..a8a8428c 100644 --- a/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md +++ b/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md @@ -75,3 +75,31 @@ input, text clients, and retained-history preservation. Debug build succeeds with no warnings; changed-file SwiftLint and formatting pass. Full `make check` still stops on the five pre-existing RepositoryIconImage aspect-ratio violations. The current iOS Debug was installed successfully on the connected iPhone 16. + +## Agent wheel units and reading origin (2026-10-02) + +Remote acceptance reproduced a 51-row pane producing 48 wheel events, while +Codex 0.159.2 moves three transcript rows per event. The old TUI test confirmed +only direction, so it did not catch the resulting threefold overshoot. + +An internal constant table now assigns Codex three rows per event and eight +reserved composer/status rows. Together with the existing three-row overlap, +a 51-row pane sends 13 events, approximately 39 transcript rows. Unknown Agents +use one row per event and no reserved rows. This is not a user-facing setting. +Proven Ghostty native scrollback bypasses Agent profiles, including when Codex +is detected. All paths clamp to at least one row/event for tiny panes. + +Both mobile clients now anchor both completed directions at the top. Mac exposes +a correlated completion identity only after the reply and displayed frame match; +the viewport resets once for each completion, including Original Size mode. +Ordinary subsequent frames and duplicate results preserve local reading offsets. + +Verification: 23 Mac tests pass, including real TUI event counts in both +directions, exact native scrolling with a detected Codex Agent, correlated +completion and Original Size local-offset preservation. Two iPhone UI tests and +one iPad UI test pass; both directions reveal the first row of long content. +Android JVM tests and lint pass, and all eight remote-scroll UI tests pass when +run directly after an initial emulator instrumentation startup crash (zero tests +executed in the failed launch). Mac Debug and signed iPhoneOS builds succeed. +Full repository checks still stop at the five existing RepositoryIconImage lint +violations; changed Mac files pass SwiftLint and formatting. diff --git a/docs/remote-mirror.md b/docs/remote-mirror.md index 4445fd14..ffe77494 100644 --- a/docs/remote-mirror.md +++ b/docs/remote-mirror.md @@ -107,10 +107,16 @@ renders replacement text with local reflow. Cleared output is not an archive. On a Host advertising remote scrolling, **Scroll Up** and **Scroll Down** move the live Host terminal by its current pane height minus three rows (at least one row). Native scrollback moves by that exact number of rows. Application-owned -scrolling receives the equivalent precision wheel distance; Host precision-scroll -settings and the application can change the resulting distance. These buttons sit above the live +scrolling receives the equivalent precision wheel distance. Internal Agent defaults account for known application +wheel steps: Codex uses three rows per event and reserves eight pane rows for its +composer/status area, in addition to the three-row overlap. Unlisted Agents use +one row per event with no extra reservation. These are compiled constants, not +user settings, and never affect native Ghostty scrollback. Host precision-scroll +settings and application behavior can still affect TUI distance. These buttons sit above the live view on all clients. Wheel and touch gestures only move the local readable view; -they never request remote scrolling. Follow latest remains below the mobile reader. +they never request remote scrolling. Each completed remote scroll resets the local +reader to the top in both directions, including Mac Original Size mode. Follow +latest remains below the mobile reader. A current Host sends frame-matched boundary state. **Scroll Up** is disabled at a known top and **Scroll Down** at a known bottom. Native terminal scrollback can diff --git a/supacode/Features/RemoteMirror/GhosttyMirrorPaneSource.swift b/supacode/Features/RemoteMirror/GhosttyMirrorPaneSource.swift index 1f8ea7e4..b47b61a3 100644 --- a/supacode/Features/RemoteMirror/GhosttyMirrorPaneSource.swift +++ b/supacode/Features/RemoteMirror/GhosttyMirrorPaneSource.swift @@ -4,6 +4,17 @@ import GhosttyKit @MainActor final class GhosttyMirrorPaneSource: MirrorPaneSource { + // TUI wheel events have no row unit. These internal defaults describe known + // handlers; native Ghostty scrollback bypasses them entirely. + private static let scrollProfiles: [DetectedAgent: (rowsPerEvent: Int, reservedRows: Int)] = [ + .codex: (rowsPerEvent: 3, reservedRows: 8) + ] + + static func wheelEvents(paneRows: Int, agent: DetectedAgent?) -> Int { + let profile = agent.flatMap { scrollProfiles[$0] } ?? (rowsPerEvent: 1, reservedRows: 0) + return max(1, (max(1, paneRows) - profile.reservedRows - 3) / profile.rowsPerEvent) + } + let manager: WorktreeTerminalManager init(manager: WorktreeTerminalManager) { @@ -118,7 +129,10 @@ final class GhosttyMirrorPaneSource: MirrorPaneSource { // applications still decide how many rows each resulting mouse event moves. ghostty_surface_mouse_pos(terminal, -1, -1, GHOSTTY_MODS_NONE) ghostty_surface_mouse_pos(terminal, view.bounds.midX, view.bounds.midY, GHOSTTY_MODS_NONE) - let pixels = Double(rows) * Double(size.cell_height_px) + let agent = manager.activeWorktreeStates.first { $0.surfaces[id] != nil }? + .surfaceAgentStates[id]?.detectedAgent + let events = Self.wheelEvents(paneRows: Int(size.rows), agent: agent) + let pixels = Double(events) * Double(size.cell_height_px) ghostty_surface_mouse_scroll(terminal, 0, direction == .upward ? pixels : -pixels, 1) } diff --git a/supacode/Features/RemoteMirror/MirrorScrollState.swift b/supacode/Features/RemoteMirror/MirrorScrollState.swift index 503ed421..4f5e9cac 100644 --- a/supacode/Features/RemoteMirror/MirrorScrollState.swift +++ b/supacode/Features/RemoteMirror/MirrorScrollState.swift @@ -5,6 +5,7 @@ import Observation @Observable final class MirrorScrollState { private(set) var requestID: UUID? + private(set) var completedRequestID: UUID? private(set) var error: String? var isLoading: Bool { requestID != nil } @ObservationIgnored private var presentedSequence: UInt64 = 0 @@ -61,6 +62,7 @@ final class MirrorScrollState { func reset() { cancel() + completedRequestID = nil presentedSequence = 0 baseline = 0 error = nil @@ -68,6 +70,7 @@ final class MirrorScrollState { private func finishIfPresented() { guard let resultSequence, presentedSequence >= resultSequence else { return } + completedRequestID = requestID cancel() } } diff --git a/supacode/Features/RemoteMirror/MirrorTerminalViewport.swift b/supacode/Features/RemoteMirror/MirrorTerminalViewport.swift index c21fe74f..13f0b2e2 100644 --- a/supacode/Features/RemoteMirror/MirrorTerminalViewport.swift +++ b/supacode/Features/RemoteMirror/MirrorTerminalViewport.swift @@ -7,16 +7,19 @@ struct MirrorTerminalViewport: NSViewRepresentable { let displaySize: CGSize var fitsWindow = true var viewportText: String? + var scrollCompletion: UUID? func makeNSView(context: Context) -> MirrorTerminalScrollView { let view = MirrorTerminalScrollView(surface: surface, displaySize: displaySize, fitsWindow: fitsWindow) view.updateViewportText(viewportText) + view.updateScrollCompletion(scrollCompletion) return view } func updateNSView(_ view: MirrorTerminalScrollView, context: Context) { view.update(surface: surface, displaySize: displaySize, fitsWindow: fitsWindow) view.updateViewportText(viewportText) + view.updateScrollCompletion(scrollCompletion) } } @@ -57,6 +60,7 @@ final class MirrorTerminalScrollView: NSScrollView { private let viewport = ViewportTextView(frame: .zero) private var fitsWindow: Bool private var resetOrigin = true + private var scrollCompletion: UUID? private var isLayingOut = false var displaySize: CGSize { didSet { if displaySize != oldValue { needsLayout = true } } @@ -121,6 +125,14 @@ final class MirrorTerminalScrollView: NSScrollView { needsLayout = true } + func updateScrollCompletion(_ completion: UUID?) { + guard scrollCompletion != completion else { return } + scrollCompletion = completion + guard completion != nil else { return } + resetOrigin = true + needsLayout = true + } + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } diff --git a/supacode/Features/RemoteMirror/RemoteMirrorPaneView.swift b/supacode/Features/RemoteMirror/RemoteMirrorPaneView.swift index c785c197..7e12a054 100644 --- a/supacode/Features/RemoteMirror/RemoteMirrorPaneView.swift +++ b/supacode/Features/RemoteMirror/RemoteMirrorPaneView.swift @@ -63,7 +63,7 @@ struct RemoteMirrorPaneView: View { if let view = client.replica.view { MirrorTerminalViewport( surface: view, displaySize: client.replica.displaySize, fitsWindow: fitsWindow, - viewportText: client.viewportState.text + viewportText: client.viewportState.text, scrollCompletion: client.scrollState.completedRequestID ) .opacity(client.showsHistory ? 0 : 1) .allowsHitTesting(!client.showsHistory) diff --git a/supacodeTests/RemoteMirror/MirrorScrollStateTests.swift b/supacodeTests/RemoteMirror/MirrorScrollStateTests.swift index 07427380..f17ddef7 100644 --- a/supacodeTests/RemoteMirror/MirrorScrollStateTests.swift +++ b/supacodeTests/RemoteMirror/MirrorScrollStateTests.swift @@ -6,6 +6,14 @@ import Testing @MainActor struct MirrorScrollStateTests { + @Test func agentWheelProfilesReserveChromeAndDoNotChangeUnknownAgents() { + #expect(GhosttyMirrorPaneSource.wheelEvents(paneRows: 51, agent: .codex) == 13) + #expect(GhosttyMirrorPaneSource.wheelEvents(paneRows: 51, agent: .claude) == 48) + #expect(GhosttyMirrorPaneSource.wheelEvents(paneRows: 51, agent: nil) == 48) + #expect(GhosttyMirrorPaneSource.wheelEvents(paneRows: 1, agent: .codex) == 1) + #expect(GhosttyMirrorPaneSource.wheelEvents(paneRows: 3, agent: nil) == 1) + } + @Test func waitsForCorrelatedFramePresentationAndLimitsPendingInput() throws { let state = MirrorScrollState() state.didPresent(sequence: 4) @@ -16,9 +24,11 @@ struct MirrorScrollStateTests { state.receiveResult(requestID: request, sequence: 6) state.didPresent(sequence: 5) #expect(state.isLoading) + #expect(state.completedRequestID == nil) state.didPresent(sequence: 6) #expect(!state.isLoading) #expect(state.error == nil) + #expect(state.completedRequestID == request) } @Test func resultCanArriveAfterItsFrameWasPresented() throws { @@ -46,6 +56,7 @@ struct MirrorScrollStateTests { await clock.advance(by: .seconds(5)) #expect(!state.isLoading) #expect(state.error?.contains("timed out") == true) + #expect(state.completedRequestID == nil) let next = try #require(state.begin()) state.receiveResult(requestID: previous, sequence: 1) state.didPresent(sequence: 1) diff --git a/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift b/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift index bdf1b741..d2ab456f 100644 --- a/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift +++ b/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift @@ -50,6 +50,8 @@ struct MirrorTerminalIntegrationTests { #expect(overlay.isHidden) hosting.layoutSubtreeIfNeeded() let viewportHeight = try #require(overlay.enclosingScrollView).frame.height + fixture.manager.activeWorktreeStates.first { $0.surfaces[fixture.hostView.id] != nil }? + .surfaceAgentStates[fixture.hostView.id] = PaneAgentState(detectedAgent: .codex) let initialText = try fixture.source.textSnapshot(fixture.hostView.id).text let pageRows = max(1, Int(ghostty_surface_size(try #require(fixture.hostView.surface)).rows) - 3) func firstHistoryRow(_ text: String) throws -> Int { @@ -149,6 +151,22 @@ struct MirrorTerminalIntegrationTests { #expect(client.scrollState.error == nil) } + @Test(.timeLimit(.minutes(1))) func codexWheelProfileLimitsRealTUIEventCount() async throws { + let fixture = try Fixture() + defer { fixture.close() } + try await fixture.wait("Host ready") { fixture.hostText.contains("READY") } + try fixture.send("count-scroll") + try await fixture.wait("Counter ready") { fixture.hostText.contains("COUNT:0") } + fixture.manager.activeWorktreeStates.first { $0.surfaces[fixture.hostView.id] != nil }? + .surfaceAgentStates[fixture.hostView.id] = PaneAgentState(detectedAgent: .codex) + let rows = Int(ghostty_surface_size(try #require(fixture.hostView.surface)).rows) + let events = GhosttyMirrorPaneSource.wheelEvents(paneRows: rows, agent: .codex) + try fixture.source.scroll(.upward, to: fixture.hostView.id) + try await fixture.wait("Converted TUI events consumed") { fixture.hostText.contains("COUNT:\(events)!") } + try fixture.source.scroll(.downward, to: fixture.hostView.id) + try await fixture.wait("Reverse TUI events consumed") { fixture.hostText.contains("COUNT:0!") } + } + @Test(.timeLimit(.minutes(1))) func textWireScrollsRealViewportAndKeepsHistorySnapshot() async throws { let fixture = try Fixture() defer { fixture.close() } @@ -601,7 +619,16 @@ struct MirrorTerminalIntegrationTests { try await fixture.wait("mirror viewport scroll") { viewport.contentView.bounds.minY > initialTop + 1 } #expect(try fixture.frame(replica) == original) #expect(try fixture.sourceFrame() == original) + let completion = UUID() + viewport.updateScrollCompletion(completion) + viewport.layoutSubtreeIfNeeded() + #expect(viewport.contentView.bounds.minY == 0) + replica.scrollWheel(with: try #require(NSEvent(cgEvent: event))) + try await fixture.wait("Reading continues after completion") { viewport.contentView.bounds.minY > 1 } let manualOffset = viewport.contentView.bounds.origin + viewport.updateScrollCompletion(completion) + viewport.layoutSubtreeIfNeeded() + #expect(viewport.contentView.bounds.origin == manualOffset) window.setContentSize(NSSize(width: 280, height: 200)) viewport.layoutSubtreeIfNeeded() #expect(abs(viewport.contentView.bounds.minY - manualOffset.y) < 1) @@ -965,6 +992,18 @@ struct MirrorTerminalIntegrationTests { printf '界🙂X\n' done ;; + count-scroll) + stty raw -echo + printf '\033[?1049h\033[?1007h\033[2J\033[HCOUNT:0!' + count=0 + while IFS= read -r -n 3 key; do + case "$key" in + $'\033[A') count=$((count+1));; + $'\033[B') count=$((count-1));; + esac + printf '\033[2J\033[HCOUNT:%s!' "$count" + done + ;; scroll-tui) stty raw -echo printf '\033[?1049h\033[?1007h\033[2J\033[HTUI-READY' -- 2.51.2 From ad243c28e6eefbf4b765e4a9d97ee8577fde8b0c Mon Sep 17 00:00:00 2001 From: Awhisper <4347733+Awhisper@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:41:19 +0800 Subject: [PATCH 07/14] test: validate styled scrollback replay with existing Ghostty APIs --- .../MirrorVTExportSpikeTests.swift | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 supacodeTests/RemoteMirror/MirrorVTExportSpikeTests.swift diff --git a/supacodeTests/RemoteMirror/MirrorVTExportSpikeTests.swift b/supacodeTests/RemoteMirror/MirrorVTExportSpikeTests.swift new file mode 100644 index 00000000..e56a27b8 --- /dev/null +++ b/supacodeTests/RemoteMirror/MirrorVTExportSpikeTests.swift @@ -0,0 +1,207 @@ +import AppKit +import Foundation +import GhosttyKit +import Testing + +/// Opt-in experiment only. Uses its own Ghostty app and clipboard callback; +/// production runtime, Mirror transport, and mobile clients are not involved. +@Suite(.serialized) +@MainActor +struct MirrorVTExportSpikeTests { + @Test( + .enabled(if: ProcessInfo.processInfo.environment["PROWL_VT_EXPORT_SPIKE"] == "1"), + .timeLimit(.minutes(3))) + func styledHistoryReplay() async throws { + let directory = URL(filePath: "/tmp/prowl-vt-export-spike") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let rowCount = Int(ProcessInfo.processInfo.environment["PROWL_VT_EXPORT_ROWS"] ?? "500") ?? 500 + try #require(rowCount > 50) + let contents = + (1...rowCount).map { row in + "\u{1B}[38;2;80;190;240mROW \(row)\u{1B}[0m " + + "\u{1B}[1;33m粗体 Bold\u{1B}[0m │ 界🙂 │ " + + "\u{1B}[48;2;80;40;100m背景 Background\u{1B}[0m " + + (row.isMultiple(of: 7) ? String(repeating: "长行 wrap 界🙂 ", count: 12) : "table │ value") + + "\r\n" + }.joined() + "END-OF-FIXTURE" + try Data(contents.utf8).write(to: directory.appending(path: "source.vt")) + let host = try Terminal( + directory: directory, file: "source.vt", title: "Host — native scrollback") + defer { host.close() } + try await wait { host.text().contains("END-OF-FIXTURE") } + let beforeClipboard = NSPasteboard.general.changeCount + let start = ContinuousClock.now + let archive = try host.export() + let elapsed = start.duration(to: .now) + #expect(NSPasteboard.general.changeCount == beforeClipboard) + #expect(try #require(String(data: archive, encoding: .utf8)).contains("38;2;80;190;240")) + try archive.write(to: directory.appending(path: "replay.vt")) + let replay = try Terminal( + directory: directory, file: "replay.vt", title: "Replica — exported VT replay") + defer { replay.close() } + try await wait { replay.text().contains("END-OF-FIXTURE") } + #expect(host.size.rows == replay.size.rows) + #expect(host.size.columns == replay.size.columns) + host.action("scroll_page_lines:-50") + replay.action("scroll_page_lines:-50") + try await wait { + !host.text().contains("END-OF-FIXTURE") && !replay.text().contains("END-OF-FIXTURE") + } + let original = host.text() + let reconstructed = replay.text() + try original.write(to: directory.appending(path: "host.txt"), atomically: true, encoding: .utf8) + try reconstructed.write( + to: directory.appending(path: "replica.txt"), atomically: true, encoding: .utf8) + let reexport = try replay.export() + #expect(try #require(String(data: reexport, encoding: .utf8)).contains("38;2;80;190;240")) + #expect(try #require(String(data: reexport, encoding: .utf8)).contains("48;2;80;40;100")) + #expect(original == reconstructed) + try host.export(format: "html").write(to: directory.appending(path: "host.html")) + try replay.export(format: "html").write(to: directory.appending(path: "replica.html")) + let report = + "fixtureRows=\(rowCount) rows=\(host.size.rows) columns=\(host.size.columns) " + + "bytes=\(archive.count) exportTime=\(elapsed) textEqual=\(original == reconstructed) " + + "clipboardUnchanged=\(NSPasteboard.general.changeCount == beforeClipboard)\n" + try report.write(to: directory.appending(path: "result.txt"), atomically: true, encoding: .utf8) + host.window.setFrameOrigin(NSPoint(x: 30, y: 120)) + replay.window.setFrameOrigin(NSPoint(x: 770, y: 120)) + host.window.orderFront(nil) + replay.window.orderFront(nil) + // A short opt-in inspection window permits screenshots via the UI tool. + try await wait(timeout: .seconds(150)) { + ProcessInfo.processInfo.environment["PROWL_VT_EXPORT_INSPECT"] != "1" + || FileManager.default.fileExists(atPath: directory.appending(path: "finish").path) + } + } + + private final class Clipboard { + var path: String? + } + + private final class Terminal { + let window: NSWindow + let view: NSView + let clipboard = Clipboard() + let config: ghostty_config_t + let app: ghostty_app_t + let surface: ghostty_surface_t + var timer: Timer? + var size: ghostty_surface_size_s { ghostty_surface_size(surface) } + + init(directory: URL, file: String, title: String) throws { + config = try #require(ghostty_config_new()) + let configuration = directory.appending(path: "ghostty.conf") + try + "font-size = 12\nbackground = #20242c\nforeground = #e0e0e0\nscrollback-limit = 10000000\n" + .write( + to: configuration, atomically: true, encoding: .utf8) + ghostty_config_load_file(config, configuration.path) + ghostty_config_finalize(config) + var runtime = ghostty_runtime_config_s( + userdata: nil, supports_selection_clipboard: false, + wakeup_cb: { @Sendable _ in }, action_cb: { @Sendable _, _, _ in false }, + read_clipboard_cb: { @Sendable _, _, _ in false }, + confirm_read_clipboard_cb: { @Sendable _, _, _, _ in }, + write_clipboard_cb: { @Sendable userdata, _, content, length, _ in + guard let userdata, let content, length == 1, let data = content.pointee.data else { + return + } + let path = String(cString: data) + let pointer = UInt(bitPattern: userdata) + MainActor.assumeIsolated { + Unmanaged.fromOpaque(UnsafeMutableRawPointer(bitPattern: pointer)!) + .takeUnretainedValue().path = path + } + }, close_surface_cb: { @Sendable _, _ in }) + app = try #require(ghostty_app_new(&runtime, config)) + view = NSView(frame: NSRect(x: 0, y: 0, width: 720, height: 520)) + view.wantsLayer = true + window = NSWindow( + contentRect: view.frame, styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.title = title + window.contentView = view + var settings = ghostty_surface_config_new() + settings.platform_tag = GHOSTTY_PLATFORM_MACOS + settings.platform = ghostty_platform_u( + macos: ghostty_platform_macos_s(nsview: Unmanaged.passUnretained(view).toOpaque())) + settings.userdata = Unmanaged.passUnretained(clipboard).toOpaque() + settings.scale_factor = window.backingScaleFactor + let script = directory.appending(path: file + ".sh") + try + "#!/bin/bash\nstty -echo\ncat '\(directory.appending(path: file).path)'\nwhile IFS= read -r line; do :; done\n" + .write( + to: script, atomically: true, encoding: .utf8) + let command = strdup("/bin/bash '\(script.path)'")! + defer { free(command) } + settings.command = UnsafePointer(command) + surface = try #require(ghostty_surface_new(app, &settings)) + ghostty_surface_set_content_scale( + surface, window.backingScaleFactor, window.backingScaleFactor) + ghostty_surface_set_size( + surface, UInt32(720 * window.backingScaleFactor), UInt32(520 * window.backingScaleFactor)) + let appBits = UInt(bitPattern: app) + timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { _ in + ghostty_app_tick(UnsafeMutableRawPointer(bitPattern: appBits)) + } + } + + func action(_ value: String) { + _ = ghostty_surface_binding_action(surface, value, UInt(value.utf8.count)) + } + + func text() -> String { + var result = ghostty_text_s() + let selection = ghostty_selection_s( + top_left: .init( + tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_TOP_LEFT, x: 0, y: 0), + bottom_right: .init( + tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, x: 0, y: 0), + rectangle: false) + guard ghostty_surface_read_text(surface, selection, &result) else { return "" } + defer { ghostty_surface_free_text(surface, &result) } + guard let bytes = result.text else { return "" } + return String( + bytes: UnsafeBufferPointer( + start: UnsafeRawPointer(bytes).assumingMemoryBound(to: UInt8.self), + count: Int(result.text_len)), encoding: .utf8) ?? "" + } + + func export(format: String = "vt") throws -> Data { + clipboard.path = nil + let binding = "write_screen_file:copy,\(format)" + try #require(ghostty_surface_binding_action(surface, binding, UInt(binding.utf8.count))) + let file = URL(filePath: try #require(clipboard.path)) + let data = try Data(contentsOf: file) + try FileManager.default.removeItem(at: file) + return data + } + + func close() { + timer?.invalidate() + ghostty_surface_free(surface) + ghostty_app_free(app) + ghostty_config_free(config) + window.close() + } + } + + private func wait(timeout: Duration = .seconds(15), until condition: @MainActor () throws -> Bool) + async throws + { + let (ticks, continuation) = AsyncStream.makeStream() + let timer = Timer.scheduledTimer(withTimeInterval: 0.025, repeats: true) { _ in + continuation.yield(()) + } + defer { + timer.invalidate() + continuation.finish() + } + let deadline = ContinuousClock.now.advanced(by: timeout) + for await _ in ticks { + if try condition() { return } + if ContinuousClock.now >= deadline { throw NSError(domain: "VTExportSpikeTimeout", code: 1) } + } + throw CancellationError() + } +} -- 2.51.2 From ebf2afe6f5ecc3b896f6b6b3cc7c508957d8fbd1 Mon Sep 17 00:00:00 2001 From: Awhisper <4347733+Awhisper@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:15:05 +0800 Subject: [PATCH 08/14] fix: preserve terminal rendering for Mac mirror scrollback --- ...012-scroll-controls-and-report-feedback.md | 31 +++++ docs/remote-mirror-wire.md | 10 ++ docs/remote-mirror.md | 9 ++ .../GhosttyMirrorPaneSource.swift | 104 ++++++++++++++-- .../Features/RemoteMirror/MirrorClient.swift | 9 +- .../Features/RemoteMirror/MirrorHost.swift | 12 +- .../RemoteMirror/MirrorPaneSource.swift | 4 + .../RemoteMirror/MirrorProtocol.swift | 17 +++ .../Features/RemoteMirror/MirrorReplica.swift | 113 +++++++++++++++++- .../RemoteMirror/MirrorViewportState.swift | 5 +- .../RemoteMirror/RemoteMirrorPaneView.swift | 5 +- .../Ghostty/GhosttyRuntime+Callbacks.swift | 14 ++- .../Ghostty/GhosttySurfaceBridge.swift | 13 ++ .../MirrorTerminalIntegrationTests.swift | 100 ++++++++++++---- .../MirrorViewportStateTests.swift | 23 ++++ 15 files changed, 425 insertions(+), 44 deletions(-) diff --git a/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md b/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md index a8a8428c..3ea36f9c 100644 --- a/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md +++ b/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md @@ -103,3 +103,34 @@ run directly after an initial emulator instrumentation startup crash (zero tests executed in the failed launch). Mac Debug and signed iPhoneOS builds succeed. Full repository checks still stop at the five existing RepositoryIconImage lint violations; changed Mac files pass SwiftLint and formatting. + +## Mac styled native scrollback (2026-10-02) + +The plain NSTextView fallback uses different text shaping from the live Ghostty +surface, so matching its nominal font is insufficient. A negotiated Mac-only +`styled-scrollback-v1` extension carries a VT archive and native viewport offset +alongside the existing viewport metadata. The replica reuses its live Ghostty +surface and font settings. Mobile text-v1, retained History, and Ghostty source +and binaries remain unchanged. + +The existing `write_screen_file:copy,vt` binding exports retained rows with styles. +A synchronous surface-scoped callback intercepts only that binding's temporary +file path, leaving the system clipboard untouched. Bounded archives are cached +briefly; unbounded/oversized histories keep the established text fallback. The +replica restores live input modes, replays history with trailing blank padding, waits for a +private end-of-stream title marker, scrolls locally, and verifies the visible +text against Host before revealing styled history. PTY write acknowledgement +alone is insufficient because parsing may still be in flight. + +The archive omits trailing empty rows. Locating from the retained buffer start +rather than its bottom avoids dropped rows at the active/history boundary. +Bindings enqueue scroll work asynchronously too: verify the viewport for up to +500 ms before acknowledging and revealing it. A mismatch keeps the text fallback. +Exports are limited to 5,000 retained rows, 250,000 grid cells and 2 MiB; a large +Host buffer or a smaller client retention setting can therefore use plain text. + +Verification: 70 selected Mac tests passed, including real native/TUI scrolling, +wide-character reflow, unchanged font name and size, clipboard preservation, +mobile text-v1, wire vectors, Host leases and input report suppression. +Ghostty source and framework checksum remain unchanged. Physical remote cfuse +acceptance is still required; no mobile rebuild is needed. diff --git a/docs/remote-mirror-wire.md b/docs/remote-mirror-wire.md index 076a4a78..934ea27d 100644 --- a/docs/remote-mirror-wire.md +++ b/docs/remote-mirror-wire.md @@ -175,3 +175,13 @@ a fresh connection without a saved credential. Host still enforces expiry and single use. The payload is not persisted or logged; transport authentication and credential storage are unchanged. Version 1 is generated by Host and parsed by both mobile apps. Cameras are not required for manual enrollment. + +### Optional Mac styled scrollback + +`styled-scrollback-v1` is an opt-in capability for `vt-v1` subscriptions with +`includeViewportText: true` and `includeStyledScrollback: true`. The correlated +`viewport` message may contain `styledScrollback: { bytes, rowOffset }`. +`bytes` uses Codable Data/base64 encoding; archives are capped at 2 MiB and +zero-based offsets at 5,000 rows. Missing metadata retains the existing plain-text +fallback. No new messages are sent to text-v1 subscribers. Binary VT frames and +mobile wire behavior are unchanged. diff --git a/docs/remote-mirror.md b/docs/remote-mirror.md index ffe77494..5527dd3b 100644 --- a/docs/remote-mirror.md +++ b/docs/remote-mirror.md @@ -250,3 +250,12 @@ to that address; scanning does not create a tunnel. The QR expires with the existing 60-second, single-use code. Refresh Code on Host and scan again if it expires. Camera permission is requested only when scanning; manual entry remains available if permission is denied or scanning is unsupported. + +### Native history appearance on Mac + +When both Macs support styled scrollback, **Scroll Up/Down** keeps the same +terminal renderer, font, font size and colors used by the live Mirror display. +This is separate from **History**, which remains a retained-text snapshot. +Older Hosts, histories exceeding the replay limit, or a viewport that cannot be +reconstructed safely use the existing **Host scrollback · Plain text** fallback. +Mobile clients continue using text-v1 and require no update for this feature. diff --git a/supacode/Features/RemoteMirror/GhosttyMirrorPaneSource.swift b/supacode/Features/RemoteMirror/GhosttyMirrorPaneSource.swift index b47b61a3..3d97d246 100644 --- a/supacode/Features/RemoteMirror/GhosttyMirrorPaneSource.swift +++ b/supacode/Features/RemoteMirror/GhosttyMirrorPaneSource.swift @@ -15,6 +15,82 @@ final class GhosttyMirrorPaneSource: MirrorPaneSource { return max(1, (max(1, paneRows) - profile.reservedRows - 3) / profile.rowsPerEvent) } + private struct ArchiveCache { + let id: UUID + let frame: MirrorFrame + let offset: Int + let bytes: Data? + let expires: ContinuousClock.Instant + } + private var cachedArchive: ArchiveCache? + var supportsStyledScrollback: Bool { true } + + func snapshot(_ id: UUID, styledScrollback: Bool) throws -> MirrorFrame { + var frame = try snapshot(id) + guard styledScrollback, let text = frame.viewportText, let view = view(id), + let terminal = view.surface, let scroll = view.bridge.mirrorScrollbar, + scroll.total >= scroll.length, scroll.offset <= scroll.total - scroll.length + else { return frame } + let distance = scroll.total - scroll.length - scroll.offset + // The public exporter is unbounded. Keep both the IO lock time and replica + // retention bounded; larger buffers retain the established text fallback. + guard distance > 0, scroll.total <= 5000, scroll.total * UInt64(frame.columns) <= 250_000 else { + return frame + } + let offset = Int(scroll.offset) + let archive: Data? + if let cachedArchive, cachedArchive.id == id, + cachedArchive.frame.columns == frame.columns, cachedArchive.frame.rows == frame.rows, + cachedArchive.frame.viewportText == frame.viewportText, + cachedArchive.offset == offset, ContinuousClock.now < cachedArchive.expires + { + archive = cachedArchive.bytes + } else { + archive = try Self.exportScrollback(view) + let geometry = try captureGeometry(terminal) + guard + try Self.viewportText( + terminal, columns: frame.columns, rows: frame.rows, preserveRows: true) == text, + geometry.columns == frame.columns, geometry.rows == frame.rows + else { throw MirrorPaneSourceError.captureChanged } + cachedArchive = .init( + id: id, frame: frame, offset: offset, bytes: archive, expires: .now.advanced(by: .milliseconds(500))) + } + if let archive { frame.styledScrollback = .init(bytes: archive, rowOffset: offset) } + return frame + } + + static func exportScrollback(_ view: GhosttySurfaceView) throws -> Data? { + guard let terminal = view.surface, view.bridge.captureClipboard == nil else { + throw MirrorProtocolError.invalidMessage + } + var exportedPath: String? + view.bridge.captureClipboard = { items in + if items.count == 1 { exportedPath = items[0].data } + } + defer { view.bridge.captureClipboard = nil } + let action = "write_screen_file:copy,vt" + guard ghostty_surface_binding_action(terminal, action, UInt(action.utf8.count)), + let exportedPath + else { + throw MirrorProtocolError.invalidMessage + } + let url = URL(filePath: exportedPath) + // Ghostty owns the temporary directory; only consume the file it just made. + let handle = try FileHandle(forReadingFrom: url) + let data: Data + do { + data = try handle.read(upToCount: MirrorStyledScrollback.maximumBytes + 1) ?? Data() + try handle.close() + try FileManager.default.removeItem(at: url) + try FileManager.default.removeItem(at: url.deletingLastPathComponent()) + } catch { + try? handle.close() + throw error + } + return data.isEmpty || data.count > MirrorStyledScrollback.maximumBytes ? nil : data + } + let manager: WorktreeTerminalManager init(manager: WorktreeTerminalManager) { @@ -64,8 +140,11 @@ final class GhosttyMirrorPaneSource: MirrorPaneSource { } let viewportText = geometry.active.isScrolled - ? try viewportText(terminal, columns: geometry.columns, rows: geometry.rows, preserveRows: true) : nil - guard try captureGeometry(terminal) == geometry else { throw MirrorPaneSourceError.captureChanged } + ? try Self.viewportText( + terminal, columns: geometry.columns, rows: geometry.rows, preserveRows: true) : nil + guard try captureGeometry(terminal) == geometry else { + throw MirrorPaneSourceError.captureChanged + } return MirrorFrame( columns: geometry.columns, rows: geometry.rows, bytes: Data(bytes: bytes, count: Int(text.text_len)), viewportText: viewportText, @@ -145,9 +224,14 @@ final class GhosttyMirrorPaneSource: MirrorPaneSource { func textSnapshot(_ id: UUID) throws -> MirrorTextSnapshot { guard let terminal = view(id)?.surface else { throw MirrorProtocolError.invalidMessage } let geometry = try captureGeometry(terminal) - let text = try viewportText(terminal, columns: geometry.columns, rows: geometry.rows, preserveRows: false) - guard try captureGeometry(terminal) == geometry else { throw MirrorPaneSourceError.captureChanged } - return .init(text: text, columns: geometry.columns, rows: geometry.rows, scrollBounds: geometry.scrollBounds) + let text = try Self.viewportText( + terminal, columns: geometry.columns, rows: geometry.rows, preserveRows: false) + guard try captureGeometry(terminal) == geometry else { + throw MirrorPaneSourceError.captureChanged + } + return .init( + text: text, columns: geometry.columns, rows: geometry.rows, + scrollBounds: geometry.scrollBounds) } private struct Probe: Equatable { @@ -204,11 +288,11 @@ final class GhosttyMirrorPaneSource: MirrorPaneSource { return .init(pixelY: text.tl_px_y, offset: text.offset_start) } - private func viewportText( + static func viewportText( _ terminal: ghostty_surface_t, columns: UInt32, rows: UInt32, preserveRows: Bool ) throws -> String { if !preserveRows { - return try readText( + return try Self.readText( terminal, selection: .init( top_left: .init(tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_TOP_LEFT, x: 0, y: 0), @@ -220,7 +304,7 @@ final class GhosttyMirrorPaneSource: MirrorPaneSource { var lines: [String] = [] var byteCount = 0 for row in 0.. String { + private static func readText(_ terminal: ghostty_surface_t, selection: ghostty_selection_s) throws + -> String + { var result = ghostty_text_s() guard ghostty_surface_read_text(terminal, selection, &result) else { throw MirrorProtocolError.invalidMessage } defer { ghostty_surface_free_text(terminal, &result) } diff --git a/supacode/Features/RemoteMirror/MirrorClient.swift b/supacode/Features/RemoteMirror/MirrorClient.swift index c91474c8..5058a4cf 100644 --- a/supacode/Features/RemoteMirror/MirrorClient.swift +++ b/supacode/Features/RemoteMirror/MirrorClient.swift @@ -25,6 +25,7 @@ final class MirrorClient: Identifiable { private(set) var supportsRemoteScroll = false private(set) var supportsScrollState = false private(set) var scrollBoundsState = MirrorScrollBoundsState() + private(set) var supportsStyledScrollback = false private(set) var supportsViewportText = false private(set) var viewportState = MirrorViewportState() let scrollState = MirrorScrollState() @@ -174,7 +175,8 @@ final class MirrorClient: Identifiable { .init( paneID: pane.id, representation: .terminal, intent: resumeIntent, includeViewportText: supportsViewportText ? true : nil, - includeScrollState: supportsScrollState ? true : nil)) + includeScrollState: supportsScrollState ? true : nil, + includeStyledScrollback: supportsStyledScrollback ? true : nil)) ) } catch { peer?.close(error.localizedDescription) } } @@ -305,7 +307,9 @@ final class MirrorClient: Identifiable { } } isSubscribed = true - replica.display(message) + replica.display( + message, styledScrollback: viewportState.pendingStyledScrollback, + viewportText: viewportState.pendingText) } private func receiveViewport(_ message: MirrorMessage) { @@ -385,6 +389,7 @@ final class MirrorClient: Identifiable { supportsRemoteScroll = message.capabilities?.contains("remote-scroll") == true supportsScrollState = message.capabilities?.contains("scroll-state-v1") == true supportsViewportText = message.capabilities?.contains("viewport-text-v1") == true + supportsStyledScrollback = message.capabilities?.contains("styled-scrollback-v1") == true supportsTakeover = message.capabilities?.contains("takeover") == true isConnecting = false onVerifiedConnection?() diff --git a/supacode/Features/RemoteMirror/MirrorHost.swift b/supacode/Features/RemoteMirror/MirrorHost.swift index b2a5500b..af0cca86 100644 --- a/supacode/Features/RemoteMirror/MirrorHost.swift +++ b/supacode/Features/RemoteMirror/MirrorHost.swift @@ -153,6 +153,7 @@ final class MirrorHost { let id = UUID() var representation: MirrorMessage.Representation = .terminal var includeViewportText = false + var includeStyledScrollback = false var includeScrollState = false var unstableCaptures = 0 var gate = MirrorFrameGate() @@ -510,6 +511,7 @@ final class MirrorHost { + (source.supportsBoundedHistory ? ["history"] : []) + (source.supportsRemoteScroll ? ["remote-scroll"] : []) + (source.supportsViewportText ? ["viewport-text-v1"] : []) + + (source.supportsStyledScrollback ? ["styled-scrollback-v1"] : []) + (source.supportsScrollState ? ["scroll-state-v1"] : []), hostRunID: hostRunID))) case .command: try handleCommand(message, peer: peer) @@ -703,6 +705,8 @@ final class MirrorHost { paneID: paneID, representation: representation, includeViewportText: representation == .terminal && message.includeViewportText == true && source.supportsViewportText, + includeStyledScrollback: representation == .terminal && message.includeViewportText == true + && message.includeStyledScrollback == true && source.supportsStyledScrollback, includeScrollState: message.includeScrollState == true && source.supportsScrollState) // Prepare and encode before revoking the old lease. Capture failure leaves it intact. let first = try capture(&next) @@ -761,7 +765,8 @@ final class MirrorHost { sequence: sequence, text: text, subscriptionID: subscription.id, scrollBounds: bounds)) } guard subscription.gate.outstanding == nil else { return nil } - var frame = try source.snapshot(subscription.paneID) + var frame = try source.snapshot( + subscription.paneID, styledScrollback: subscription.includeStyledScrollback) if !subscription.includeViewportText { frame.viewportText = nil } if !subscription.includeScrollState { frame.scrollBounds = nil } guard let sequence = subscription.gate.offer(frame) else { return nil } @@ -771,7 +776,10 @@ final class MirrorHost { private func send(_ message: MirrorMessage, subscription: Subscription, to peer: MirrorConnection) { if subscription.includeViewportText, case .frame(let payload) = message { peer.send( - .viewport(.init(text: payload.frame.viewportText, sequence: payload.sequence, subscriptionID: subscription.id))) + .viewport( + .init( + styledScrollback: payload.frame.styledScrollback, text: payload.frame.viewportText, + sequence: payload.sequence, subscriptionID: subscription.id))) } if subscription.includeScrollState, let sequence = message.sequence { let bounds: MirrorScrollBounds? diff --git a/supacode/Features/RemoteMirror/MirrorPaneSource.swift b/supacode/Features/RemoteMirror/MirrorPaneSource.swift index a76affde..a9fe30bc 100644 --- a/supacode/Features/RemoteMirror/MirrorPaneSource.swift +++ b/supacode/Features/RemoteMirror/MirrorPaneSource.swift @@ -3,6 +3,8 @@ import Foundation @MainActor protocol MirrorPaneSource { func panes() -> [MirrorPaneDescriptor] + var supportsStyledScrollback: Bool { get } + func snapshot(_ id: UUID, styledScrollback: Bool) throws -> MirrorFrame func snapshot(_ id: UUID) throws -> MirrorFrame func write(_ bytes: Data, to id: UUID) throws func textSnapshot(_ id: UUID) throws -> MirrorTextSnapshot @@ -24,6 +26,8 @@ struct MirrorTextSnapshot { } extension MirrorPaneSource { + var supportsStyledScrollback: Bool { false } + func snapshot(_ id: UUID, styledScrollback: Bool) throws -> MirrorFrame { try snapshot(id) } func textSnapshot(_ id: UUID) throws -> MirrorTextSnapshot { .init(text: try activeText(id)) } var supportsViewportText: Bool { false } var supportsScrollState: Bool { false } diff --git a/supacode/Features/RemoteMirror/MirrorProtocol.swift b/supacode/Features/RemoteMirror/MirrorProtocol.swift index 54085dd2..2fff5e0c 100644 --- a/supacode/Features/RemoteMirror/MirrorProtocol.swift +++ b/supacode/Features/RemoteMirror/MirrorProtocol.swift @@ -16,6 +16,17 @@ nonisolated struct MirrorFrame: Codable, Equatable, Sendable { let bytes: Data var viewportText: String? var scrollBounds: MirrorScrollBounds? + var styledScrollback: MirrorStyledScrollback? +} + +nonisolated struct MirrorStyledScrollback: Codable, Equatable, Sendable { + static let maximumBytes = 2 * 1024 * 1024 + let bytes: Data + let rowOffset: Int + + var isValid: Bool { + !bytes.isEmpty && bytes.count <= Self.maximumBytes && (0...5000).contains(rowOffset) + } } nonisolated struct MirrorScrollBounds: Codable, Equatable, Sendable { @@ -74,6 +85,7 @@ nonisolated enum MirrorMessage: Codable, Sendable { var intent: Intent var includeViewportText: Bool? var includeScrollState: Bool? + var includeStyledScrollback: Bool? } case subscribed(SubscribedPayload) struct SubscribedPayload: Codable, Sendable { @@ -99,6 +111,7 @@ nonisolated enum MirrorMessage: Codable, Sendable { } case viewport(ViewportPayload) struct ViewportPayload: Codable, Sendable { + var styledScrollback: MirrorStyledScrollback? var text: String? var sequence: UInt64 var subscriptionID: UUID @@ -296,6 +309,10 @@ nonisolated enum MirrorMessage: Codable, Sendable { default: nil } } + var includeStyledScrollback: Bool? { + if case .subscribe(let payload) = self { return payload.includeStyledScrollback } + return nil + } var includeViewportText: Bool? { switch self { case .subscribe(let payload): payload.includeViewportText diff --git a/supacode/Features/RemoteMirror/MirrorReplica.swift b/supacode/Features/RemoteMirror/MirrorReplica.swift index bc758796..56e3170f 100644 --- a/supacode/Features/RemoteMirror/MirrorReplica.swift +++ b/supacode/Features/RemoteMirror/MirrorReplica.swift @@ -7,6 +7,7 @@ import Observation @MainActor @Observable final class MirrorReplica { + private(set) var usesStyledScrollback = false private(set) var view: GhosttySurfaceView? private(set) var displaySize = CGSize(width: 800, height: 600) var onMessage: ((MirrorMessage) -> Void)? @@ -17,6 +18,13 @@ final class MirrorReplica { @ObservationIgnored private let runtime: GhosttyRuntime @ObservationIgnored private let token = UUID().uuidString + UUID().uuidString @ObservationIgnored private var pending: MirrorMessage? + @ObservationIgnored private var pendingScrollback: MirrorStyledScrollback? + @ObservationIgnored private var pendingViewportText: String? + @ObservationIgnored private var displayedScrollback: MirrorStyledScrollback? + @ObservationIgnored private var expectedViewportText: String? + @ObservationIgnored private var presentationPaused = false + @ObservationIgnored private var presentationTask: Task? + @ObservationIgnored private var presentationMarker: String? @ObservationIgnored private var displayedMessage: MirrorMessage? @ObservationIgnored private var stopped = false @ObservationIgnored private var needsRestart = false @@ -47,6 +55,11 @@ final class MirrorReplica { self.view = GhosttySurfaceView( runtime: self.runtime, workingDirectory: nil, context: GHOSTTY_SURFACE_CONTEXT_WINDOW, command: command) + self.view?.bridge.consumeTitle = { [weak self] title in + guard let self, title == self.presentationMarker else { return false } + self.didParseFrame() + return true + } case .failed(let error): self.fail(error.localizedDescription) default: break } @@ -58,7 +71,10 @@ final class MirrorReplica { listener.start(queue: .main) } - func display(_ message: MirrorMessage) { + func display( + _ message: MirrorMessage, styledScrollback: MirrorStyledScrollback? = nil, + viewportText: String? = nil + ) { guard let frame = message.frame, frame.columns >= 1, frame.columns <= 1000, frame.rows >= 1, frame.rows <= 1000 else { @@ -75,17 +91,93 @@ final class MirrorReplica { height: CGFloat(size.height_px) / scale) } if let peer, let sequence = message.sequence { + guard styledScrollback?.isValid != false else { + fail(String(localized: "Invalid Host viewport frame.")) + return + } + if styledScrollback != nil || usesStyledScrollback { + presentationPaused = true + view?.setOcclusion(false) + } displayedMessage = message + displayedScrollback = styledScrollback + expectedViewportText = viewportText + let marker = "prowl-replica-\(token)-\(sequence)" + presentationMarker = marker var payload = MirrorRelayPacket.sequenceBytes(sequence) payload.append(frame.bytes) + if let styledScrollback { + // Establish the live input modes first, then replace the display with + // the archive. The exporter omits trailing empty rows, so pad one + // viewport and position from the retained buffer's start, not its end. + payload.append(Data("\u{1b}[H\u{1b}[2J\u{1b}[3J\u{1b}[0m".utf8)) + payload.append(styledScrollback.bytes) + payload.append(Data(String(repeating: "\r\n", count: Int(frame.rows)).utf8)) + payload.append(Data("\u{1b}[?25l".utf8)) + } + payload.append(Data("\u{1b}]2;\(marker)\u{7}".utf8)) peer.send(MirrorRelayPacket(kind: .frame, payload: payload)) } else { pending = message + pendingScrollback = styledScrollback + pendingViewportText = viewportText } } + private func didParseFrame() { + guard let message = displayedMessage, let sequence = message.sequence, + let lease = message.subscriptionID, let terminal = view?.surface + else { return } + presentationMarker = nil + guard let scrollback = displayedScrollback else { + usesStyledScrollback = false + resumeRendering() + onMessage?(.acknowledge(.init(sequence: sequence, subscriptionID: lease))) + return + } + let top = "scroll_to_top" + let scroll = "scroll_page_lines:\(scrollback.rowOffset)" + guard ghostty_surface_binding_action(terminal, top, UInt(top.utf8.count)), + ghostty_surface_binding_action(terminal, scroll, UInt(scroll.utf8.count)) + else { + fail(String(localized: "Invalid Host viewport frame.")) + return + } + // Scroll bindings enqueue IO work too. Verify after that queue advances, + // rather than acknowledging the still-visible bottom of the archive. + presentationTask = Task { @MainActor [weak self] in + var verified = false + for _ in 0..<50 { + do { try await Task.sleep(for: .milliseconds(10)) } catch { return } + guard let self, !self.stopped, self.displayedMessage?.sequence == sequence, + let terminal = self.view?.surface, let frame = message.frame + else { return } + if let text = try? GhosttyMirrorPaneSource.viewportText( + terminal, columns: frame.columns, rows: frame.rows, preserveRows: true), + text == self.expectedViewportText + { + verified = true + break + } + } + guard let self, !Task.isCancelled, !self.stopped else { return } + self.usesStyledScrollback = verified + self.resumeRendering() + self.presentationTask = nil + self.onMessage?(.acknowledge(.init(sequence: sequence, subscriptionID: lease))) + } + } + + private func resumeRendering() { + guard presentationPaused else { return } + presentationPaused = false + view?.setOcclusion(view?.window?.occlusionState.contains(.visible) == true) + } + func stop() { stopped = true + presentationTask?.cancel() + presentationTask = nil needsRestart = false listener?.cancel() listener = nil @@ -97,6 +189,13 @@ final class MirrorReplica { view = nil pending = nil displayedMessage = nil + pendingScrollback = nil + pendingViewportText = nil + displayedScrollback = nil + expectedViewportText = nil + presentationMarker = nil + presentationPaused = false + usesStyledScrollback = false } private func fail(_ reason: String) { @@ -127,7 +226,11 @@ final class MirrorReplica { self.listener = nil if let pending = self.pending { self.pending = nil - self.display(pending) + self.display( + pending, styledScrollback: self.pendingScrollback, + viewportText: self.pendingViewportText) + self.pendingScrollback = nil + self.pendingViewportText = nil } } else { guard let displayed = self.displayedMessage, let lease = displayed.subscriptionID else { @@ -139,13 +242,13 @@ final class MirrorReplica { .input(.init(bytes: packet.payload, subscriptionID: lease))) case .acknowledge: guard let sequence = try? MirrorRelayPacket.sequence(packet.payload), - sequence == displayed.sequence + sequence <= (displayed.sequence ?? 0) else { candidate.close(String(localized: "Invalid display acknowledgement.")) return } - self.onMessage?( - .acknowledge(.init(sequence: sequence, subscriptionID: lease))) + // PTY write completion precedes parsing. The private OSC marker + // above is the presentation barrier, including history positioning. default: candidate.close(String(localized: "Invalid display relay message.")) } } diff --git a/supacode/Features/RemoteMirror/MirrorViewportState.swift b/supacode/Features/RemoteMirror/MirrorViewportState.swift index c76cc3f0..9df3daff 100644 --- a/supacode/Features/RemoteMirror/MirrorViewportState.swift +++ b/supacode/Features/RemoteMirror/MirrorViewportState.swift @@ -4,12 +4,15 @@ import Foundation struct MirrorViewportState { private(set) var text: String? private var pending: MirrorMessage.ViewportPayload? + var pendingText: String? { pending?.text } + var pendingStyledScrollback: MirrorStyledScrollback? { pending?.styledScrollback } private var receivedSequence: UInt64? private var presentedSequence: UInt64 = 0 mutating func stage(_ payload: MirrorMessage.ViewportPayload) throws { guard pending == nil, payload.sequence > presentedSequence, - (payload.text?.utf8.count ?? 0) <= MirrorWire.maximumPayload / 8 + (payload.text?.utf8.count ?? 0) <= MirrorWire.maximumPayload / 8, + payload.styledScrollback?.isValid != false else { throw MirrorProtocolError.invalidMessage } pending = payload } diff --git a/supacode/Features/RemoteMirror/RemoteMirrorPaneView.swift b/supacode/Features/RemoteMirror/RemoteMirrorPaneView.swift index 7e12a054..b1819fab 100644 --- a/supacode/Features/RemoteMirror/RemoteMirrorPaneView.swift +++ b/supacode/Features/RemoteMirror/RemoteMirrorPaneView.swift @@ -63,7 +63,8 @@ struct RemoteMirrorPaneView: View { if let view = client.replica.view { MirrorTerminalViewport( surface: view, displaySize: client.replica.displaySize, fitsWindow: fitsWindow, - viewportText: client.viewportState.text, scrollCompletion: client.scrollState.completedRequestID + viewportText: client.replica.usesStyledScrollback ? nil : client.viewportState.text, + scrollCompletion: client.scrollState.completedRequestID ) .opacity(client.showsHistory ? 0 : 1) .allowsHitTesting(!client.showsHistory) @@ -175,7 +176,7 @@ struct RemoteMirrorPaneView: View { } else if let error = client.scrollState.error { Text(error).font(.caption).foregroundStyle(.secondary).textSelection(.enabled) .help(error) - } else if client.viewportState.text != nil { + } else if client.viewportState.text != nil && !client.replica.usesStyledScrollback { Text("Host scrollback · Plain text").font(.caption).foregroundStyle(.secondary) } } diff --git a/supacode/Infrastructure/Ghostty/GhosttyRuntime+Callbacks.swift b/supacode/Infrastructure/Ghostty/GhosttyRuntime+Callbacks.swift index d3630601..3b7f4b25 100644 --- a/supacode/Infrastructure/Ghostty/GhosttyRuntime+Callbacks.swift +++ b/supacode/Infrastructure/Ghostty/GhosttyRuntime+Callbacks.swift @@ -120,7 +120,6 @@ extension GhosttyRuntime { _ len: Int, _ confirm: Bool ) { - _ = userdata guard let content, len > 0 else { return } let items: [(mime: String, data: String)] = (0.. Bool)? + var captureClipboard: (([(mime: String, data: String)]) -> Void)? + struct MirrorScrollbar { + let total: UInt64 + let offset: UInt64 + let length: UInt64 + } + private(set) var mirrorScrollbar: MirrorScrollbar? var onTitleChange: ((String) -> Void)? var onSplitAction: ((GhosttySplitAction) -> Bool)? var onCloseRequest: ((Bool) -> Void)? @@ -237,6 +246,9 @@ final class GhosttySurfaceBridge { switch action.tag { case GHOSTTY_ACTION_SET_TITLE: // TUIs re-emit the same title constantly; skip the no-op write + a11y post. + if let title = string(from: action.action.set_title.title), consumeTitle?(title) == true { + return true + } if let title = string(from: action.action.set_title.title), title != state.title { state.title = title onTitleChange?(title) @@ -444,6 +456,7 @@ final class GhosttySurfaceBridge { switch action.tag { case GHOSTTY_ACTION_SCROLLBAR: let scroll = action.action.scrollbar + mirrorScrollbar = .init(total: scroll.total, offset: scroll.offset, length: scroll.len) surfaceView?.updateScrollbar( total: scroll.total, offset: scroll.offset, diff --git a/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift b/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift index d2ab456f..890008f0 100644 --- a/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift +++ b/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift @@ -14,7 +14,46 @@ import Testing @Suite(.serialized) @MainActor struct MirrorTerminalIntegrationTests { - @Test(.timeLimit(.minutes(1))) func remoteScrollMovesRealViewportAndPreservesFrozenHistory() async throws { + @Test(.timeLimit(.minutes(1))) func styledNativeScrollbackSurvivesWideRowsAndReflow() async throws { + let fixture = try Fixture() + defer { fixture.close() } + try await fixture.wait("Host ready") { fixture.hostText.contains("READY") } + try await fixture.startHost() + let client = try await fixture.connect() + try fixture.send("wrapped") + try await fixture.wait("Wide output received") { fixture.replicaText(client).contains("W250") } + let replica = try #require(client.replica.view) + let originalFont = try #require(ghostty_surface_quicklook_font(try #require(replica.surface))) + let font = Unmanaged.fromOpaque(originalFont).takeRetainedValue() + let clipboard = NSPasteboard.general.changeCount + for _ in 0..<3 { + client.scroll(.upward) + try await fixture.wait("Styled wrapped page") { !client.scrollState.isLoading } + #expect(client.scrollState.error == nil) + #expect(client.replica.usesStyledScrollback) + #expect(client.replica.view === replica) + let currentFont = try #require(ghostty_surface_quicklook_font(try #require(replica.surface))) + let current = Unmanaged.fromOpaque(currentFont).takeRetainedValue() + #expect(CTFontCopyPostScriptName(font) == CTFontCopyPostScriptName(current)) + #expect(CTFontGetSize(font) == CTFontGetSize(current)) + } + #expect(NSPasteboard.general.changeCount == clipboard) + fixture.hostView.setFrameSize(NSSize(width: 480, height: 420)) + fixture.hostView.updateSurfaceSize() + try await fixture.wait("Styled resized page") { + let terminal = try #require(fixture.hostView.surface) + return client.replica.usesStyledScrollback + && client.replica.view?.mirrorGrid?.columns + == UInt32(ghostty_surface_size(terminal).columns) + } + client.scroll(.downward) + try await fixture.wait("Styled downward page") { !client.scrollState.isLoading } + #expect(client.replica.usesStyledScrollback) + } + + @Test(.timeLimit(.minutes(1))) func remoteScrollMovesRealViewportAndPreservesFrozenHistory() + async throws + { let fixture = try Fixture() defer { fixture.close() } try await fixture.wait("Host program ready") { fixture.hostText.contains("READY") } @@ -53,11 +92,8 @@ struct MirrorTerminalIntegrationTests { fixture.manager.activeWorktreeStates.first { $0.surfaces[fixture.hostView.id] != nil }? .surfaceAgentStates[fixture.hostView.id] = PaneAgentState(detectedAgent: .codex) let initialText = try fixture.source.textSnapshot(fixture.hostView.id).text - let pageRows = max(1, Int(ghostty_surface_size(try #require(fixture.hostView.surface)).rows) - 3) - func firstHistoryRow(_ text: String) throws -> Int { - let row = try #require(text.split(separator: "\n").first { $0.hasPrefix("HISTORY:") }) - return try #require(Int(row.dropFirst("HISTORY:".count))) - } + let pageRows = max( + 1, Int(ghostty_surface_size(try #require(fixture.hostView.surface)).rows) - 3) client.scroll(.upward) #expect(client.scrollState.isLoading) try await fixture.wait("Earlier remote viewport") { @@ -68,18 +104,17 @@ struct MirrorTerminalIntegrationTests { #expect(client.scrollState.error == nil) let earlier = try #require(client.viewportState.text) #expect(try firstHistoryRow(initialText) - firstHistoryRow(earlier) == pageRows) - #expect(earlier.contains("HISTORY:")) #expect(!earlier.contains("HISTORY:450")) - // Original Ghostty snapshots remain the styled active screen. The paired - // overlay is what presents native scrollback on Mac, without replaying it. - #expect(fixture.replicaText(client).contains("HISTORY:450")) + // The same Ghostty surface now displays native history, retaining its font and styles. + #expect(client.replica.usesStyledScrollback) + #expect(client.replica.view === replica) + try verifyStyledReplica(replica, expected: earlier) #expect(earlier == (try fixture.sourceFrame()).viewportText) #expect( earlier.trimmingCharacters(in: .newlines) - == (try fixture.source.textSnapshot(fixture.hostView.id)).text.trimmingCharacters(in: .newlines)) - try await fixture.wait("Observable pane presents viewport overlay") { - !overlay.isHidden && overlay.string == earlier - } + == (try fixture.source.textSnapshot(fixture.hostView.id)).text.trimmingCharacters( + in: .newlines)) + try await fixture.wait("Observable pane retains the terminal renderer") { overlay.isHidden } var jumpedBack = false let stableUntil = ContinuousClock.now.advanced(by: .seconds(2)) try await fixture.wait("Viewport stays scrolled across later polls") { @@ -87,13 +122,16 @@ struct MirrorTerminalIntegrationTests { return ContinuousClock.now >= stableUntil } #expect(!jumpedBack) - #expect(try fixture.source.snapshot(fixture.hostView.id).scrollBounds == .init(atTop: false, atBottom: false)) - try verifyOverlayPreservesOtherInputFocus(overlay, container: container, window: window) + #expect( + try fixture.source.snapshot(fixture.hostView.id).scrollBounds + == .init(atTop: false, atBottom: false)) + do { + let viewport = try #require(overlay.enclosingScrollView as? MirrorTerminalScrollView) + viewport.updateViewportText(earlier) + try verifyOverlayPreservesOtherInputFocus(overlay, container: container, window: window) + viewport.updateViewportText(nil) + } hosting.layoutSubtreeIfNeeded() - let bitmap = try #require(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) - hosting.cacheDisplay(in: hosting.bounds, to: bitmap) - let image = try #require(bitmap.representation(using: .png, properties: [:])) - try image.write(to: URL(filePath: "/tmp/prowl-scroll-mac-native.png")) #expect(client.historyLines == history) #expect(client.historyOffset == offset) client.scroll(.downward) @@ -114,7 +152,25 @@ struct MirrorTerminalIntegrationTests { #expect(client.historyLines == history) } - private func verifyOverlayPreservesOtherInputFocus(_ overlay: NSTextView, container: NSView, window: NSWindow) + private func verifyStyledReplica(_ replica: GhosttySurfaceView, expected: String) throws { + let terminal = try #require(replica.surface) + let size = ghostty_surface_size(terminal) + let actual = try GhosttyMirrorPaneSource.viewportText( + terminal, columns: UInt32(size.columns), rows: UInt32(size.rows), preserveRows: true) + #expect(actual == expected) + let archive = try #require(try GhosttyMirrorPaneSource.exportScrollback(replica)) + let vt = try #require(String(data: archive, encoding: .utf8)) + #expect(vt.contains("38;2;80;190;240")) + } + + private func firstHistoryRow(_ text: String) throws -> Int { + let row = try #require(text.split(separator: "\n").first { $0.hasPrefix("HISTORY:") }) + return try #require(Int(row.dropFirst("HISTORY:".count))) + } + + private func verifyOverlayPreservesOtherInputFocus( + _ overlay: NSTextView, container: NSView, window: NSWindow + ) throws { let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 180, height: 24)) @@ -982,7 +1038,7 @@ struct MirrorTerminalIntegrationTests { finish) printf '\033[2J\033[H\033[32mFINAL:结论\033[0m\033[2;3H';; history|focus-history) if [ "$action" = focus-history ]; then printf '\033[?1004h'; fi - for ((n=1; n<=450; n++)); do printf 'HISTORY:%03d\n' "$n"; done;; + for ((n=1; n<=450; n++)); do printf '\033[38;2;80;190;240mHISTORY:%03d\033[0m\n' "$n"; done;; mouse-mode) printf '\033[?1000h\033[?1006hMOUSE-READY';; wrapped) columns=$(stty size); columns=${columns##* } diff --git a/supacodeTests/RemoteMirror/MirrorViewportStateTests.swift b/supacodeTests/RemoteMirror/MirrorViewportStateTests.swift index 49f47225..cd82c1ca 100644 --- a/supacodeTests/RemoteMirror/MirrorViewportStateTests.swift +++ b/supacodeTests/RemoteMirror/MirrorViewportStateTests.swift @@ -5,6 +5,29 @@ import Testing @MainActor struct MirrorViewportStateTests { + @Test func styledArchiveIsBoundedAndStagedWithItsFrame() throws { + var state = MirrorViewportState() + let lease = UUID() + let valid = MirrorStyledScrollback(bytes: Data("styled".utf8), rowOffset: 20) + try state.stage( + .init(styledScrollback: valid, text: "expected", sequence: 1, subscriptionID: lease)) + #expect(state.pendingStyledScrollback == valid) + try state.receiveFrame(sequence: 1) + try state.didPresent(sequence: 1) + #expect(state.pendingStyledScrollback == nil) + for invalid in [ + MirrorStyledScrollback(bytes: Data(), rowOffset: 1), + MirrorStyledScrollback(bytes: Data([1]), rowOffset: -1), + MirrorStyledScrollback( + bytes: Data(repeating: 1, count: MirrorStyledScrollback.maximumBytes + 1), rowOffset: 1), + ] { + #expect(throws: MirrorProtocolError.invalidMessage) { + try state.stage( + .init(styledScrollback: invalid, text: "fallback", sequence: 2, subscriptionID: lease)) + } + } + } + @Test func textChangesOnlyWhenTheMatchingTerminalFrameIsPresented() throws { var state = MirrorViewportState() let lease = UUID() -- 2.51.2 From 614531e66a49ab9bc109d35490e8eb3b55de8545 Mon Sep 17 00:00:00 2001 From: Awhisper <4347733+Awhisper@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:16:56 +0800 Subject: [PATCH 09/14] Fix Android pairing scanner and remote scroll toolbar layout --- MirrorClient/Android/README.md | 5 ++- .../prowlmirror/PairingCaptureTest.kt | 41 +++++++++++++++++++ .../awhisper/prowlmirror/RemoteScrollTest.kt | 6 +++ .../Android/app/src/main/AndroidManifest.xml | 1 + .../com/awhisper/prowlmirror/MirrorApp.kt | 14 ++++--- .../prowlmirror/PairingCaptureActivity.kt | 9 ++++ .../awhisper/prowlmirror/PairingScanButton.kt | 1 + docs/remote-mirror.md | 3 ++ 8 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/PairingCaptureTest.kt create mode 100644 MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/PairingCaptureActivity.kt diff --git a/MirrorClient/Android/README.md b/MirrorClient/Android/README.md index ac8c737e..2efc710b 100644 --- a/MirrorClient/Android/README.md +++ b/MirrorClient/Android/README.md @@ -93,4 +93,7 @@ Delivery confirmation and reconnect receipt lookup remain separate from task suc Use **Scan QR Code** in the Host connection form to scan **Add a Device** on Host. Scanning connects with the encoded address, port and short-lived code. Camera -permission is optional; manual entry remains available. Refresh expired codes on Host. +permission is optional; manual entry remains available. The scanner opens in portrait +without a red scanning line. Refresh expired codes on Host. +The Live scroll toolbar places Scroll up on the left and Scroll down on the right, +with loading feedback centered between them. diff --git a/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/PairingCaptureTest.kt b/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/PairingCaptureTest.kt new file mode 100644 index 00000000..72afffba --- /dev/null +++ b/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/PairingCaptureTest.kt @@ -0,0 +1,41 @@ +package com.awhisper.prowlmirror + +import android.Manifest +import android.content.res.Configuration +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.journeyapps.barcodescanner.DecoratedBarcodeView +import com.journeyapps.barcodescanner.ScanOptions +import com.journeyapps.barcodescanner.ViewfinderView +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PairingCaptureTest { + @Test + fun scannerUsesPortraitWithoutTheLaserOverlay() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = instrumentation.targetContext + instrumentation.uiAutomation.grantRuntimePermission(context.packageName, Manifest.permission.CAMERA) + val intent = ScanOptions() + .setCaptureActivity(PairingCaptureActivity::class.java) + .setDesiredBarcodeFormats(ScanOptions.QR_CODE) + .setOrientationLocked(false) + .createScanIntent(context) + ActivityScenario.launch(intent).use { scenario -> + scenario.onActivity { activity -> + assertEquals(Configuration.ORIENTATION_PORTRAIT, activity.resources.configuration.orientation) + val scanner = activity.findViewById( + com.google.zxing.client.android.R.id.zxing_barcode_scanner, + ) + assertNotNull(scanner) + // ZXing exposes a setter only; inspect the resulting overlay state. + val laser = ViewfinderView::class.java.getDeclaredField("laserVisibility") + laser.isAccessible = true + assertFalse(laser.getBoolean(scanner.viewFinder)) + } + } + } +} diff --git a/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt b/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt index dbfa8055..ea62c213 100644 --- a/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt +++ b/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt @@ -111,12 +111,18 @@ class RemoteScrollTest { val outputBounds = rule.onNodeWithTag("mirror-output").fetchSemanticsNode().boundsInRoot val upBounds = rule.onNodeWithText("Scroll up").fetchSemanticsNode().boundsInRoot val followBounds = rule.onNodeWithText("Follow latest").fetchSemanticsNode().boundsInRoot + val downBounds = rule.onNodeWithText("Scroll down").fetchSemanticsNode().boundsInRoot + assertTrue(upBounds.left <= outputBounds.left + outputBounds.width * 0.2f) + assertTrue(downBounds.right >= outputBounds.right - outputBounds.width * 0.2f) + assertTrue(downBounds.left - upBounds.right > outputBounds.width * 0.4f) assertTrue(upBounds.bottom <= outputBounds.top) assertTrue(followBounds.top >= outputBounds.bottom) rule.onNodeWithText("Scroll up").assertIsEnabled().performClick() rule.onNodeWithText("Scroll up").assertIsNotEnabled() rule.onNodeWithText("Scroll down").assertIsNotEnabled() rule.onNodeWithText("Loading…").assertIsDisplayed() + assertEquals(upBounds, rule.onNodeWithText("Scroll up").fetchSemanticsNode().boundsInRoot) + assertEquals(downBounds, rule.onNodeWithText("Scroll down").fetchSemanticsNode().boundsInRoot) screenshot("loading") rule.runOnIdle { assertEquals(1, sent.count { it.kind == "scroll" }) diff --git a/MirrorClient/Android/app/src/main/AndroidManifest.xml b/MirrorClient/Android/app/src/main/AndroidManifest.xml index bbd25a56..d86b03fb 100644 --- a/MirrorClient/Android/app/src/main/AndroidManifest.xml +++ b/MirrorClient/Android/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + diff --git a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt index 33339edd..c0af7c00 100644 --- a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt +++ b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt @@ -420,19 +420,23 @@ private fun Reading(session: Session, state: SessionState) { Text("History truncated", style = MaterialTheme.typography.labelSmall) } else if ("remote-scroll" in state.capabilities) - Row(verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { TextButton( onClick = { session.scrollRemote(ScrollDirection.UP) }, enabled = state.canScrollRemote(ScrollDirection.UP), ) { Text("Scroll up") } + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + if (state.scrolling != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + Text("Loading…", modifier = Modifier.padding(start = 8.dp)) + } + } + } TextButton( onClick = { session.scrollRemote(ScrollDirection.DOWN) }, enabled = state.canScrollRemote(ScrollDirection.DOWN), ) { Text("Scroll down") } - if (state.scrolling != null) { - CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) - Text("Loading…", modifier = Modifier.padding(start = 8.dp)) - } } LazyColumn( Modifier.weight(1f).fillMaxWidth().testTag("mirror-output"), diff --git a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/PairingCaptureActivity.kt b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/PairingCaptureActivity.kt new file mode 100644 index 00000000..e442bafe --- /dev/null +++ b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/PairingCaptureActivity.kt @@ -0,0 +1,9 @@ +package com.awhisper.prowlmirror + +import com.journeyapps.barcodescanner.CaptureActivity +import com.journeyapps.barcodescanner.DecoratedBarcodeView + +class PairingCaptureActivity : CaptureActivity() { + override fun initializeContent(): DecoratedBarcodeView = + super.initializeContent().also { it.viewFinder.setLaserVisibility(false) } +} diff --git a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/PairingScanButton.kt b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/PairingScanButton.kt index e92dacb8..cc50a6ca 100644 --- a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/PairingScanButton.kt +++ b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/PairingScanButton.kt @@ -24,6 +24,7 @@ internal fun PairingScanButton(connect: (Host, String) -> Unit, failed: (String) val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> if (granted) { scanner.launch(ScanOptions().apply { + setCaptureActivity(PairingCaptureActivity::class.java) setDesiredBarcodeFormats(ScanOptions.QR_CODE) setPrompt("Scan the QR code in Prowl Host → Add a Device") setBeepEnabled(false) diff --git a/docs/remote-mirror.md b/docs/remote-mirror.md index 5527dd3b..4ab24598 100644 --- a/docs/remote-mirror.md +++ b/docs/remote-mirror.md @@ -250,6 +250,9 @@ to that address; scanning does not create a tunnel. The QR expires with the existing 60-second, single-use code. Refresh Code on Host and scan again if it expires. Camera permission is requested only when scanning; manual entry remains available if permission is denied or scanning is unsupported. +On Android, the scanner opens in portrait without a red scanning line. The Live +scroll toolbar places **Scroll up** and **Scroll down** at opposite ends, with +loading feedback between them. ### Native history appearance on Mac -- 2.51.2 From 3b3ba2e3f3ad75a68a45c64247c5180450309fc4 Mon Sep 17 00:00:00 2001 From: Awhisper <4347733+Awhisper@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:23:31 +0800 Subject: [PATCH 10/14] Add Host action to revoke all paired Mirror devices --- docs/remote-mirror.md | 6 +- .../Features/RemoteMirror/MirrorHost.swift | 27 ++++++ .../RemoteMirror/MirrorHostButton.swift | 24 +++++ .../MirrorDevicePairingTests.swift | 91 +++++++++++++++++++ .../RemoteMirror/MirrorHostTests.swift | 5 +- 5 files changed, 151 insertions(+), 2 deletions(-) diff --git a/docs/remote-mirror.md b/docs/remote-mirror.md index 4ab24598..057c22a2 100644 --- a/docs/remote-mirror.md +++ b/docs/remote-mirror.md @@ -66,7 +66,11 @@ Device labels use the Mac local host name or the iOS system device name; pairing does not resolve a DNS name to obtain this label. Revocation removes the persisted secret and disconnects all connections for that device; -other devices remain connected. Device records are limited to 64. If enrollment +other devices remain connected. **Revoke All Devices…** asks for confirmation, then +removes every device's saved authorization, including offline devices, disconnects +all clients, and invalidates the current pairing code. Host keeps listening and +local terminals keep running. Saved client connections cannot reconnect until each +device pairs again with a fresh code. Device records are limited to 64. If enrollment succeeds on Host but its response or the client's save is lost, open a new window and remove the unused device record. Unreadable saved entries are omitted from the Host list without deleting them. Errors that prevent pairing or connection remain visible in plain language, naming diff --git a/supacode/Features/RemoteMirror/MirrorHost.swift b/supacode/Features/RemoteMirror/MirrorHost.swift index af0cca86..bb8300cc 100644 --- a/supacode/Features/RemoteMirror/MirrorHost.swift +++ b/supacode/Features/RemoteMirror/MirrorHost.swift @@ -148,6 +148,33 @@ final class MirrorHost { } } + func revokeAllDevices() { + guard var next = identity else { return } + next.devices.removeAll() + do { + // Persist first: a failed vault write must not pretend access was revoked. + try saveIdentity(next) + identity = next + devices = [] + error = nil + pairingTask?.cancel() + pairingTask = nil + pairingKey = "" + pairingExpiresAt = nil + lastPairedDevice = nil + pendingPairings.removeAll() + for peerID in Array(commandPeers.keys) { cancelCommand(peerID, includingCreate: true) } + // Include unauthenticated peers so an in-flight pairing cannot survive the reset. + for peer in Array(peers.values) + Array(pendingPeers.values) { + peer.close("All device access was revoked. Pair again to connect.") + } + if isRunning || isStarting { try rebuildListener() } + } catch { + SupaLogger("RemoteMirror").warning("Host device reset failed: \(error)") + self.error = error.localizedDescription + } + } + private struct Subscription { let paneID: UUID let id = UUID() diff --git a/supacode/Features/RemoteMirror/MirrorHostButton.swift b/supacode/Features/RemoteMirror/MirrorHostButton.swift index 9e6221b0..5063d5e6 100644 --- a/supacode/Features/RemoteMirror/MirrorHostButton.swift +++ b/supacode/Features/RemoteMirror/MirrorHostButton.swift @@ -116,6 +116,7 @@ private struct MirrorSettingsView: View { let reconnect: (MirrorKnownHost) -> Void @State private var interfaces: [MirrorNetworkInterface] = [] @State private var deviceToRevoke: MirrorPairedDevice? + @State private var revokeAllPresented = false @State private var mirrorToDisconnect: DisconnectTarget? private struct DisconnectTarget { @@ -175,6 +176,19 @@ private struct MirrorSettingsView: View { } message: { _ in Text("The device is disconnected and must pair again before it can mirror this Mac.") } + .alert( + "Revoke All Devices?", isPresented: $revokeAllPresented + ) { + Button("Revoke All Devices", role: .destructive) { host.revokeAllDevices() } + Button("Cancel", role: .cancel) {} + } message: { + Text( + """ + All devices will be disconnected and must pair again. \ + Saved connections and the current pairing code will no longer work. Local terminals keep running. + """ + ) + } .alert( "Disconnect Mirror?", isPresented: Binding(get: { mirrorToDisconnect != nil }, set: { if !$0 { mirrorToDisconnect = nil } }), @@ -256,6 +270,16 @@ private struct MirrorSettingsView: View { .help("Allow paired Prowl devices to connect to this Mac") } } + if !host.devices.isEmpty { + Button("Revoke All Devices…", role: .destructive) { + interact() + revokeAllPresented = true + } + .help( + "Disconnect all devices and require fresh pairing, including devices currently offline" + ) + .accessibilityIdentifier("remote-mirror-revoke-all-devices") + } ForEach(host.devices) { device in VStack(alignment: .leading, spacing: 4) { let panes = host.mirroredPanes(for: device.id) diff --git a/supacodeTests/RemoteMirror/MirrorDevicePairingTests.swift b/supacodeTests/RemoteMirror/MirrorDevicePairingTests.swift index 26e87485..74ac2075 100644 --- a/supacodeTests/RemoteMirror/MirrorDevicePairingTests.swift +++ b/supacodeTests/RemoteMirror/MirrorDevicePairingTests.swift @@ -88,6 +88,97 @@ struct MirrorDevicePairingTests { #expect(rejected.reason?.contains("no longer recognizes") == true) } + @Test(.timeLimit(.minutes(1)), arguments: [false, true]) + func revokeAllRequiresFreshPairing(saveFails: Bool) async throws { + let vault = Vault() + var rejectSave = false + let source = Source() + let suite = "MirrorRevokeAllTests-\(UUID())" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let host = MirrorHost( + source: source, defaults: defaults, enabled: true, + loadIdentity: { vault.identity }, + saveIdentity: { + if rejectSave { + throw MirrorCredentialVault.Failure(status: errSecIO, operation: .saveIdentity) + } + vault.identity = $0 + }) + host.address = "127.0.0.1" + host.port = String(try MirrorTestPort.unusedPort()) + defer { host.stop() } + try await MirrorTestPort.startHost(host) + var clients: [Client] = [] + var credentials: [MirrorDeviceCredential] = [] + defer { for client in clients { client.peer.close() } } + for _ in 0..<2 { + host.addDevice() + try await listening(host) + let client = Client(port: UInt16(host.port)!, code: host.pairingKey) + clients.append(client) + try await client.start(stage: "pair before revoke all") + credentials.append(try #require(client.saved?.credential)) + } + clients[0].peer.send( + .subscribe(.init(paneID: source.id, representation: .text, intent: .ifFree))) + var messages = clients[0].messages.makeAsyncIterator() + #expect(await messages.next()?.kind == .subscribed) + #expect(await messages.next()?.text == "current") + clients[1].peer.close() + for await offline in Observations({ !host.isOnline(credentials[1].deviceID) }) where offline { + break + } + host.addDevice() + try await listening(host) + let oldCode = host.pairingKey + let hostID = vault.identity?.id + rejectSave = saveFails + host.revokeAllDevices() + if saveFails { + #expect(host.error != nil) + #expect(host.devices.count == 2) + #expect(vault.identity?.devices.count == 2) + #expect(!clients[0].closed) + #expect(host.subscriberCount == 1) + #expect(host.pairingKey == oldCode) + return + } + for await closed in Observations({ clients[0].closed }) where closed { break } + try await listening(host) + #expect(host.devices.isEmpty) + #expect(host.onlineDeviceIDs.isEmpty) + #expect(host.subscriberCount == 0) + #expect(host.pairingKey.isEmpty) + #expect(host.pairingExpiresAt == nil) + #expect(host.lastPairedDevice == nil) + #expect(vault.identity?.devices.isEmpty == true) + #expect(vault.identity?.id == hostID) + host.stop() + host.start() + try await listening(host) + #expect(host.devices.isEmpty) + let rejected = + credentials.map { Client(port: UInt16(host.port)!, credential: $0) } + + [Client(port: UInt16(host.port)!, code: oldCode)] + for client in rejected { + clients.append(client) + client.peer.start() + for await closed in Observations({ client.closed }) where closed { break } + #expect(!client.ready) + guard case .handshakeRejected = client.peer.failure else { + Issue.record("Old device or pairing code was not rejected at TLS") + continue + } + } + host.addDevice() + try await listening(host) + let fresh = Client(port: UInt16(host.port)!, code: host.pairingKey) + clients.append(fresh) + try await fresh.start(stage: "fresh pairing after revoke all") + #expect(host.devices.count == 1) + } + @Test(.timeLimit(.minutes(1))) func pairingConnectionCannotListAndWindowExpires() async throws { let clock = TestClock() let vault = Vault() diff --git a/supacodeTests/RemoteMirror/MirrorHostTests.swift b/supacodeTests/RemoteMirror/MirrorHostTests.swift index 2a44c513..dcc5fb01 100644 --- a/supacodeTests/RemoteMirror/MirrorHostTests.swift +++ b/supacodeTests/RemoteMirror/MirrorHostTests.swift @@ -281,7 +281,9 @@ struct MirrorHostTests { @Test( .timeLimit(.minutes(1)), - arguments: ["revoke", "stop", "disconnect-revoke", "disconnect-stop", "disconnect"]) + arguments: [ + "revoke", "revoke-all", "stop", "disconnect-revoke", "disconnect-revoke-all", "disconnect-stop", "disconnect", + ]) func authorityRemovalCancelsPreparedProfile(mode: String) async throws { let suite = "MirrorCreateCancellation-\(UUID())" let defaults = try #require(UserDefaults(suiteName: suite)) @@ -348,6 +350,7 @@ struct MirrorHostTests { for await offline in Observations({ host.onlineDeviceIDs.isEmpty }) where offline { break } } if mode.hasSuffix("revoke") { host.revoke(Self.identity.devices[0].id) } + if mode.hasSuffix("revoke-all") { host.revokeAllDevices() } if mode.hasSuffix("stop") { host.stop() } try #require(preparation != nil) preparation?.resume() -- 2.51.2 From c01a73783812b89ca0a64e9fa9064761e4555a73 Mon Sep 17 00:00:00 2001 From: Awhisper <4347733+Awhisper@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:45:04 +0800 Subject: [PATCH 11/14] Allow explicit re-pairing of saved Mirror hosts --- docs/remote-mirror.md | 4 ++- .../RemoteMirror/AddRemoteMirrorView.swift | 21 +++++++++++--- .../MirrorDevicePairingTests.swift | 29 ++++++++++++++----- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/docs/remote-mirror.md b/docs/remote-mirror.md index 057c22a2..490e1065 100644 --- a/docs/remote-mirror.md +++ b/docs/remote-mirror.md @@ -70,7 +70,9 @@ other devices remain connected. **Revoke All Devices…** asks for confirmation, removes every device's saved authorization, including offline devices, disconnects all clients, and invalidates the current pairing code. Host keeps listening and local terminals keep running. Saved client connections cannot reconnect until each -device pairs again with a fresh code. Device records are limited to 64. If enrollment +device pairs again with a fresh code. In the Mac connection form, **Pair Again…** +shows the code field and bypasses saved access even if the connection error was +reported as a network failure. Device records are limited to 64. If enrollment succeeds on Host but its response or the client's save is lost, open a new window and remove the unused device record. Unreadable saved entries are omitted from the Host list without deleting them. Errors that prevent pairing or connection remain visible in plain language, naming diff --git a/supacode/Features/RemoteMirror/AddRemoteMirrorView.swift b/supacode/Features/RemoteMirror/AddRemoteMirrorView.swift index a6126515..659d822d 100644 --- a/supacode/Features/RemoteMirror/AddRemoteMirrorView.swift +++ b/supacode/Features/RemoteMirror/AddRemoteMirrorView.swift @@ -24,7 +24,7 @@ struct AddRemoteMirrorView: View { return mirrors.knownHosts.host(address: endpoint.address, port: endpoint.port) } - /// A saved credential is used silently; the code field appears only when one is missing or rejected. + /// Saved access is the default, but users can explicitly replace it after Host revocation. private var needsPairingCode: Bool { requiresNewCode || knownHost == nil } var body: some View { @@ -163,9 +163,22 @@ struct AddRemoteMirrorView: View { } .frame(maxWidth: .infinity) } else if let knownHost { - Label("Already paired with \(knownHost.displayName). No code is needed.", systemImage: "checkmark.seal") - .font(.caption).foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + HStack { + Label("Already paired with \(knownHost.displayName). No code is needed.", systemImage: "checkmark.seal") + .font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Spacer() + Button("Pair Again…") { + client?.close() + client = nil + error = nil + pairingCode = "" + requiresNewCode = true + codeFocused = true + } + .help("Enter a fresh code from Host instead of using saved device access") + .accessibilityIdentifier("remote-mirror-pair-again") + } } if let message = client?.error ?? error { Text(message).foregroundStyle(.red).textSelection(.enabled) diff --git a/supacodeTests/RemoteMirror/MirrorDevicePairingTests.swift b/supacodeTests/RemoteMirror/MirrorDevicePairingTests.swift index 74ac2075..22902be6 100644 --- a/supacodeTests/RemoteMirror/MirrorDevicePairingTests.swift +++ b/supacodeTests/RemoteMirror/MirrorDevicePairingTests.swift @@ -27,9 +27,11 @@ struct MirrorDevicePairingTests { #expect(host.pairingKey.isEmpty) host.addDevice() try await listening(host) - let first = Client(port: UInt16(host.port)!, code: host.pairingKey) { - #expect(host.isRunning && !host.isStarting) - } + let first = Client( + port: UInt16(host.port)!, code: host.pairingKey, + onPersist: { + #expect(host.isRunning && !host.isStarting) + }) defer { first.peer.close() } try await first.start(stage: "first pairing") #expect(host.devices.count == 1) @@ -44,9 +46,11 @@ struct MirrorDevicePairingTests { #expect(host.mirroredPanes(for: firstCredential.deviceID).map(\.id) == [source.id]) host.addDevice() try await listening(host) - let second = Client(port: UInt16(host.port)!, code: host.pairingKey) { - #expect(host.isRunning && !host.isStarting) - } + let second = Client( + port: UInt16(host.port)!, code: host.pairingKey, + onPersist: { + #expect(host.isRunning && !host.isStarting) + }) defer { second.peer.close() } try await second.start(stage: "second pairing") #expect(host.devices.count == 2) @@ -173,9 +177,17 @@ struct MirrorDevicePairingTests { } host.addDevice() try await listening(host) - let fresh = Client(port: UInt16(host.port)!, code: host.pairingKey) + var restoredOldAccess = false + let fresh = Client( + port: UInt16(host.port)!, code: host.pairingKey, + restore: { configuration in + restoredOldAccess = true + return .init( + address: configuration.address, port: configuration.port, pairingKey: "", credential: credentials[0]) + }) clients.append(fresh) try await fresh.start(stage: "fresh pairing after revoke all") + #expect(!restoredOldAccess) #expect(host.devices.count == 1) } @@ -464,6 +476,7 @@ struct MirrorDevicePairingTests { let messages: AsyncStream init( port: UInt16, code: String = "", credential: MirrorDeviceCredential? = nil, + restore: @escaping (MirrorSavedConnection) throws -> MirrorSavedConnection? = { _ in nil }, onPersist: @escaping () -> Void = {} ) { let events = AsyncStream.makeStream(of: MirrorMessage.self) @@ -471,7 +484,7 @@ struct MirrorDevicePairingTests { peer = MirrorRemoteConnection( configuration: .init( address: "127.0.0.1", port: port, pairingKey: code, credential: credential), - restore: { _ in nil }, + restore: restore, persist: { [weak self] in self?.saved = $0 onPersist() -- 2.51.2 From 12147d95a78349616509589f2d7e092f202706f9 Mon Sep 17 00:00:00 2001 From: Awhisper <4347733+Awhisper@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:00:31 +0800 Subject: [PATCH 12/14] Simplify Mac Mirror pairing with combined connection copy and paste --- docs/remote-mirror.md | 12 +- .../RemoteMirror/AddRemoteMirrorView.swift | 36 ++++-- .../RemoteMirror/MirrorEndpointInput.swift | 21 ++++ .../RemoteMirror/MirrorHostButton.swift | 106 ++++++------------ .../MirrorPairingPayloadTests.swift | 22 ++++ 5 files changed, 119 insertions(+), 78 deletions(-) diff --git a/docs/remote-mirror.md b/docs/remote-mirror.md index 490e1065..4d904ffb 100644 --- a/docs/remote-mirror.md +++ b/docs/remote-mirror.md @@ -244,12 +244,22 @@ and [MirrorClient/Android](../MirrorClient/Android/) (phones and tablets). Each project retains its own build and test entry points; neither is built by the Mac App target or release pipeline. See [client setup](../MirrorClient/README.md). +### Copy connection details to Mac + +On Host, open **Add a Device**, choose the Wi-Fi or VPN address reachable from +Mirror, and click **Copy Connection Details**. This single copy includes the +address, port, pairing code and expiry. In Mac Mirror's connection form, paste +into the address, port or pairing-code field; all three fields fill together, +even when pasting into an existing value. Click **Connect** to pair. Fresh copied +details bypass saved device access, including credentials revoked by Host. +Expired or malformed details show an error without replacing existing fields. + ### Scan to pair a phone Open **Add a Device** on Host, then **Scan QR Code** in the mobile app's Host connection form. The QR includes the numeric IP address, port and current pairing code. Scanning starts the normal authenticated pairing flow and then offers the -Host panes. Choose the Wi-Fi or VPN address reachable from your phone in **QR +Host panes. Choose the Wi-Fi or VPN address reachable from your phone in **Connection address** when Host has multiple interfaces. Both devices still need network access to that address; scanning does not create a tunnel. diff --git a/supacode/Features/RemoteMirror/AddRemoteMirrorView.swift b/supacode/Features/RemoteMirror/AddRemoteMirrorView.swift index 659d822d..8ba804b0 100644 --- a/supacode/Features/RemoteMirror/AddRemoteMirrorView.swift +++ b/supacode/Features/RemoteMirror/AddRemoteMirrorView.swift @@ -133,18 +133,18 @@ struct AddRemoteMirrorView: View { private var form: some View { VStack(alignment: .leading, spacing: 16) { // Sheets propose no height; without fixedSize multi-line text collapses to one truncated line. - Text("Enter the address shown on Host under Remote Mirror → Add a Device.") + Text("Paste connection details from Host into any field below, or enter the address and pairing code manually.") .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) Form { - TextField("Address", text: $address, prompt: Text("IP address")) + TextField("Address", text: connectionField($address), prompt: Text("IP address")) .accessibilityIdentifier("remote-mirror-address") - TextField("Port", text: $port, prompt: Text("7880")) + TextField("Port", text: connectionField($port), prompt: Text("7880")) } .disabled(isConnecting) if needsPairingCode { VStack(spacing: 6) { - TextField("Pairing Code", text: $pairingCode, prompt: Text("XXXX-XXXX")) + TextField("Pairing Code", text: connectionField($pairingCode, isCode: true), prompt: Text("XXXX-XXXX")) .labelsHidden() .font(.title.monospaced()) .multilineTextAlignment(.center) @@ -152,10 +152,6 @@ struct AddRemoteMirrorView: View { .frame(maxWidth: 220) .focused($codeFocused) .disabled(isConnecting) - .onChange(of: pairingCode) { _, next in - let formatted = MirrorPairingCode.formatted(next) - if formatted != next { pairingCode = formatted } - } .accessibilityIdentifier("remote-mirror-pairing-code") Text("The eight-character code shown on Host under Add a Device.\nIt expires after 60 seconds.") .font(.caption).foregroundStyle(.secondary).multilineTextAlignment(.center) @@ -187,6 +183,30 @@ struct AddRemoteMirrorView: View { } } + private func connectionField(_ field: Binding, isCode: Bool = false) -> Binding { + Binding( + get: { field.wrappedValue }, + set: { value in + do { + if let payload = try MirrorEndpointInput.pastedPairingPayload(value) { + client?.close() + client = nil + address = payload.address + port = String(payload.port) + pairingCode = MirrorPairingCode.formatted(payload.code) + requiresNewCode = true + error = nil + } else { + field.wrappedValue = isCode ? MirrorPairingCode.formatted(value) : value + } + } catch let problem as MirrorEndpointInput.Problem { + self.error = problem.message + } catch { + self.error = error.localizedDescription + } + }) + } + private func panePicker(_ client: MirrorClient) -> some View { VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 4) { diff --git a/supacode/Features/RemoteMirror/MirrorEndpointInput.swift b/supacode/Features/RemoteMirror/MirrorEndpointInput.swift index 39f625e3..6713aa3f 100644 --- a/supacode/Features/RemoteMirror/MirrorEndpointInput.swift +++ b/supacode/Features/RemoteMirror/MirrorEndpointInput.swift @@ -15,6 +15,8 @@ nonisolated enum MirrorEndpointInput { case invalidPort case missingPairingCode case invalidPairingCode + case invalidPairingDetails + case expiredPairingDetails var message: String { switch self { @@ -25,10 +27,29 @@ nonisolated enum MirrorEndpointInput { case .missingPairingCode: String(localized: "Enter the pairing code shown on Host.") case .invalidPairingCode: String(localized: "Pairing codes have eight letters or digits, shown as XXXX-XXXX.") + case .invalidPairingDetails: + String(localized: "Invalid connection details. Copy Connection Details again from Host.") + case .expiredPairingDetails: + String(localized: "These connection details expired. Refresh Code on Host, then copy and paste again.") } } } + static func pastedPairingPayload(_ input: String, now: Date = Date()) throws(Problem) -> MirrorPairingPayload? { + guard input.contains("\"prowl-mirror-pairing\"") else { return nil } + // Paste may insert into an existing value rather than replace the whole field. + guard input.utf8.count <= 4096, let start = input.firstIndex(of: "{"), let end = input.lastIndex(of: "}"), + start < end + else { throw .invalidPairingDetails } + do { + return try MirrorPairingPayload.parse(String(input[start...end]), now: now) + } catch MirrorPairingPayload.Problem.expired { + throw .expiredPairingDetails + } catch { + throw .invalidPairingDetails + } + } + static func endpoint(address: String, port: String) throws(Problem) -> Endpoint { let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw .emptyAddress } diff --git a/supacode/Features/RemoteMirror/MirrorHostButton.swift b/supacode/Features/RemoteMirror/MirrorHostButton.swift index 5063d5e6..39fb2e8a 100644 --- a/supacode/Features/RemoteMirror/MirrorHostButton.swift +++ b/supacode/Features/RemoteMirror/MirrorHostButton.swift @@ -420,9 +420,9 @@ private struct MirrorPairingView: View { MirrorHostAddresses.reachable(listenAddress: host.address, interfaces: interfaces) } - private var localName: String? { - guard host.address != MirrorHostAddresses.loopback else { return nil } - return MirrorHostAddresses.localHostName() + private var pairingAddress: String? { + let choices = reachable.filter { !$0.isLoopback && $0.address != "::1" } + return choices.first(where: { $0.address == qrAddress })?.address ?? choices.first?.address } var body: some View { @@ -438,12 +438,11 @@ private struct MirrorPairingView: View { Text( """ On the other device, open Remote Mirror → Client → Connect to a New Host. \ - Scan the QR code in the mobile app, or enter an address, port and code below. + Copy the connection details and paste into any connection field on Mac, or scan the QR code on mobile. """ ) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - addresses code pairingQR } @@ -478,6 +477,7 @@ private struct MirrorPairingView: View { hasRequestedCode = true host.addDevice() } + .onChange(of: qrAddress) { _, _ in copied = false } .onChange(of: host.pairingKey) { _, _ in copied = false copyError = nil @@ -499,85 +499,44 @@ private struct MirrorPairingView: View { private var pairingQR: some View { let choices = reachable.filter { !$0.isLoopback && $0.address != "::1" } - let selected = - choices.first(where: { $0.address == qrAddress })?.address ?? choices.first?.address + let selected = pairingAddress return VStack(spacing: 8) { if let expires = host.pairingExpiresAt, let selected { if choices.count > 1 { - Picker("QR address", selection: Binding(get: { selected }, set: { qrAddress = $0 })) { + Picker("Connection address", selection: Binding(get: { selected }, set: { qrAddress = $0 })) { ForEach(choices) { item in Text("\(item.label): \(item.address)").tag(item.address) } } - .help("Choose an address reachable from your phone over Wi-Fi or VPN") + .help("Choose the Wi-Fi or VPN address reachable from your Mirror device") + } + Text("\(selected):\(host.port)").font(.body.monospaced()).textSelection(.enabled) + Button(copied ? "Copied" : "Copy Connection Details", systemImage: copied ? "checkmark" : "doc.on.doc") { + copyConnectionDetails() } + .keyboardShortcut("c", modifiers: .command) + .help("Copy the address, port and pairing code together (⌘C)") + .accessibilityIdentifier("remote-mirror-copy-connection") MirrorPairingQRCode( address: selected, port: host.port, code: host.pairingKey, expires: expires) Text("In Prowl Mirror on your phone, tap Scan QR Code.") .font(.caption).foregroundStyle(.secondary) - } - } - .frame(maxWidth: .infinity) - } - - private var addresses: some View { - VStack(alignment: .leading, spacing: 8) { - Text("This Mac’s address").font(.subheadline.weight(.semibold)) - // A grid lets the label column fit names like Thunderbolt Bridge without a fixed width. - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { - if let localName { - addressRow(label: "Name", value: localName + ":" + host.port) - } - ForEach(reachable) { interface in - addressRow(label: interface.label, value: interface.address + ":" + host.port) - } - } - if reachable.isEmpty, localName == nil { - Text("No network address found. Connect this Mac to a network.").font(.caption).foregroundStyle(.secondary) + } else if selected == nil { + Text("No network address is available for pairing. Start Host on a Wi-Fi or VPN interface.") + .font(.caption).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } - Text( - "Reachable on your local network or VPN. The internet cannot reach this port unless your router forwards it." - ) - .font(.caption).foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - - private func addressRow(label: String, value: String) -> some View { - GridRow { - Text(label).foregroundStyle(.secondary).lineLimit(1) - Text(value).font(.body.monospaced()).textSelection(.enabled).lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - Button { - copy(value) - } label: { - Label("Copy \(value)", systemImage: "doc.on.doc").labelStyle(.iconOnly) - } - .buttonStyle(.borderless) - .help("Copy \(value)") } + .frame(maxWidth: .infinity) } private var code: some View { VStack(spacing: 8) { if let expires = host.pairingExpiresAt { - HStack(spacing: 12) { - Text(host.pairingKey) - .font(.largeTitle.monospaced().weight(.semibold)) - .textSelection(.enabled) - .accessibilityIdentifier("remote-mirror-pairing-key") - Button { - copy(host.pairingKey) - } label: { - Label(copied ? "Copied" : "Copy the pairing code", systemImage: copied ? "checkmark" : "doc.on.doc") - .labelStyle(.iconOnly) - } - .buttonStyle(.borderless) - .keyboardShortcut("c", modifiers: .command) - .help("Copy the pairing code (⌘C)") - .accessibilityIdentifier("remote-mirror-copy-key") - } + Text(host.pairingKey) + .font(.largeTitle.monospaced().weight(.semibold)) + .textSelection(.enabled) + .accessibilityIdentifier("remote-mirror-pairing-key") HStack(spacing: 4) { Text("Expires in") Text(expires, style: .timer).monospacedDigit() @@ -595,11 +554,20 @@ private struct MirrorPairingView: View { .padding(.vertical, 8) } - private func copy(_ value: String) { - NSPasteboard.general.clearContents() - let done = NSPasteboard.general.setString(value, forType: .string) - copied = done && value == host.pairingKey - copyError = done ? nil : String(localized: "Unable to copy to the clipboard.") + private func copyConnectionDetails() { + do { + guard let address = pairingAddress, let port = UInt16(host.port), let expires = host.pairingExpiresAt else { + throw MirrorPairingPayload.Problem.invalid + } + let payload = try MirrorPairingPayload(address: address, port: port, code: host.pairingKey, expires: expires) + let value = try payload.encoded() + NSPasteboard.general.clearContents() + copied = NSPasteboard.general.setString(value, forType: .string) + copyError = copied ? nil : String(localized: "Unable to copy to the clipboard.") + } catch { + copied = false + copyError = error.localizedDescription + } } } diff --git a/supacodeTests/RemoteMirror/MirrorPairingPayloadTests.swift b/supacodeTests/RemoteMirror/MirrorPairingPayloadTests.swift index e677224d..1dcf20e7 100644 --- a/supacodeTests/RemoteMirror/MirrorPairingPayloadTests.swift +++ b/supacodeTests/RemoteMirror/MirrorPairingPayloadTests.swift @@ -16,6 +16,28 @@ struct MirrorPairingPayloadTests { } } + @Test func pastedDetailsReplaceExistingConnectionFieldContents() throws { + let payload = try MirrorPairingPayload( + address: "30.172.64.62", port: 5988, code: "ABCD-EFGH", expires: now.addingTimeInterval(60)) + let copied = try payload.encoded() + for previous in ["old-host.local", "7880", "ZZZZ-YYYY", ""] { + for input in [copied, previous + copied, copied + previous, " \n" + copied + "\n "] { + #expect(try MirrorEndpointInput.pastedPairingPayload(input, now: now) == payload) + } + } + #expect(try MirrorEndpointInput.pastedPairingPayload("192.168.0.1", now: now) == nil) + #expect(try MirrorEndpointInput.pastedPairingPayload("ABCD-EFGH", now: now) == nil) + #expect(throws: MirrorEndpointInput.Problem.expiredPairingDetails) { + try MirrorEndpointInput.pastedPairingPayload(copied, now: now.addingTimeInterval(61)) + } + #expect(throws: MirrorEndpointInput.Problem.invalidPairingDetails) { + try MirrorEndpointInput.pastedPairingPayload(copied.replacing("5988", with: "0"), now: now) + } + #expect(throws: MirrorEndpointInput.Problem.invalidPairingDetails) { + try MirrorEndpointInput.pastedPairingPayload("{\"type\":\"prowl-mirror-pairing\"", now: now) + } + } + @Test func rejectsExpiredAndMalformedCodes() throws { let valid = #"{"type":"prowl-mirror-pairing","version":1,"address":"192.168.1.20","port":7880,"# -- 2.51.2 From afe294dcde14fd78664874bcf2e514b1ac5bc5e2 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 2 Oct 2026 22:30:12 +0900 Subject: [PATCH 13/14] Fix Mirror frame acknowledgements and Android reading restoration --- MirrorClient/Android/README.md | 3 +- .../awhisper/prowlmirror/RemoteScrollTest.kt | 24 ++++++++++- .../com/awhisper/prowlmirror/MirrorApp.kt | 1 + .../java/com/awhisper/prowlmirror/Session.kt | 6 +++ .../com/awhisper/prowlmirror/SessionTest.kt | 6 +++ ...012-scroll-controls-and-report-feedback.md | 13 +++++- docs/remote-mirror.md | 8 +++- .../Features/RemoteMirror/MirrorReplica.swift | 32 +++++++++++--- .../Ghostty/GhosttySurfaceBridge.swift | 4 ++ .../MirrorReplicaInputTests.swift | 42 +++++++++++++++---- 10 files changed, 122 insertions(+), 17 deletions(-) diff --git a/MirrorClient/Android/README.md b/MirrorClient/Android/README.md index 2efc710b..366ae008 100644 --- a/MirrorClient/Android/README.md +++ b/MirrorClient/Android/README.md @@ -50,7 +50,8 @@ is no private AI console or dependency on personal shell wrappers. including applications that manage their own scrolling, stay enabled; unchanged text alone never disables a direction. Older Hosts need no boundary metadata. Both directions resume reading at the top of the returned screen. Automatic - following is paused by remote scrolling. + following is paused by remote scrolling. Completion is consumed once: switching + panes or recreating the reader preserves later local reading positions. - Saving credentials does not persist terminal output or drafts across process death. Rotation retains the current ViewModel; reopening the app offers saved Hosts. diff --git a/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt b/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt index ea62c213..3f0e9645 100644 --- a/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt +++ b/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt @@ -2,6 +2,8 @@ package com.awhisper.prowlmirror import android.graphics.Bitmap import androidx.activity.ComponentActivity +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.test.* @@ -25,6 +27,7 @@ class RemoteScrollTest { private val lease = uuid() private var sequence = 1L private var includesScrollState = false + private val compositionGeneration = mutableStateOf(0) @After fun close() { @@ -74,7 +77,7 @@ class RemoteScrollTest { model.sessions += session model.selected = session.id } - rule.setContent { MirrorApp(model) } + rule.setContent { key(compositionGeneration.value) { MirrorApp(model) } } } private fun bounds(sequence: Long, atTop: Boolean? = null, atBottom: Boolean? = null) { @@ -228,6 +231,25 @@ class RemoteScrollTest { } } + @Test + fun rebuildingReaderDoesNotReplayCompletedRemoteScroll() { + val text = (1..140).joinToString("\n") { "Terminal row $it" } + show(text) + rule.onNodeWithText("Scroll up").performClick() + complete(text) + rule.onNodeWithTag("mirror-output").performTouchInput { swipeUp() } + var position = 0 to 0 + rule.runOnIdle { + position = session.liveScrollIndex to session.liveScrollOffset + assertTrue(position.first > 0 || position.second > 0) + compositionGeneration.value++ + } + rule.waitForIdle() + rule.runOnIdle { + assertEquals(position, session.liveScrollIndex to session.liveScrollOffset) + } + } + @Test fun oldHostsHaveNoRemoteControlsOrRemoteGestures() { show(capability = false) diff --git a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt index c0af7c00..0704210f 100644 --- a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt +++ b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt @@ -404,6 +404,7 @@ private fun Reading(session: Session, state: SessionState) { val completion = state.scrollCompletion if (!state.showsHistory && completion != null && blocks.isNotEmpty()) { scroll.scrollToItem(0) + session.consumeScrollCompletion(completion.requestID) } } Column(Modifier.fillMaxSize()) { diff --git a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Session.kt b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Session.kt index 45343d76..c6817b6b 100644 --- a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Session.kt +++ b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Session.kt @@ -521,6 +521,12 @@ class Session( ))) } + fun consumeScrollCompletion(id: String) { + state.update { + if (it.scrollCompletion?.requestID == id) it.copy(scrollCompletion = null) else it + } + } + private fun clearScroll() { scrollTimeout?.cancel() scrollTimeout = null diff --git a/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/SessionTest.kt b/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/SessionTest.kt index 981e87e1..8a43ca09 100644 --- a/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/SessionTest.kt +++ b/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/SessionTest.kt @@ -219,6 +219,12 @@ class SessionTest { ))) assertNull(f.session.state.value.scrolling) assertEquals(ScrollDirection.UP, f.session.state.value.scrollCompletion?.direction) + f.session.consumeScrollCompletion(uuid()) + assertNotNull(f.session.state.value.scrollCompletion) + f.session.consumeScrollCompletion(request.string("requestID")) + assertNull(f.session.state.value.scrollCompletion) + f.session.consumeScrollCompletion(request.string("requestID")) + assertNull(f.session.state.value.scrollCompletion) assertTrue(f.session.state.value.canScrollRemote(ScrollDirection.UP)) assertTrue(f.session.state.value.canScrollRemote(ScrollDirection.DOWN)) assertFalse(f.session.state.value.follow) diff --git a/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md b/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md index 3ea36f9c..f56ca7e7 100644 --- a/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md +++ b/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md @@ -93,6 +93,10 @@ Both mobile clients now anchor both completed directions at the top. Mac exposes a correlated completion identity only after the reply and displayed frame match; the viewport resets once for each completion, including Original Size mode. Ordinary subsequent frames and duplicate results preserve local reading offsets. +Android consumes each matching completion after positioning the reader. Keeping a +consumed completion in Session would replay it when composition is recreated and +overwrite the saved reading position. Unknown completion IDs leave the current +event intact. Verification: 23 Mac tests pass, including real TUI event counts in both directions, exact native scrolling with a detected Codex Agent, correlated @@ -118,9 +122,14 @@ A synchronous surface-scoped callback intercepts only that binding's temporary file path, leaving the system clipboard untouched. Bounded archives are cached briefly; unbounded/oversized histories keep the established text fallback. The replica restores live input modes, replays history with trailing blank padding, waits for a -private end-of-stream title marker, scrolls locally, and verifies the visible +private end-of-stream OSC 7 working-directory marker, scrolls locally, and verifies the visible text against Host before revealing styled history. PTY write acknowledgement -alone is insufficient because parsing may still be in flight. +alone is insufficient because parsing may still be in flight. The replica consumes +the marker before it updates public working-directory or title state. Unlike OSC 2 +title callbacks, this callback is not disabled by a fixed Ghostty `title`, including +after configuration reload. A 30-second parser watchdog reports a stalled replica +and disconnects Mirror rather than leaving Host's outstanding frame unacknowledged +indefinitely. The archive omits trailing empty rows. Locating from the retained buffer start rather than its bottom avoids dropped rows at the active/history boundary. diff --git a/docs/remote-mirror.md b/docs/remote-mirror.md index 4d904ffb..130f6c37 100644 --- a/docs/remote-mirror.md +++ b/docs/remote-mirror.md @@ -268,7 +268,9 @@ and scan again if it expires. Camera permission is requested only when scanning; manual entry remains available if permission is denied or scanning is unsupported. On Android, the scanner opens in portrait without a red scanning line. The Live scroll toolbar places **Scroll up** and **Scroll down** at opposite ends, with -loading feedback between them. +loading feedback between them. Each completed scroll moves the reader to the top +once. Switching panes or recreating the Android reader preserves subsequent local +reading positions instead of repeating that move. ### Native history appearance on Mac @@ -278,3 +280,7 @@ This is separate from **History**, which remains a retained-text snapshot. Older Hosts, histories exceeding the replay limit, or a viewport that cannot be reconstructed safely use the existing **Host scrollback · Plain text** fallback. Mobile clients continue using text-v1 and require no update for this feature. +Mac frame updates also work with a fixed Ghostty `title`, including after a config +reload. If the local replica cannot confirm frame parsing within 30 seconds, +Mirror disconnects with an error rather than silently stopping updates. Reconnect +to try again. diff --git a/supacode/Features/RemoteMirror/MirrorReplica.swift b/supacode/Features/RemoteMirror/MirrorReplica.swift index 56e3170f..6ea4a113 100644 --- a/supacode/Features/RemoteMirror/MirrorReplica.swift +++ b/supacode/Features/RemoteMirror/MirrorReplica.swift @@ -16,6 +16,8 @@ final class MirrorReplica { @ObservationIgnored private var peer: MirrorRelayConnection? @ObservationIgnored private var candidate: MirrorRelayConnection? @ObservationIgnored private let runtime: GhosttyRuntime + @ObservationIgnored private let clock: any Clock + @ObservationIgnored private var parseTimeout: Task? @ObservationIgnored private let token = UUID().uuidString + UUID().uuidString @ObservationIgnored private var pending: MirrorMessage? @ObservationIgnored private var pendingScrollback: MirrorStyledScrollback? @@ -29,7 +31,10 @@ final class MirrorReplica { @ObservationIgnored private var stopped = false @ObservationIgnored private var needsRestart = false - init(runtime: GhosttyRuntime) { self.runtime = runtime } + init(runtime: GhosttyRuntime, clock: any Clock = ContinuousClock()) { + self.runtime = runtime + self.clock = clock + } func start() throws { if needsRestart { stop() } @@ -55,11 +60,16 @@ final class MirrorReplica { self.view = GhosttySurfaceView( runtime: self.runtime, workingDirectory: nil, context: GHOSTTY_SURFACE_CONTEXT_WINDOW, command: command) - self.view?.bridge.consumeTitle = { [weak self] title in - guard let self, title == self.presentationMarker else { return false } + self.view?.bridge.consumeWorkingDirectory = { [weak self] path in + guard let self, path == self.presentationMarker else { return false } self.didParseFrame() return true } + // Ghostty can also derive a title from OSC 7. Do not expose private markers. + self.view?.bridge.consumeTitle = { [weak self] title in + guard let self else { return false } + return title.hasPrefix("/prowl-replica-\(self.token)-") + } case .failed(let error): self.fail(error.localizedDescription) default: break } @@ -102,8 +112,15 @@ final class MirrorReplica { displayedMessage = message displayedScrollback = styledScrollback expectedViewportText = viewportText - let marker = "prowl-replica-\(token)-\(sequence)" + let marker = "/prowl-replica-\(token)-\(sequence)" presentationMarker = marker + parseTimeout?.cancel() + let clock = clock + parseTimeout = Task { @MainActor [weak self] in + do { try await clock.sleep(for: .seconds(30)) } catch { return } + guard let self, self.presentationMarker == marker else { return } + self.fail(String(localized: "Display replica timed out while parsing a Host frame. Reconnect to try again.")) + } var payload = MirrorRelayPacket.sequenceBytes(sequence) payload.append(frame.bytes) if let styledScrollback { @@ -115,7 +132,8 @@ final class MirrorReplica { payload.append(Data(String(repeating: "\r\n", count: Int(frame.rows)).utf8)) payload.append(Data("\u{1b}[?25l".utf8)) } - payload.append(Data("\u{1b}]2;\(marker)\u{7}".utf8)) + // Unlike title callbacks, OSC 7 remains available with a static title and after config reloads. + payload.append(Data("\u{1b}]7;file://localhost\(marker)\u{7}".utf8)) peer.send(MirrorRelayPacket(kind: .frame, payload: payload)) } else { pending = message @@ -129,6 +147,8 @@ final class MirrorReplica { let lease = message.subscriptionID, let terminal = view?.surface else { return } presentationMarker = nil + parseTimeout?.cancel() + parseTimeout = nil guard let scrollback = displayedScrollback else { usesStyledScrollback = false resumeRendering() @@ -176,6 +196,8 @@ final class MirrorReplica { func stop() { stopped = true + parseTimeout?.cancel() + parseTimeout = nil presentationTask?.cancel() presentationTask = nil needsRestart = false diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift index 0100b226..3329a991 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift @@ -9,6 +9,7 @@ final class GhosttySurfaceBridge { weak var surfaceView: GhosttySurfaceView? // Local replica markers acknowledge parsing, rather than merely a PTY write. var consumeTitle: ((String) -> Bool)? + var consumeWorkingDirectory: ((String) -> Bool)? var captureClipboard: (([(mime: String, data: String)]) -> Void)? struct MirrorScrollbar { let total: UInt64 @@ -263,6 +264,9 @@ final class GhosttySurfaceBridge { return true case GHOSTTY_ACTION_PWD: + if let path = string(from: action.action.pwd.pwd), consumeWorkingDirectory?(path) == true { + return true + } state.pwd = string(from: action.action.pwd.pwd) if let surfaceView { NSAccessibility.post(element: surfaceView, notification: .valueChanged) diff --git a/supacodeTests/RemoteMirror/MirrorReplicaInputTests.swift b/supacodeTests/RemoteMirror/MirrorReplicaInputTests.swift index 9a10957e..65da7442 100644 --- a/supacodeTests/RemoteMirror/MirrorReplicaInputTests.swift +++ b/supacodeTests/RemoteMirror/MirrorReplicaInputTests.swift @@ -1,5 +1,7 @@ import AppKit +import Clocks import Foundation +import GhosttyKit import Testing @testable import supacode @@ -7,10 +9,22 @@ import Testing @Suite(.serialized) @MainActor struct MirrorReplicaInputTests { - private static let runtime = GhosttyRuntime() - - @Test(.timeLimit(.minutes(1))) func replayDoesNotForwardAutomaticTerminalReports() async throws { - let replica = MirrorReplica(runtime: Self.runtime) + @Test(.timeLimit(.minutes(2)), arguments: [false, true]) + func replayDoesNotForwardAutomaticTerminalReports(staticTitle: Bool) async throws { + let runtime = GhosttyRuntime() + let configFile = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString + ".conf") + defer { try? FileManager.default.removeItem(at: configFile) } + func reloadTitle(_ title: String) throws { + let config = try #require(ghostty_config_new()) + defer { ghostty_config_free(config) } + try "title = \(title)\n".write(to: configFile, atomically: true, encoding: .utf8) + ghostty_config_load_file(config, configFile.path) + ghostty_config_finalize(config) + ghostty_app_update_config(runtime.app, config) + } + if staticTitle { try reloadTitle("Fixed replica title") } + let clock = TestClock() + let replica = MirrorReplica(runtime: runtime, clock: clock) let lease = UUID() var input = Data() var acknowledged: UInt64 = 0 @@ -29,6 +43,7 @@ struct MirrorReplicaInputTests { window.contentView = view defer { window.close() } for sequence in 1...3 { + if staticTitle, sequence == 2 { try reloadTitle("Reloaded fixed title") } replica.display( .frame( .init( @@ -44,8 +59,21 @@ struct MirrorReplicaInputTests { #expect(input.isEmpty, "Frame replay generated Host input: \(Array(input))") view.insertText("用户输入", replacementRange: NSRange(location: NSNotFound, length: 0)) - try await wait { (String(data: input, encoding: .utf8) ?? "").contains("用户输入") } - #expect((String(data: input, encoding: .utf8) ?? "").contains("\u{1b}[200~用户输入\u{1b}[201~")) + try await wait { + (String(data: input, encoding: .utf8) ?? "").contains("\u{1b}[200~用户输入\u{1b}[201~") + } + #expect(view.bridge.state.pwd?.contains("prowl-replica-") != true) + #expect(view.bridge.state.title?.contains("prowl-replica-") != true) + + // A lost parser callback must report failure rather than hold Host's frame gate forever. + var failure: String? + replica.onFailure = { failure = $0 } + view.bridge.consumeWorkingDirectory = { _ in true } + replica.display( + .frame(.init(frame: .init(columns: 80, rows: 24, bytes: Data("LAST".utf8)), sequence: 4, subscriptionID: lease))) + await clock.advance(by: .seconds(30)) + #expect(failure?.contains("timed out") == true) + #expect(acknowledged == 3) } private func wait(until condition: @MainActor () -> Bool) async throws { @@ -55,7 +83,7 @@ struct MirrorReplicaInputTests { timer.invalidate() continuation.finish() } - let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + let deadline = ContinuousClock.now.advanced(by: .seconds(30)) for await _ in ticks { if condition() { return } if ContinuousClock.now >= deadline { throw Timeout() } -- 2.51.2 From 46f1f3973ed4086bd52af0da76eb11de863b632a Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 2 Oct 2026 22:30:14 +0900 Subject: [PATCH 14/14] Stabilize Mirror and CLI tests under full-suite load --- Makefile | 2 +- scripts/test_app_build_path.py | 2 +- .../CLISendCommandHandlerTests.swift | 20 ++++++------------- .../RemoteMirror/MirrorScrollStateTests.swift | 8 ++++++-- .../MirrorVTExportSpikeTests.swift | 3 +-- 5 files changed, 15 insertions(+), 20 deletions(-) diff --git a/Makefile b/Makefile index 3ebcc860..eed3c26c 100644 --- a/Makefile +++ b/Makefile @@ -402,7 +402,7 @@ test-app: ensure-ghostty # Run app/unit tests via xcodebuild skip_args+=("-skip-testing:$$test_id"); \ only_args+=("-only-testing:$$test_id"); \ done; \ - mirror_suites=(MirrorHostTests MirrorDevicePairingTests MirrorConnectionTests MirrorTerminalIntegrationTests); \ + mirror_suites=(MirrorHostTests MirrorDevicePairingTests MirrorConnectionTests MirrorTerminalIntegrationTests MirrorReplicaInputTests); \ mirror_args=(); \ for suite in "$${mirror_suites[@]}"; do \ skip_args+=("-skip-testing:supacodeTests/$$suite"); \ diff --git a/scripts/test_app_build_path.py b/scripts/test_app_build_path.py index 866e67a8..480c00f1 100644 --- a/scripts/test_app_build_path.py +++ b/scripts/test_app_build_path.py @@ -63,7 +63,7 @@ class AppBuildPathTests(unittest.TestCase): result, calls = self.run_grouped_build() self.assertEqual(result.returncode, 0, result.stderr) for suite in ["MirrorHostTests", "MirrorDevicePairingTests", "MirrorConnectionTests", - "MirrorTerminalIntegrationTests"]: + "MirrorTerminalIntegrationTests", "MirrorReplicaInputTests"]: target = f"supacodeTests/{suite}" self.assertIn(f"-skip-testing:{target}", calls[0]) runs = [call for call in calls if f"-only-testing:{target}" in call] diff --git a/supacodeTests/CLISendCommandHandlerTests.swift b/supacodeTests/CLISendCommandHandlerTests.swift index ae829342..93c9f081 100644 --- a/supacodeTests/CLISendCommandHandlerTests.swift +++ b/supacodeTests/CLISendCommandHandlerTests.swift @@ -32,7 +32,6 @@ struct CLISendCommandHandlerTests { private static func makeHandler( resolveResult: Result = .success(makeTarget()), waiterResult: (exitCode: Int?, durationMs: Int)? = nil, - waiterDelay: Duration? = nil, textDelivery: (@MainActor (SendResolvedTarget, String, Bool) -> Void)? = nil, captureProvider: (@MainActor (SendResolvedTarget) -> ReadCaptureInput?)? = nil ) -> SendCommandHandler { @@ -45,16 +44,8 @@ struct CLISendCommandHandlerTests { waiterProvider: { _, _ in guard let waiterResult else { return nil } return AsyncStream { continuation in - if let delay = waiterDelay { - Task { - try? await Task.sleep(for: delay) - continuation.yield(waiterResult) - continuation.finish() - } - } else { - continuation.yield(waiterResult) - continuation.finish() - } + continuation.yield(waiterResult) + continuation.finish() } }, captureProvider: captureProvider @@ -122,9 +113,10 @@ struct CLISendCommandHandlerTests { } @Test func timeoutReturnsWaitTimeoutError() async throws { - let handler = Self.makeHandler( - waiterResult: (exitCode: 0, durationMs: 5000), - waiterDelay: .seconds(10) + let handler = SendCommandHandler( + resolveProvider: { _ in .success(Self.makeTarget()) }, + textDelivery: { _, _, _ in true }, + waiterProvider: { _, _ in AsyncStream { _ in } } ) let response = await handler.handle( envelope: Self.makeEnvelope(timeoutSeconds: 1) diff --git a/supacodeTests/RemoteMirror/MirrorScrollStateTests.swift b/supacodeTests/RemoteMirror/MirrorScrollStateTests.swift index f17ddef7..991aba03 100644 --- a/supacodeTests/RemoteMirror/MirrorScrollStateTests.swift +++ b/supacodeTests/RemoteMirror/MirrorScrollStateTests.swift @@ -61,8 +61,10 @@ struct MirrorScrollStateTests { state.receiveResult(requestID: previous, sequence: 1) state.didPresent(sequence: 1) #expect(state.requestID == next) + await clock.advance() state.cancel() - await clock.run() + await clock.advance() + try await clock.checkSuspension() #expect(!state.isLoading) } @@ -71,8 +73,10 @@ struct MirrorScrollStateTests { let state = MirrorScrollState(clock: clock) state.didPresent(sequence: 100) _ = state.begin() + await clock.advance() state.reset() - await clock.run() + await clock.advance() + try await clock.checkSuspension() let next = try #require(state.begin()) state.didPresent(sequence: 1) state.receiveResult(requestID: next, sequence: 1) diff --git a/supacodeTests/RemoteMirror/MirrorVTExportSpikeTests.swift b/supacodeTests/RemoteMirror/MirrorVTExportSpikeTests.swift index e56a27b8..b5949228 100644 --- a/supacodeTests/RemoteMirror/MirrorVTExportSpikeTests.swift +++ b/supacodeTests/RemoteMirror/MirrorVTExportSpikeTests.swift @@ -91,8 +91,7 @@ struct MirrorVTExportSpikeTests { init(directory: URL, file: String, title: String) throws { config = try #require(ghostty_config_new()) let configuration = directory.appending(path: "ghostty.conf") - try - "font-size = 12\nbackground = #20242c\nforeground = #e0e0e0\nscrollback-limit = 10000000\n" + try "font-size = 12\nbackground = #20242c\nforeground = #e0e0e0\nscrollback-limit = 10000000\n" .write( to: configuration, atomically: true, encoding: .utf8) ghostty_config_load_file(config, configuration.path)