diff --git a/Makefile b/Makefile index 3ebcc860c..eed3c26c3 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/MirrorClient/Android/README.md b/MirrorClient/Android/README.md index 11483190e..366ae008f 100644 --- a/MirrorClient/Android/README.md +++ b/MirrorClient/Android/README.md @@ -21,11 +21,11 @@ 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. - 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. @@ -34,6 +34,24 @@ 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` 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. Host targets its pane height minus three rows (at least one row), + with exact native scrollback movement and equivalent precision wheel input for + 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. + Both directions resume reading at the top of the returned screen. Automatic + 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. @@ -69,3 +87,14 @@ 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. + +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. 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/ThirdPartyNotices/README.md b/MirrorClient/Android/ThirdPartyNotices/README.md index d74f20692..a77ccc013 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 7093297c7..ca61568a5 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/androidTest/java/com/awhisper/prowlmirror/PairingCaptureTest.kt b/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/PairingCaptureTest.kt new file mode 100644 index 000000000..72afffba4 --- /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 new file mode 100644 index 000000000..3f0e96453 --- /dev/null +++ b/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt @@ -0,0 +1,302 @@ +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.* +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 + private var includesScrollState = false + private val compositionGeneration = mutableStateOf(0) + + @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, + scrollState: Boolean = false, + ) { + includesScrollState = scrollState + 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 }, + "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) + model.sessions += session + model.selected = session.id + } + rule.setContent { key(compositionGeneration.value) { 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, + "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() + 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" }) + 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 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 { swipeDown() } + output.performTouchInput { swipeUp() } + rule.runOnIdle { assertEquals(0, sent.count { it.kind == "scroll" }) } + rule.onNodeWithText("Scroll up").assertIsEnabled() + rule.onNodeWithText("Scroll down").assertIsEnabled() + } + + @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) + } + output.performTouchInput { swipeUp() } + rule.runOnIdle { assertTrue(session.liveScrollIndex > 0 || session.liveScrollOffset > 0) } + rule.onNodeWithText("Scroll down").performClick() + complete(text) + rule.runOnIdle { + assertEquals(0, session.liveScrollIndex) + assertEquals(0, session.liveScrollOffset) + } + 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 + 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) + 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/AndroidManifest.xml b/MirrorClient/Android/app/src/main/AndroidManifest.xml index e709f94ad..d86b03fbe 100644 --- a/MirrorClient/Android/app/src/main/AndroidManifest.xml +++ b/MirrorClient/Android/app/src/main/AndroidManifest.xml @@ -1,6 +1,10 @@ + + + + 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 4f5a53bd3..0704210fb 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 = { @@ -399,6 +400,13 @@ 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()) { + scroll.scrollToItem(0) + session.consumeScrollCompletion(completion.requestID) + } + } Column(Modifier.fillMaxSize()) { if (state.showsHistory) Row(verticalAlignment = Alignment.CenterVertically) { @@ -412,6 +420,25 @@ 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(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") } + } LazyColumn( Modifier.weight(1f).fillMaxWidth().testTag("mirror-output"), state = scroll, 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 000000000..e442bafe0 --- /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/PairingPayload.kt b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/PairingPayload.kt new file mode 100644 index 000000000..4d306281b --- /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 000000000..cc50a6cad --- /dev/null +++ b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/PairingScanButton.kt @@ -0,0 +1,40 @@ +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 { + setCaptureActivity(PairingCaptureActivity::class.java) + 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/main/java/com/awhisper/prowlmirror/RemoteScroll.kt b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/RemoteScroll.kt new file mode 100644 index 000000000..9cbc91e3b --- /dev/null +++ b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/RemoteScroll.kt @@ -0,0 +1,13 @@ +package com.awhisper.prowlmirror + +enum class ScrollDirection(val wireName: String) { + UP("up"), + DOWN("down"), +} + +data class ScrollCompletion(val requestID: String, val direction: ScrollDirection) + +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 221d8675f..c6817b6b3 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,17 @@ data class SessionState( val loadingHistory: Boolean = false, val showsHistory: Boolean = false, 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 && @@ -133,6 +143,17 @@ 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 PendingScrollState(val sequence: Long, val bounds: ScrollBounds) + private var pendingScrollState: PendingScrollState? = null + private var expectsScrollState = false private data class Submission( val id: String, @@ -213,6 +234,10 @@ 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++ @@ -320,13 +345,19 @@ class Session( } private fun subscribe() { + 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 }, + ), ) ) } @@ -360,6 +391,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" } @@ -432,6 +466,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()) } @@ -462,6 +497,67 @@ class Session( } } + fun scrollRemote(direction: ScrollDirection) { + val active = lease ?: return + if (!state.value.canScrollRemote(direction)) 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, + ))) + } + + fun consumeScrollCompletion(id: String) { + state.update { + if (it.scrollCompletion?.requestID == id) it.copy(scrollCompletion = null) else it + } + } + + 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 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) { @@ -474,8 +570,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, @@ -504,6 +609,8 @@ class Session( else if (state.value.status != Status.live) subscribe() } "subscribed" -> { + clearScroll() + pendingScrollState = null require(canonical(p.string("paneID")) == state.value.pane?.id) lease = canonical(p.string("subscriptionID")) runID = canonical(p.string("hostRunID")) @@ -514,6 +621,7 @@ class Session( it.copy( status = Status.live, error = null, + scrollBounds = ScrollBounds(), history = emptyList(), historyOffset = 0, historyTime = null, @@ -538,7 +646,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 @@ -551,6 +667,8 @@ class Session( failed(error) } } + "scrollResult" -> scrollResult(p) + "scrollState" -> receiveScrollState(p) "historyPage" -> { require( canonical(p.string("subscriptionID")) == lease && state.value.loadingHistory @@ -584,12 +702,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 +719,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 c266348fd..a10c9c5f5 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,12 +30,18 @@ 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) 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 +104,9 @@ object Wire { "acknowledge", "input", "refresh", + "scroll", + "scrollResult", + "scrollState", "history", "historyPage", "command", @@ -217,7 +226,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 932413c37..c9f93d10e 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,48 @@ 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 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 fun pairingAndProofBinding() { assertEquals("ABCD2345", Authentication.code("abcd-2345")) @@ -112,7 +154,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/PairingPayloadTest.kt b/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/PairingPayloadTest.kt new file mode 100644 index 000000000..4736a2c4c --- /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/Android/app/src/test/java/com/awhisper/prowlmirror/SessionTest.kt b/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/SessionTest.kt index 6fde43dc7..8a43ca09a 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,26 @@ class SessionTest { val peer get() = peers.last() - fun ready() { + fun ready(interactive: Boolean = true, remoteScroll: Boolean = false, scrollState: Boolean = false) { 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()) + + (if (remoteScroll) listOf("remote-scroll") else emptyList()) + + (if (scrollState) listOf("scroll-state-v1") else emptyList())), "hostRunID" to run, ), ) ) } - fun live() { + fun live(remoteScroll: Boolean = false, scrollState: Boolean = false) { session.connect() - ready() + ready(remoteScroll = remoteScroll, scrollState = scrollState) session.choose(pane) subscribed() } @@ -101,6 +104,245 @@ 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) + 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) + 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) + 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>() @@ -209,7 +451,7 @@ class SessionTest { } @Test - fun agentDispatchDoesNotRequireShellCapability() = runTest { + fun agentInputDoesNotRequireShellCapability() = runTest { val f = Fixture(backgroundScope) f.live() f.session.setDraft("Review") @@ -219,11 +461,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 +496,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/Android/docs/validation.md b/MirrorClient/Android/docs/validation.md index c17500560..92e1d38a9 100644 --- a/MirrorClient/Android/docs/validation.md +++ b/MirrorClient/Android/docs/validation.md @@ -39,6 +39,56 @@ Ghostty, existing remote debug apps, or any physical device. selection cancellation, session-specific dialogs, session-owned launch state, and Unicode-safe detail chunking. +## 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, + 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/`. + +## 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/Shared/MirrorPairingPayload.swift b/MirrorClient/Shared/MirrorPairingPayload.swift new file mode 100644 index 000000000..b03ed2c0c --- /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 098152e33..7ebf90f0e 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 18f520a2c..978db87bf 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() } } } @@ -340,6 +360,35 @@ private struct MirrorReadingView: View { .onChange(of: session.revision) { _, _ in if session.followsLatest { proxy.scrollTo("latest", anchor: .bottom) } } + .onChange(of: session.completedScroll) { _, completion in + guard completion != nil else { return } + position.scrollTo(edge: .top) + } + .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() + if session.isScrolling { + ProgressView("Scrolling…").accessibilityIdentifier("mirror-scroll-progress") + } + 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") + } + 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) + } + } + .font(.caption).padding(.horizontal).padding(.vertical, 8).background(.bar) + } .safeAreaInset(edge: .bottom) { HStack { Toggle("Follow latest", isOn: $session.followsLatest).toggleStyle(.button) @@ -349,6 +398,7 @@ private struct MirrorReadingView: View { 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/Composer/MirrorPairingScanner.swift b/MirrorClient/iOS/ProwlMirror-iOS/Features/Composer/MirrorPairingScanner.swift new file mode 100644 index 000000000..8b955afea --- /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-iOS/Features/Sessions/MirrorInputRoute.swift b/MirrorClient/iOS/ProwlMirror-iOS/Features/Sessions/MirrorInputRoute.swift index e4d2b3f09..ecd058388 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 4c9fea1a6..e22343f8e 100644 --- a/MirrorClient/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift +++ b/MirrorClient/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift @@ -35,7 +35,18 @@ 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? + 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 + @ObservationIgnored private var supportsAgentInput = false @ObservationIgnored private var pendingCommand: PendingCommand? @ObservationIgnored private var commandTimeout: Task? @ObservationIgnored private let clock: any Clock @@ -44,13 +55,30 @@ 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 + } + func canScroll(_ direction: MirrorMessage.ScrollDirection) -> Bool { + canScroll && (direction == .upward ? scrollAtTop != true : scrollAtBottom != true) + } 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 +156,13 @@ final class MirrorSession: Identifiable { status = .connecting supportsLaunch = false supportsShellSend = false + supportsAgentInput = false + supportsRemoteScroll = false + supportsScrollState = false + clearScrollState() + clearScroll() + scrollError = nil + completedScroll = nil generation = UUID() let attempt = generation do { @@ -151,6 +186,8 @@ final class MirrorSession: Identifiable { self.transport = nil 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 @@ -228,6 +265,8 @@ final class MirrorSession: Identifiable { old?.onClose = nil old?.close(nil) subscriptionID = nil + clearScroll() + clearScrollState() markDeliveryUncertain() isLoadingHistory = false status = .disconnected @@ -277,7 +316,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 +333,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 +354,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 +373,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) @@ -398,13 +431,96 @@ final class MirrorSession: Identifiable { subscriptionID: subscriptionID))) } + func scroll(_ direction: MirrorMessage.ScrollDirection) { + guard canScroll(direction), 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 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 + 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() + 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) } @@ -419,9 +535,12 @@ 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 + supportsRemoteScroll = message.capabilities?.contains("remote-scroll") == true + supportsScrollState = message.capabilities?.contains("scroll-state-v1") == true if supportsSubmission { querySubmission() } panes = message.panes ?? [] onVerifiedConnection?(configuration) @@ -466,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 @@ -476,6 +595,10 @@ final class MirrorSession: Identifiable { updatedAt = Date() status = .live 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, @@ -513,6 +636,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/MirrorCommand.swift b/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorCommand.swift index 00a073b80..614676697 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/MirrorProtocol.swift b/MirrorClient/iOS/ProwlMirror-iOS/Infrastructure/MirrorProtocol.swift index 6c60cb393..0fe4fb21d 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, scrollState } case list case panes(PanesPayload) @@ -61,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 { @@ -113,6 +118,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 +130,25 @@ 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 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? @@ -160,6 +185,9 @@ nonisolated enum MirrorMessage: Codable, Sendable { case .pong: .pong case .ended: .ended case .refresh: .refresh + case .scroll: .scroll + case .scrollResult: .scrollResult + case .scrollState: .scrollState case .command: .command case .commandResult: .commandResult case .commandReceipt: .commandReceipt @@ -214,6 +242,9 @@ 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 .scrollState(let payload): payload.subscriptionID case .command(let payload): payload.subscriptionID case .commandReceipt(let payload): payload.subscriptionID default: nil @@ -230,6 +261,8 @@ 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 + case .scrollState(let payload): payload.sequence default: nil } } @@ -301,6 +334,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 4159da281..99801e8bf 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"}}]}}"# @@ -56,9 +56,15 @@ 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? + private var includesScrollState = false 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 { @@ -68,10 +74,13 @@ .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()))) + ] + (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() @@ -81,8 +90,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 @@ -101,10 +113,14 @@ ]) ]), ]) - 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") @@ -114,6 +130,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) @@ -129,8 +147,44 @@ } } + 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 + 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)\nUse the buttons above 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/MirrorCommandTransportTests.swift b/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorCommandTransportTests.swift index dc984d7d6..8d69e3565 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 868f69624..058b075d9 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 cb4722e1a..bb49ef89b 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/ProwlMirror-iOSTests/MirrorPairingPayloadTests.swift b/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorPairingPayloadTests.swift new file mode 100644 index 000000000..a1e126c7d --- /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/ProwlMirror-iOSTests/MirrorRemoteScrollTests.swift b/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorRemoteScrollTests.swift new file mode 100644 index 000000000..b29cd4eba --- /dev/null +++ b/MirrorClient/iOS/ProwlMirror-iOSTests/MirrorRemoteScrollTests.swift @@ -0,0 +1,419 @@ +import Foundation +import Observation +import Testing + +@testable import ProwlMirror_iOS + +@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) + #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 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 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)"}}} + """ + #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 + 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, supportsScrollState: Bool = false) { + self.supportsScroll = supportsScroll + self.supportsScrollState = supportsScrollState + } + 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"] : []) + + (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) + case .scroll: + if let id = message.scrollRequestID { requests.append(id) } + default: break + } + } + 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))) + } + 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 52e4aedb3..578117aa6 100644 --- a/MirrorClient/iOS/ProwlMirror-iOSUITests/ProwlMirror_iOSUITests.swift +++ b/MirrorClient/iOS/ProwlMirror-iOSUITests/ProwlMirror_iOSUITests.swift @@ -2,6 +2,192 @@ 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() + 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 testRemoteScrollAlwaysRevealsTheTopWhileInteriorDragStaysLocal() { + 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 1"] + 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 testRemoteScrollButtonsLeaveLocalGesturesAndHistoryUnchanged() { + 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)) + XCTAssertLessThan(scrollUp.frame.maxY, page(0).frame.minY) + let reading = app.scrollViews["mirror-live-scroll"] + reading.swipeDown() + XCTAssertTrue(page(0).exists) + reading.swipeUp() + XCTAssertTrue(page(0).exists) + 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 11ca07d61..5441267b8 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. @@ -46,6 +48,35 @@ 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 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 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 +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 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: +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 within 350 ms send when enabled, removing only the shortcut's first newline. @@ -71,3 +102,11 @@ 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. + +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/MirrorRelay/main.swift b/MirrorRelay/main.swift index 1dbdf31a1..b3c4fbad3 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 000000000..8145b93e5 --- /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/ProwlCLITests/DispatchWireModelTests.swift b/ProwlCLITests/DispatchWireModelTests.swift index e1155a4b8..44847466d 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 35539a0e5..18b8cb740 100644 --- a/docs-ai/067-remote-mirror/000-plan.md +++ b/docs-ai/067-remote-mirror/000-plan.md @@ -67,3 +67,9 @@ 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). +- 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/009-interactive-input.md b/docs-ai/067-remote-mirror/009-interactive-input.md new file mode 100644 index 000000000..b646765cd --- /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-ai/067-remote-mirror/010-qr-pairing.md b/docs-ai/067-remote-mirror/010-qr-pairing.md new file mode 100644 index 000000000..cbfd65d24 --- /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/067-remote-mirror/011-remote-scroll.md b/docs-ai/067-remote-mirror/011-remote-scroll.md new file mode 100644 index 000000000..bfaadaa4d --- /dev/null +++ b/docs-ai/067-remote-mirror/011-remote-scroll.md @@ -0,0 +1,79 @@ +# 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 explicit up/down buttons. + No automatic retry of input after uncertain delivery. +- 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. + +## 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-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 000000000..f56ca7e7d --- /dev/null +++ b/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md @@ -0,0 +1,145 @@ +# 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. + +## 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. + +## 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. +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 +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. + +## 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 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. 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. +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-ai/README.md b/docs-ai/README.md index 6ed92f83b..5509cbc0c 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), [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 10484e621..934ea27db 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 @@ -65,3 +65,123 @@ 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. + +## 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. + +## 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 +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 +`{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. + +## 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. + +### 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 c95b7a473..130f6c377 100644 --- a/docs/remote-mirror.md +++ b/docs/remote-mirror.md @@ -66,7 +66,13 @@ 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. 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 @@ -99,11 +105,52 @@ 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. 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 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. 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. 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 +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 +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. @@ -135,7 +182,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 +197,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 +212,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. @@ -189,3 +243,44 @@ 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). + +### 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 **Connection +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. +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. 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 + +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. +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/scripts/test_app_build_path.py b/scripts/test_app_build_path.py index 866e67a80..480c00f15 100644 --- a/scripts/test_app_build_path.py +++ b/scripts/test_app_build_path.py @@ -63,7 +63,7 @@ def test_network_suites_run_once_outside_the_bulk_suite(self): 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/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index f63428a87..f064c3a75 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/CLIService/AgentDispatchCommandHandler.swift b/supacode/CLIService/AgentDispatchCommandHandler.swift index 5a0702898..d14711e0c 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 23d50890a..2be43a9b7 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 06955e8b3..c73492d85 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/AddRemoteMirrorView.swift b/supacode/Features/RemoteMirror/AddRemoteMirrorView.swift index a61265157..8ba804b04 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 { @@ -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) @@ -163,9 +159,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) @@ -174,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/GhosttyMirrorPaneSource.swift b/supacode/Features/RemoteMirror/GhosttyMirrorPaneSource.swift index 3c20b17f0..3d97d246b 100644 --- a/supacode/Features/RemoteMirror/GhosttyMirrorPaneSource.swift +++ b/supacode/Features/RemoteMirror/GhosttyMirrorPaneSource.swift @@ -1,8 +1,96 @@ +import AppKit import Foundation 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) + } + + 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) { @@ -36,10 +124,12 @@ final class GhosttyMirrorPaneSource: MirrorPaneSource { }.sorted { $0.title < $1.title } } + var supportsViewportText: Bool { true } + var supportsScrollState: 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 +138,17 @@ 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 Self.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, + scrollBounds: geometry.scrollBounds) } func write(_ bytes: Data, to id: UUID) throws { @@ -79,6 +177,44 @@ 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, (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 { + 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. 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) + 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) + } + var supportsBoundedHistory: Bool { true } func boundedRetainedText(_ id: UUID) throws -> MirrorRetainedText { @@ -87,11 +223,111 @@ 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 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 { + 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 + 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 { let size = ghostty_surface_size(terminal) - let value = try boundedText(id, active: true, maximumBytes: MirrorWire.maximumPayload / 8) + 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 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( - text: value.text, columns: UInt32(size.columns), rows: UInt32(size.rows), - truncated: value.truncated) + columns: columns, rows: rows, active: active, screen: screen, + mouseCaptured: ghostty_surface_mouse_captured(terminal)) + } + + 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) + } + + static func viewportText( + _ terminal: ghostty_surface_t, columns: UInt32, rows: UInt32, preserveRows: Bool + ) throws -> String { + if !preserveRows { + return try Self.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 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) } + 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 27693a3b5..5058a4cf6 100644 --- a/supacode/Features/RemoteMirror/MirrorClient.swift +++ b/supacode/Features/RemoteMirror/MirrorClient.swift @@ -22,6 +22,13 @@ 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 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() private(set) var historyTruncated = false private(set) var isSubscribed = false var onVerifiedConnection: (() -> Void)? @@ -76,6 +83,12 @@ final class MirrorClient: Identifiable { failure = nil endReason = nil subscriptionID = nil + scrollState.reset() + supportsRemoteScroll = false + supportsViewportText = false + supportsScrollState = false + viewportState = MirrorViewportState() + scrollBoundsState = MirrorScrollBoundsState() isSubscribed = false isConnecting = true let peer = makeConnection(configuration) @@ -105,6 +118,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 +151,32 @@ 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 + } + } + 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) } 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, + includeScrollState: supportsScrollState ? true : nil, + includeStyledScrollback: supportsStyledScrollback ? true : nil)) ) } catch { peer?.close(error.localizedDescription) } } @@ -156,6 +190,9 @@ final class MirrorClient: Identifiable { isConnected = false isConnecting = false isLoadingHistory = false + scrollState.reset() + viewportState = MirrorViewportState() + scrollBoundsState = MirrorScrollBoundsState() isSubscribed = false subscriptionID = nil historyLines = [] @@ -172,6 +209,7 @@ final class MirrorClient: Identifiable { } isLoadingHistory = true showsHistory = true + scrollState.cancel() peer?.send( .history( .init( @@ -179,6 +217,16 @@ final class MirrorClient: Identifiable { subscriptionID: subscriptionID))) } + func canScroll(_ direction: MirrorMessage.ScrollDirection) -> Bool { + isSubscribed && supportsRemoteScroll && !showsHistory && !scrollState.isLoading + && scrollBoundsState.canScroll(direction) + } + + func scroll(_ direction: MirrorMessage.ScrollDirection) { + guard canScroll(direction), 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 +244,9 @@ final class MirrorClient: Identifiable { return } subscriptionID = id + scrollState.reset() + viewportState = MirrorViewportState() + scrollBoundsState = MirrorScrollBoundsState() historyID = nil historyLines = [] historyOffset = 0 @@ -203,14 +254,11 @@ 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 .scrollState: + receiveScrollBounds(message) case .historyPage: if historyID == nil { historyPageGate = MirrorHistoryPageGate() } guard isLoadingHistory, message.subscriptionID == subscriptionID, @@ -227,18 +275,93 @@ 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.")) } + 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, styledScrollback: viewportState.pendingStyledScrollback, + viewportText: viewportState.pendingText) + } + + 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 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 + 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 +386,10 @@ 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 + 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/MirrorCommand.swift b/supacode/Features/RemoteMirror/MirrorCommand.swift index 00a073b80..614676697 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 c7ee1d676..38f1c7775 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/MirrorEndpointInput.swift b/supacode/Features/RemoteMirror/MirrorEndpointInput.swift index 39f625e32..6713aa3fc 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/MirrorHost.swift b/supacode/Features/RemoteMirror/MirrorHost.swift index a9bec1340..bb8300ccf 100644 --- a/supacode/Features/RemoteMirror/MirrorHost.swift +++ b/supacode/Features/RemoteMirror/MirrorHost.swift @@ -148,13 +148,54 @@ 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() var representation: MirrorMessage.Representation = .terminal + var includeViewportText = false + var includeStyledScrollback = false + var includeScrollState = 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( @@ -493,8 +534,12 @@ final class MirrorHost { panes: panes, capabilities: ["vt-v1", "text-v1", "takeover", "refresh"] + (commandService != nil - ? ["launch-profile", "launch-shell", "agents-dispatch"] : []) - + (source.supportsBoundedHistory ? ["history"] : []), hostRunID: hostRunID))) + ? ["launch-profile", "launch-shell", "agents-dispatch", "agent-input"] : []) + + (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) case .commandReceipt: @@ -516,6 +561,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 +570,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 +729,12 @@ final class MirrorHost { return } var next = Subscription( - paneID: paneID, representation: representation) + 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) if let first { _ = try MirrorWire.encode(first) } @@ -628,12 +745,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,25 +764,65 @@ 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) 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 } - let 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 } 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( + styledScrollback: payload.frame.styledScrollback, 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) + } + private func sendHistory(_ message: MirrorMessage, to peer: MirrorConnection) throws { guard var subscription = subscription(for: message, peer: peer) else { throw MirrorProtocolError.invalidMessage @@ -711,8 +869,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/MirrorHostButton.swift b/supacode/Features/RemoteMirror/MirrorHostButton.swift index 019f4361c..39fb2e8a3 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) @@ -385,6 +409,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? @@ -395,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 { @@ -413,13 +438,13 @@ 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. + 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 } if let error = copyError ?? host.error { Text(error).foregroundStyle(.red).fixedSize(horizontal: false, vertical: true) @@ -452,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 @@ -471,64 +497,46 @@ private struct MirrorPairingView: View { } } - 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) + private var pairingQR: some View { + let choices = reachable.filter { !$0.isLoopback && $0.address != "::1" } + let selected = pairingAddress + return VStack(spacing: 8) { + if let expires = host.pairingExpiresAt, let selected { + if choices.count > 1 { + Picker("Connection address", selection: Binding(get: { selected }, set: { qrAddress = $0 })) { + ForEach(choices) { item in + Text("\(item.label): \(item.address)").tag(item.address) + } + } + .help("Choose the Wi-Fi or VPN address reachable from your Mirror device") } - ForEach(reachable) { interface in - addressRow(label: interface.label, value: interface.address + ":" + host.port) + Text("\(selected):\(host.port)").font(.body.monospaced()).textSelection(.enabled) + Button(copied ? "Copied" : "Copy Connection Details", systemImage: copied ? "checkmark" : "doc.on.doc") { + copyConnectionDetails() } - } - if reachable.isEmpty, localName == nil { - Text("No network address found. Connect this Mac to a network.").font(.caption).foregroundStyle(.secondary) + .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) + } 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() @@ -546,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/supacode/Features/RemoteMirror/MirrorPairingQRCode.swift b/supacode/Features/RemoteMirror/MirrorPairingQRCode.swift new file mode 100644 index 000000000..a26d49b97 --- /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/supacode/Features/RemoteMirror/MirrorPaneSource.swift b/supacode/Features/RemoteMirror/MirrorPaneSource.swift index bfd4bed48..a9fe30bc6 100644 --- a/supacode/Features/RemoteMirror/MirrorPaneSource.swift +++ b/supacode/Features/RemoteMirror/MirrorPaneSource.swift @@ -3,10 +3,16 @@ 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 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 } func boundedRetainedText(_ id: UUID) throws -> MirrorRetainedText } @@ -16,13 +22,27 @@ struct MirrorTextSnapshot { var columns: UInt32 = 0 var rows: UInt32 = 0 var truncated = false + var scrollBounds: MirrorScrollBounds? } 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 } + 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 097d604be..2fff5e0cf 100644 --- a/supacode/Features/RemoteMirror/MirrorProtocol.swift +++ b/supacode/Features/RemoteMirror/MirrorProtocol.swift @@ -14,6 +14,24 @@ nonisolated struct MirrorFrame: Codable, Equatable, Sendable { let columns: UInt32 let rows: UInt32 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 { + var atTop: Bool? + var atBottom: Bool? } nonisolated enum MirrorMessage: Codable, Sendable { @@ -24,6 +42,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 +68,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, scrollState, command, commandResult, commandReceipt } case list case panes(PanesPayload) @@ -61,6 +83,9 @@ nonisolated enum MirrorMessage: Codable, Sendable { var paneID: UUID var representation: Representation var intent: Intent + var includeViewportText: Bool? + var includeScrollState: Bool? + var includeStyledScrollback: Bool? } case subscribed(SubscribedPayload) struct SubscribedPayload: Codable, Sendable { @@ -82,6 +107,14 @@ nonisolated enum MirrorMessage: Codable, Sendable { var sequence: UInt64 var text: String var subscriptionID: UUID + var scrollBounds: MirrorScrollBounds? + } + case viewport(ViewportPayload) + struct ViewportPayload: Codable, Sendable { + var styledScrollback: MirrorStyledScrollback? + var text: String? + var sequence: UInt64 + var subscriptionID: UUID } case acknowledge(AcknowledgePayload) struct AcknowledgePayload: Codable, Sendable { @@ -93,6 +126,25 @@ 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 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? @@ -113,6 +165,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 +204,12 @@ 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 .scrollState: .scrollState case .history: .history case .historyPage: .historyPage case .failure: .failure @@ -208,8 +265,12 @@ 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 .scrollState(let payload): payload.subscriptionID case .history(let payload): payload.subscriptionID case .historyPage(let payload): payload.subscriptionID case .failure(let payload): payload.subscriptionID @@ -229,7 +290,10 @@ 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 + case .scrollState(let payload): payload.sequence default: nil } } @@ -239,12 +303,54 @@ nonisolated enum MirrorMessage: Codable, Sendable { default: nil } } + var viewportText: String? { + switch self { + case .viewport(let payload): payload.text + 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 + 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 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 @@ -440,10 +546,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/MirrorReplica.swift b/supacode/Features/RemoteMirror/MirrorReplica.swift index bc7587967..6ea4a1138 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)? @@ -15,13 +16,25 @@ 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? + @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 - 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() } @@ -47,6 +60,16 @@ final class MirrorReplica { self.view = GhosttySurfaceView( runtime: self.runtime, workingDirectory: nil, context: GHOSTTY_SURFACE_CONTEXT_WINDOW, command: command) + 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 } @@ -58,7 +81,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 +101,105 @@ 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 + 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 { + // 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)) + } + // 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 + 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 + parseTimeout?.cancel() + parseTimeout = 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 + parseTimeout?.cancel() + parseTimeout = nil + presentationTask?.cancel() + presentationTask = nil needsRestart = false listener?.cancel() listener = nil @@ -97,6 +211,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 +248,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 +264,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/MirrorScrollBoundsState.swift b/supacode/Features/RemoteMirror/MirrorScrollBoundsState.swift new file mode 100644 index 000000000..b9a4451c8 --- /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/MirrorScrollState.swift b/supacode/Features/RemoteMirror/MirrorScrollState.swift new file mode 100644 index 000000000..4f5e9cac8 --- /dev/null +++ b/supacode/Features/RemoteMirror/MirrorScrollState.swift @@ -0,0 +1,76 @@ +import Foundation +import Observation + +@MainActor +@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 + @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() + completedRequestID = nil + presentedSequence = 0 + baseline = 0 + error = nil + } + + 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 92521ed5a..13f0b2e20 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 scrollCompletion: UUID? func makeNSView(context: Context) -> MirrorTerminalScrollView { - MirrorTerminalScrollView(surface: surface, displaySize: displaySize, fitsWindow: fitsWindow) + 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) } } @@ -20,10 +28,39 @@ 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 scrollCompletion: UUID? private var isLayingOut = false var displaySize: CGSize { didSet { if displaySize != oldValue { needsLayout = true } } @@ -45,13 +82,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,6 +116,23 @@ 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 + } + + 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") } @@ -76,6 +146,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 +163,25 @@ 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) + } + } } diff --git a/supacode/Features/RemoteMirror/MirrorViewportState.swift b/supacode/Features/RemoteMirror/MirrorViewportState.swift new file mode 100644 index 000000000..9df3daff3 --- /dev/null +++ b/supacode/Features/RemoteMirror/MirrorViewportState.swift @@ -0,0 +1,36 @@ +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? + 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.styledScrollback?.isValid != false + 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/RelayWire/MirrorDisplayFrame.swift b/supacode/Features/RemoteMirror/RelayWire/MirrorDisplayFrame.swift new file mode 100644 index 000000000..ccbd492e7 --- /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.. 0 else { return } let items: [(mime: String, data: String)] = (0.. Bool)? + var consumeWorkingDirectory: ((String) -> 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 +247,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) @@ -251,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) @@ -444,6 +460,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/AgentDispatchCommandHandlerTests.swift b/supacodeTests/AgentDispatchCommandHandlerTests.swift index 9ab886116..dd0231245 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/CLISendCommandHandlerTests.swift b/supacodeTests/CLISendCommandHandlerTests.swift index ae829342d..93c9f0818 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/MirrorCommandServiceTests.swift b/supacodeTests/RemoteMirror/MirrorCommandServiceTests.swift index 79a26704e..5a7955024 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/MirrorDevicePairingTests.swift b/supacodeTests/RemoteMirror/MirrorDevicePairingTests.swift index 26e874854..22902be64 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) @@ -88,6 +92,105 @@ 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) + 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) + } + @Test(.timeLimit(.minutes(1))) func pairingConnectionCannotListAndWindowExpires() async throws { let clock = TestClock() let vault = Vault() @@ -373,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) @@ -380,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() diff --git a/supacodeTests/RemoteMirror/MirrorHostTests.swift b/supacodeTests/RemoteMirror/MirrorHostTests.swift index 45d20237b..dcc5fb01a 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() @@ -452,6 +455,331 @@ 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) + } + + @Test(.timeLimit(.minutes(1)), arguments: [MirrorMessage.Representation.terminal, .text], [false, true]) + func scrollStateRequiresOptInAndTracksBoundaryOnlyChanges( + representation: MirrorMessage.Representation, optedIn: Bool + ) async throws { + let source = Source() + source.scrollBounds = .init(atTop: false, atBottom: true) + 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("scroll-state-v1") == true) + peer.connection.send( + .subscribe( + .init( + paneID: source.id, representation: representation, intent: .ifFree, includeScrollState: optedIn))) + let lease = try #require(await messages.next()?.subscriptionID) + if optedIn { + let state = try #require(await messages.next()) + #expect(state.kind == .scrollState) + #expect(state.scrollBounds == source.scrollBounds) + #expect(state.sequence == 1) + #expect(state.subscriptionID == lease) + } + let first = try #require(await messages.next()) + #expect(first.kind == (representation == .text ? .textFrame : .frame)) + peer.connection.send(.acknowledge(.init(sequence: 1, subscriptionID: lease))) + peer.connection.send(.list) + #expect(await messages.next()?.kind == .panes) + source.scrollBounds = .init(atTop: true, atBottom: false) + await clock.advance(by: .milliseconds(200)) + if optedIn { + #expect(await messages.next()?.scrollBounds == source.scrollBounds) + let changed = try #require(await messages.next()) + #expect(changed.sequence == 2) + #expect(changed.frame == first.frame) + #expect(changed.text == first.text) + peer.connection.send(.acknowledge(.init(sequence: 2, subscriptionID: lease))) + peer.connection.send(.list) + #expect(await messages.next()?.kind == .panes) + source.scrollBounds = nil + await clock.advance(by: .milliseconds(200)) + let unknown = try #require(await messages.next()) + #expect(unknown.kind == .scrollState) + #expect(unknown.scrollBounds == .init()) + #expect(unknown.sequence == 3) + #expect(await messages.next()?.sequence == 3) + } + peer.connection.send(.list) + #expect(await messages.next()?.kind == .panes) + } + + @Test(.timeLimit(.minutes(1))) func sourceWithoutScrollStateIgnoresOptIn() async throws { + let source = Source() + source.supportsScrollState = false + 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("scroll-state-v1") == false) + peer.connection.send( + .subscribe( + .init( + paneID: source.id, representation: .text, intent: .ifFree, includeScrollState: true))) + #expect(await messages.next()?.kind == .subscribed) + #expect(await messages.next()?.kind == .textFrame) + } + @MainActor private final class Source: MirrorPaneSource { let id = UUID() let otherID = UUID() @@ -459,12 +787,29 @@ struct MirrorHostTests { var reads = 0 var input = Data() var text = "thinking" + var frameBytes = Data("thinking".utf8) var textUnavailable = false + var supportsViewportText = true + var supportsScrollState = true + var scrollBounds: MirrorScrollBounds? + 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 return text } + func textSnapshot(_ id: UUID) throws -> MirrorTextSnapshot { + .init(text: try activeText(id), scrollBounds: scrollBounds) + } func panes() -> [MirrorPaneDescriptor] { let primary = MirrorPaneDescriptor(id: id, title: "Fixture", directory: "/", busy: false) return includesOtherPane @@ -472,7 +817,12 @@ 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, scrollBounds: scrollBounds) } func write(_ bytes: Data, to id: UUID) throws { input.append(bytes) } var supportsBoundedHistory: Bool { true } diff --git a/supacodeTests/RemoteMirror/MirrorLaunchModelTests.swift b/supacodeTests/RemoteMirror/MirrorLaunchModelTests.swift index d50533c9d..40c6d5b31 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"}}"#) diff --git a/supacodeTests/RemoteMirror/MirrorPairingPayloadTests.swift b/supacodeTests/RemoteMirror/MirrorPairingPayloadTests.swift new file mode 100644 index 000000000..1dcf20e73 --- /dev/null +++ b/supacodeTests/RemoteMirror/MirrorPairingPayloadTests.swift @@ -0,0 +1,62 @@ +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 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,"# + + #""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 000000000..452e8f4b1 --- /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) + } +} diff --git a/supacodeTests/RemoteMirror/MirrorProtocolTests.swift b/supacodeTests/RemoteMirror/MirrorProtocolTests.swift index 4bb04e40d..ae08c57b1 100644 --- a/supacodeTests/RemoteMirror/MirrorProtocolTests.swift +++ b/supacodeTests/RemoteMirror/MirrorProtocolTests.swift @@ -4,6 +4,99 @@ 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 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 000000000..65da74424 --- /dev/null +++ b/supacodeTests/RemoteMirror/MirrorReplicaInputTests.swift @@ -0,0 +1,95 @@ +import AppKit +import Clocks +import Foundation +import GhosttyKit +import Testing + +@testable import supacode + +@Suite(.serialized) +@MainActor +struct MirrorReplicaInputTests { + @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 + 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 { + if staticTitle, sequence == 2 { try reloadTitle("Reloaded fixed title") } + 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("\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 { + 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(30)) + 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 000000000..5522f4b75 --- /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 new file mode 100644 index 000000000..991aba039 --- /dev/null +++ b/supacodeTests/RemoteMirror/MirrorScrollStateTests.swift @@ -0,0 +1,86 @@ +import AppKit +import Clocks +import Testing + +@testable import supacode + +@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) + 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) + #expect(state.completedRequestID == nil) + state.didPresent(sequence: 6) + #expect(!state.isLoading) + #expect(state.error == nil) + #expect(state.completedRequestID == request) + } + + @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) + #expect(state.completedRequestID == nil) + let next = try #require(state.begin()) + state.receiveResult(requestID: previous, sequence: 1) + state.didPresent(sequence: 1) + #expect(state.requestID == next) + await clock.advance() + state.cancel() + await clock.advance() + try await clock.checkSuspension() + #expect(!state.isLoading) + } + + @Test func resetAllowsNewSubscriptionSequenceAndCancelsTimeout() async throws { + let clock = TestClock() + let state = MirrorScrollState(clock: clock) + state.didPresent(sequence: 100) + _ = state.begin() + await clock.advance() + state.reset() + await clock.advance() + try await clock.checkSuspension() + let next = try #require(state.begin()) + state.didPresent(sequence: 1) + state.receiveResult(requestID: next, sequence: 1) + #expect(!state.isLoading) + #expect(state.error == nil) + } +} diff --git a/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift b/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift index 6a52ec90f..7de8b963c 100644 --- a/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift +++ b/supacodeTests/RemoteMirror/MirrorTerminalIntegrationTests.swift @@ -14,6 +14,333 @@ import Testing @Suite(.serialized) @MainActor struct MirrorTerminalIntegrationTests { + @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") } + 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("focus-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) + 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) + client.scroll(.upward) + #expect(client.scrollState.isLoading) + try await fixture.wait("Earlier remote viewport") { + 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: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 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") { + 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)) + do { + let viewport = try #require(overlay.enclosingScrollView as? MirrorTerminalScrollView) + viewport.updateViewportText(earlier) + try verifyOverlayPreservesOtherInputFocus(overlay, container: container, window: window) + viewport.updateViewportText(nil) + } + hosting.layoutSubtreeIfNeeded() + #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 } + #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("") + 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 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 styledText = try #require(String(data: archive, encoding: .utf8)) + #expect(styledText.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)) + 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") + #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 } + #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() } + 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, includeScrollState: true))) + } + 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) + #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) + 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 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) + #expect(messages.last?.historyID == history.historyID) + #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() } + 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.sourceFrame() + 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.sourceFrame() + 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() } @@ -192,7 +519,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 @@ -206,7 +533,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 @@ -217,7 +544,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")) } @@ -283,10 +610,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() @@ -347,8 +674,17 @@ 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 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) @@ -609,6 +945,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() @@ -628,10 +972,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" @@ -692,7 +1036,40 @@ 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 '\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##* } + for ((n=1; n<=250; n++)); do + printf 'W%03d' "$n" + printf '%*s' "$((columns-5))" '' + 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' + 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/MirrorVTExportSpikeTests.swift b/supacodeTests/RemoteMirror/MirrorVTExportSpikeTests.swift new file mode 100644 index 000000000..b59492288 --- /dev/null +++ b/supacodeTests/RemoteMirror/MirrorVTExportSpikeTests.swift @@ -0,0 +1,206 @@ +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() + } +} diff --git a/supacodeTests/RemoteMirror/MirrorViewportStateTests.swift b/supacodeTests/RemoteMirror/MirrorViewportStateTests.swift new file mode 100644 index 000000000..cd82c1ca0 --- /dev/null +++ b/supacodeTests/RemoteMirror/MirrorViewportStateTests.swift @@ -0,0 +1,96 @@ +import Foundation +import Testing + +@testable import supacode + +@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() + 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) + } +}