From 4eeabc2fca41cc3fc662532bfc14dd8c31bd29dd Mon Sep 17 00:00:00 2001 From: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:21:04 +0900 Subject: [PATCH 1/3] feat(terminal): list the dev servers the shell started and open them in the browser A dev server started in the embedded terminal had no status and no way to reach it: the user had to remember the port and type the address into the in-app browser by hand. Read the ports servers announce on screen ("Local: http://localhost:5173/", "Serving HTTP on 0.0.0.0 port 8000", "listening on :3000"), probe them with a TCP connect to the loopback addresses, and show the ones that answer as chips above the output. A chip opens http://localhost:PORT/ in the in-app browser; a server that stops answering is shown as stopped, and forgotten after about half a minute. A port is only shown once it has answered, because the echoed command line ("curl http://localhost:8080") names ports nothing listens on. Only the port number is taken from the output, never the host, so a program's output cannot send the in-app browser anywhere but this device. Probing starts when the output first names a server and ends with the shell, so a plain shell costs nothing. Only screen lines that are new since the last look are searched, so an old address still in the scrollback cannot revive a server that was forgotten. Refs #402 Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- .../TerminalScreenInstrumentedTest.kt | 52 ++++ .../feature/terminal/DevServerDetector.kt | 41 +++ .../feature/terminal/DevServerTracker.kt | 78 +++++ .../feature/workspace/TerminalScreen.kt | 72 +++++ .../feature/workspace/TerminalViewModel.kt | 157 +++++++++- .../andcode/runtime/local/DevServerProbe.kt | 42 +++ .../ui/navigation/WorkspaceNavGraph.kt | 1 + app/src/main/res/values-ar/strings.xml | 3 + app/src/main/res/values-es/strings.xml | 3 + app/src/main/res/values-fr/strings.xml | 3 + app/src/main/res/values-ja/strings.xml | 3 + app/src/main/res/values-pt-rBR/strings.xml | 3 + app/src/main/res/values-ru/strings.xml | 3 + app/src/main/res/values-zh-rCN/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + .../feature/terminal/DevServerDetectorTest.kt | 73 +++++ .../feature/terminal/DevServerTrackerTest.kt | 117 ++++++++ .../workspace/TerminalViewModelTest.kt | 277 +++++++++++++++++- .../local/LoopbackDevServerProbeTest.kt | 36 +++ docs/LOCAL_RUNTIME.md | 4 + 21 files changed, 959 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/yugahashimoto/andcode/feature/terminal/DevServerDetector.kt create mode 100644 app/src/main/java/com/yugahashimoto/andcode/feature/terminal/DevServerTracker.kt create mode 100644 app/src/main/java/com/yugahashimoto/andcode/runtime/local/DevServerProbe.kt create mode 100644 app/src/test/java/com/yugahashimoto/andcode/feature/terminal/DevServerDetectorTest.kt create mode 100644 app/src/test/java/com/yugahashimoto/andcode/feature/terminal/DevServerTrackerTest.kt create mode 100644 app/src/test/java/com/yugahashimoto/andcode/runtime/local/LoopbackDevServerProbeTest.kt diff --git a/README.md b/README.md index 2b4ac681e..5dd78c505 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ On-device agents run inside a Linux environment via PRoot. OpenCode, Claude Code - **Device files** — Grant all-files access and the whole phone (`/sdcard`, SD cards, USB drives) becomes browsable in the folder picker and reachable by the agent, opened in place instead of copied into the app - **Git support** — Browse status and review diffs from the GUI; use the embedded runtime terminal for other Git operations - **Diff viewer** — Review code changes inline before accepting -- **Embedded terminal** — An interactive shell (real PTY) inside the on-device runtime: streamed output, stdin, Ctrl+C, colours, and it opens in the workspace you came from. Full-screen programs such as `vim` are not supported yet +- **Embedded terminal** — An interactive shell (real PTY) inside the on-device runtime: streamed output, stdin, Ctrl+C, colours, and it opens in the workspace you came from. Dev servers started in it (Vite, `python -m http.server`, …) show up as chips that open the page in the in-app browser. Full-screen programs such as `vim` are not supported yet - **Pull request badges** — Pull requests opened in a chat stay pinned above the composer with their diff size and state (draft, open, conflict, merged, closed); tap to open them on GitHub - **Tool approvals** — Approve or reject dangerous tool operations - **Session management** — Create, resume, rename, and delete sessions diff --git a/app/src/androidTest/java/com/yugahashimoto/andcode/feature/workspace/TerminalScreenInstrumentedTest.kt b/app/src/androidTest/java/com/yugahashimoto/andcode/feature/workspace/TerminalScreenInstrumentedTest.kt index eb0ce72bc..6a31ec43e 100644 --- a/app/src/androidTest/java/com/yugahashimoto/andcode/feature/workspace/TerminalScreenInstrumentedTest.kt +++ b/app/src/androidTest/java/com/yugahashimoto/andcode/feature/workspace/TerminalScreenInstrumentedTest.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertHasNoClickAction import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled @@ -17,6 +18,7 @@ import androidx.compose.ui.test.hasSetTextAction import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextReplacement @@ -26,6 +28,8 @@ import androidx.compose.ui.test.swipeUp import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import com.yugahashimoto.andcode.R +import com.yugahashimoto.andcode.feature.terminal.DevServer +import com.yugahashimoto.andcode.feature.terminal.DevServerStatus import com.yugahashimoto.andcode.feature.terminal.StyledLine import com.yugahashimoto.andcode.feature.terminal.TerminalKey import org.junit.Assert.assertEquals @@ -52,6 +56,7 @@ class TerminalScreenInstrumentedTest { private var submits = 0 private var restarts = 0 private var clears = 0 + private val openedServers = CopyOnWriteArrayList() private var state by mutableStateOf(TerminalUiState(status = TerminalStatus.RUNNING)) @@ -74,6 +79,7 @@ class TerminalScreenInstrumentedTest { onKey = { keys += it }, onRestart = { restarts++ }, onClear = { clears++ }, + onOpenServer = { openedServers += it }, ) } } @@ -115,6 +121,52 @@ class TerminalScreenInstrumentedTest { composeRule.onNodeWithText("server listening on :3000").assertIsDisplayed() } + @Test + fun aRunningServerIsOfferedAndOpensItsAddress() { + show(TerminalUiState(status = TerminalStatus.RUNNING, servers = listOf(DevServer(5173, DevServerStatus.RUNNING)))) + + composeRule.onNodeWithContentDescription(composeRule.activity.getString(R.string.cd_terminal_server_open, 5173)).apply { + assertIsDisplayed() + performClick() + } + + assertEquals(listOf("http://localhost:5173/"), openedServers.toList()) + } + + @Test + fun aStoppedServerIsShownAsStoppedAndHasNothingToOpen() { + show(TerminalUiState(status = TerminalStatus.RUNNING, servers = listOf(DevServer(5173, DevServerStatus.STOPPED)))) + + composeRule.onNodeWithContentDescription(composeRule.activity.getString(R.string.cd_terminal_server_stopped, 5173)).apply { + assertIsDisplayed() + assertHasNoClickAction() + } + composeRule.onNodeWithText(string(R.string.terminal_server_stopped)).assertIsDisplayed() + assertTrue(openedServers.isEmpty()) + } + + @Test + fun severalServersAreEachOffered() { + show( + TerminalUiState( + status = TerminalStatus.RUNNING, + servers = listOf(DevServer(3000, DevServerStatus.RUNNING), DevServer(5173, DevServerStatus.RUNNING)), + ), + ) + + composeRule.onNodeWithTag("terminal-server-5173").performClick() + composeRule.onNodeWithTag("terminal-server-3000").performClick() + + assertEquals(listOf("http://localhost:5173/", "http://localhost:3000/"), openedServers.toList()) + } + + @Test + fun noServersMeansNoServerRow() { + show(TerminalUiState(status = TerminalStatus.RUNNING)) + + composeRule.onNodeWithTag("terminal-server-5173").assertDoesNotExist() + } + @Test fun theOffscreenKeysSendTheirSequences() { show(TerminalUiState(status = TerminalStatus.RUNNING)) diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/terminal/DevServerDetector.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/terminal/DevServerDetector.kt new file mode 100644 index 000000000..aa081e4f3 --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/terminal/DevServerDetector.kt @@ -0,0 +1,41 @@ +package com.yugahashimoto.andcode.feature.terminal + +/** + * Finds the ports of servers a program says it is serving on this device, from what the terminal + * shows. Dev servers announce themselves in a few common ways ("Local: http://localhost:5173/", + * "Serving HTTP on 0.0.0.0 port 8000", "listening on :3000"); that is all this recognises. + * + * What it finds is a *claim* made by program output, or by a command that was typed and echoed - so + * a port found here is only worth showing once it has answered a probe (see [DevServerTracker]). + * Only the port number is taken from the text; the host is never reused, so output cannot point + * the in-app browser anywhere but this device. + */ +object DevServerDetector { + private const val LOCAL_HOST = """(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\])""" + + // A sixth digit, or a dotted quad such as 192.168.1.20, must not be read as a shorter port. + private const val PORT = """(\d{1,5})(?!\d|\.\d|:\d)""" + + private val URL = Regex("""https?://$LOCAL_HOST:$PORT""", RegexOption.IGNORE_CASE) + private val LISTENING = + Regex("""\blisten(?:ing|s)?\s+(?:on|at)\s+(?:port\s+)?(?:(?:$LOCAL_HOST|\*)?:)?$PORT""", RegexOption.IGNORE_CASE) + + private val VALID_PORTS = 1..65535 + + /** Every distinct port mentioned in [lines], in the order it first appears. */ + fun portsIn(lines: List): List { + val found = LinkedHashSet() + for (line in lines) { + if (!mightMentionAServer(line)) continue + (URL.findAll(line) + LISTENING.findAll(line)) + .sortedBy { it.range.first } + .mapNotNull { it.groupValues[1].toIntOrNull() } + .filter { it in VALID_PORTS } + .forEach(found::add) + } + return found.toList() + } + + // Output is scanned after every burst of it, so skip the two regexes for the lines that cannot match. + private fun mightMentionAServer(line: String): Boolean = line.contains("://") || line.contains("isten", ignoreCase = true) +} diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/terminal/DevServerTracker.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/terminal/DevServerTracker.kt new file mode 100644 index 000000000..047e8f4a5 --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/terminal/DevServerTracker.kt @@ -0,0 +1,78 @@ +package com.yugahashimoto.andcode.feature.terminal + +enum class DevServerStatus { + RUNNING, + + /** It answered once and has stopped answering. */ + STOPPED, +} + +/** A server listening on [port] of this device, as far as the last probe could tell. */ +data class DevServer( + val port: Int, + val status: DevServerStatus, +) { + /** Where the in-app browser opens it. `localhost` covers a server bound to IPv4 or to IPv6 only. */ + val url: String get() = "http://localhost:$port/" +} + +/** + * Which of the ports a terminal mentioned really have a server behind them, and whether they still do. + * + * Immutable: every step returns the next tracker. A port starts as a candidate that is probed but not + * shown - a command echoed back such as `curl http://localhost:8080` names a port nothing listens on - + * and becomes a [DevServer] the first time it answers. Probing is the caller's job; this only decides + * what the answers mean. + */ +class DevServerTracker private constructor( + private val entries: List, +) { + constructor() : this(emptyList()) + + private data class Entry( + val port: Int, + val seenUp: Boolean, + /** Probes in a row that got no answer. */ + val misses: Int, + ) + + /** Every port worth probing now: the shown servers and the candidates that have not answered yet. */ + val ports: List get() = entries.map { it.port } + + /** The servers to show, in the order they were first mentioned. */ + val servers: List + get() = + entries + .filter { it.seenUp } + .map { DevServer(it.port, if (it.misses >= STOPPED_AFTER_MISSES) DevServerStatus.STOPPED else DevServerStatus.RUNNING) } + + /** Starts watching ports the terminal mentioned. Programs can print any number of them; extras are ignored. */ + fun observe(mentioned: Collection): DevServerTracker { + var next = entries + for (port in mentioned) { + if (next.size >= MAX_TRACKED) break + if (next.none { it.port == port }) next = next + Entry(port, seenUp = false, misses = 0) + } + return if (next === entries) this else DevServerTracker(next) + } + + /** Applies one round of probe answers: `true` if the port accepted a connection. */ + fun applyProbe(answers: Map): DevServerTracker = + DevServerTracker( + entries.mapNotNull { entry -> + val answered = answers[entry.port] ?: return@mapNotNull entry + val updated = if (answered) entry.copy(seenUp = true, misses = 0) else entry.copy(misses = entry.misses + 1) + updated.takeIf { it.misses < DROP_AFTER_MISSES } + }, + ) + + companion object { + const val MAX_TRACKED = 8 + + /** A slow request can make one probe miss; two in a row is a server that is not there. */ + const val STOPPED_AFTER_MISSES = 2 + + /** At the probe interval this is about half a minute: long enough to see "stopped", short enough to free the slot. */ + const val DROP_AFTER_MISSES = 15 + } +} diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/workspace/TerminalScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/workspace/TerminalScreen.kt index b18bec4cb..ac5cd5072 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/workspace/TerminalScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/workspace/TerminalScreen.kt @@ -1,20 +1,32 @@ package com.yugahashimoto.andcode.feature.workspace import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons @@ -36,9 +48,12 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString @@ -56,6 +71,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.yugahashimoto.andcode.R +import com.yugahashimoto.andcode.feature.terminal.DevServer +import com.yugahashimoto.andcode.feature.terminal.DevServerStatus import com.yugahashimoto.andcode.feature.terminal.StyledLine import com.yugahashimoto.andcode.feature.terminal.TerminalKey import com.yugahashimoto.andcode.feature.terminal.TerminalStyle @@ -78,6 +95,8 @@ fun TerminalScreen( onKey: (TerminalKey) -> Unit, onRestart: () -> Unit, onClear: () -> Unit, + /** Opens a server the shell started (its [DevServer.url]) in the in-app browser. */ + onOpenServer: (url: String) -> Unit = {}, ) { val running = state.status == TerminalStatus.RUNNING // Whoever types, presses a key or clears wants to see what happens next, wherever they had @@ -99,6 +118,7 @@ fun TerminalScreen( onClear() }, ) + TerminalServers(servers = state.servers, onOpen = onOpenServer) TerminalOutput( state = state, followOutput = followOutput, @@ -174,6 +194,58 @@ private fun TerminalToolbar( } } +@Composable +private fun TerminalServers( + servers: List, + onOpen: (url: String) -> Unit, +) { + if (servers.isEmpty()) return + // Scrolls sideways instead of wrapping: a few servers fit, and the output below keeps its height. + Row( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = OutputPadding, vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + servers.forEach { server -> TerminalServerChip(server = server, onOpen = { onOpen(server.url) }) } + } +} + +@Composable +private fun TerminalServerChip( + server: DevServer, + onOpen: () -> Unit, +) { + val running = server.status == DevServerStatus.RUNNING + val color = if (running) AccentColor else Color.White.copy(alpha = 0.5f) + val description = + stringResource(if (running) R.string.cd_terminal_server_open else R.string.cd_terminal_server_stopped, server.port) + val shape = RoundedCornerShape(16.dp) + Row( + modifier = + Modifier + .testTag("terminal-server-${server.port}") + .heightIn(min = 40.dp) + .clip(shape) + .border(1.dp, color, shape) + // Only a server that answers has a page to open; a stopped one is just a status. + .then(if (running) Modifier.clickable(role = Role.Button, onClick = onOpen) else Modifier) + .semantics(mergeDescendants = true) { contentDescription = description } + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.size(8.dp).background(color, CircleShape)) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = ":${server.port}", color = color, style = OutputTextStyle) + if (!running) { + Spacer(modifier = Modifier.width(8.dp)) + Text(text = stringResource(R.string.terminal_server_stopped), color = color, style = OutputTextStyle) + } + } +} + @Composable private fun TerminalOutput( state: TerminalUiState, diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/workspace/TerminalViewModel.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/workspace/TerminalViewModel.kt index 6cf577224..78e0882e3 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/workspace/TerminalViewModel.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/workspace/TerminalViewModel.kt @@ -5,9 +5,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.yugahashimoto.andcode.core.runtime.RuntimeWorkTracker import com.yugahashimoto.andcode.feature.terminal.AnsiTerminalEmulator +import com.yugahashimoto.andcode.feature.terminal.DevServer +import com.yugahashimoto.andcode.feature.terminal.DevServerDetector +import com.yugahashimoto.andcode.feature.terminal.DevServerTracker import com.yugahashimoto.andcode.feature.terminal.StyledLine import com.yugahashimoto.andcode.feature.terminal.TerminalKey +import com.yugahashimoto.andcode.runtime.local.DevServerProbe import com.yugahashimoto.andcode.runtime.local.LocalTerminalLauncher +import com.yugahashimoto.andcode.runtime.local.LoopbackDevServerProbe import com.yugahashimoto.andcode.runtime.local.TerminalOpenRequest import com.yugahashimoto.andcode.runtime.local.TerminalSession import com.yugahashimoto.andcode.runtime.local.TerminalSessionFactory @@ -16,6 +21,10 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -23,6 +32,8 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.Collections +import java.util.IdentityHashMap enum class TerminalStatus { /** Waiting for the screen to say how large it is. */ @@ -47,11 +58,17 @@ data class TerminalUiState( val failure: TerminalFailure? = null, val failureDetail: String? = null, val currentInput: String = "", + /** Servers the shell has started that answer on this device; empty once the shell ends. */ + val servers: List = emptyList(), ) /** * Owns one interactive shell for as long as the terminal screen is open. Leaving the screen ends * the shell and anything it started - a dev server does not outlive the screen that launched it. + * + * It also keeps an eye on the servers the shell starts: ports the output announces are probed, and + * the ones that answer are published as [TerminalUiState.servers] for the screen to open in the + * in-app browser. */ class TerminalViewModel( private val sessions: TerminalSessionFactory, @@ -64,6 +81,7 @@ class TerminalViewModel( private val runtimeWork: RuntimeWorkTracker, /** A directory in the sandbox to start in; null starts in the sandbox's home. */ private val startDirectory: String? = null, + private val serverProbe: DevServerProbe = LoopbackDevServerProbe(), private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) : ViewModel() { private val _state = MutableStateFlow(TerminalUiState()) @@ -75,6 +93,14 @@ class TerminalViewModel( private var session: TerminalSession? = null private var closed = false private var sessionJob: Job? = null + + /** Guarded by [lock], like the fields that follow: what the shell announced, and what answered. */ + private var tracker = DevServerTracker() + private var probing = false + private var probeJob: Job? = null + + /** The screen lines already searched for addresses, compared by identity (see [freshTextOf]). */ + private var scannedLines: Set = identitySetOf(emptyList()) private var columns = 0 private var rows = 0 @@ -146,6 +172,9 @@ class TerminalViewModel( } finally { opened.close() synchronized(lock) { if (session === opened) session = null } + // Everything the shell started died with it; leaving its servers listed would offer links + // to pages that are gone. + endServerWatch() } } @@ -176,20 +205,109 @@ class TerminalViewModel( * bring the cleared text back). */ private fun publish(change: AnsiTerminalEmulator.() -> Unit) { + val startProbing = + synchronized(lock) { + val terminal = emulator ?: return + val snapshot = + try { + terminal.change() + terminal.snapshot() + } catch (e: RuntimeException) { + // The stream is untrusted bytes; whatever it does, it must not take the app down. + Log.w(TAG, "Terminal output could not be interpreted", e) + return + } + _state.update { + it.copy(lines = snapshot.lines, cursorRow = snapshot.cursorRow, cursorColumn = snapshot.cursorColumn) + } + watchForServers(snapshot.lines) + } + if (startProbing) launchProbe() + } + + /** + * Looks for servers in the lines that appeared since the last call. Returns whether probing has to + * be started for them. Called with [lock] held. + */ + private fun watchForServers(lines: List): Boolean { + val mentioned = DevServerDetector.portsIn(freshTextOf(lines)) + if (mentioned.isNotEmpty()) tracker = tracker.observe(mentioned) + val start = !probing && !closed && tracker.ports.isNotEmpty() + if (start) probing = true + return start + } + + /** + * The text of the screen lines that were not there, or not like that, the last time this ran. + * + * Only the end of the screen is searched, and only what changed in it: an address printed long ago + * and still on screen must not bring back a server that has since been forgotten. The emulator + * hands out the same [StyledLine] for a row until the row changes, so comparing by identity finds + * exactly the new and rewritten rows - a reprinted line is new even if its text is not. + * Called with [lock] held. + */ + private fun freshTextOf(lines: List): List { + val tail = lines.takeLast(SCAN_TAIL_LINES) + val seen = scannedLines + scannedLines = identitySetOf(tail) + return tail.filter { it !in seen }.map { it.text } + } + + private fun launchProbe() { + val job = viewModelScope.launch { probeServers() } synchronized(lock) { - val terminal = emulator ?: return - val snapshot = - try { - terminal.change() - terminal.snapshot() - } catch (e: RuntimeException) { - // The stream is untrusted bytes; whatever it does, it must not take the app down. - Log.w(TAG, "Terminal output could not be interpreted", e) - return + probeJob = job + if (closed) job.cancel() + } + } + + /** + * Asks every port the shell announced whether it answers, now and then every + * [SERVER_PROBE_INTERVAL_MILLIS], for as long as there is a port worth asking about. Output that + * names no server never gets here, so a plain shell costs nothing. + */ + private suspend fun probeServers() { + while (true) { + val ports = + synchronized(lock) { + val tracked = tracker.ports + // Deciding to stop and saying so happen under one lock, so a port announced in + // between starts a new loop instead of waiting on one that is already ending. + if (closed || tracked.isEmpty()) { + probing = false + return + } + tracked + } + val answers = + coroutineScope { + ports.map { port -> async(ioDispatcher) { port to isAnswering(port) } }.awaitAll().toMap() } - _state.update { - it.copy(lines = snapshot.lines, cursorRow = snapshot.cursorRow, cursorColumn = snapshot.cursorColumn) + synchronized(lock) { + tracker = tracker.applyProbe(answers) + _state.update { it.copy(servers = tracker.servers) } } + delay(SERVER_PROBE_INTERVAL_MILLIS) + } + } + + private suspend fun isAnswering(port: Int): Boolean = + try { + serverProbe.isListening(port) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + Log.w(TAG, "Could not probe port $port", e) + false + } + + private fun endServerWatch() { + synchronized(lock) { + tracker = DevServerTracker() + probing = false + probeJob?.cancel() + probeJob = null + _state.update { it.copy(servers = emptyList()) } } } @@ -233,6 +351,7 @@ class TerminalViewModel( val ending = synchronized(lock) { closed = true + probeJob?.cancel() session } ending?.close() @@ -243,9 +362,17 @@ class TerminalViewModel( close() } - private companion object { - const val TAG = "TerminalViewModel" - const val TERMINAL_LEASE_TAG = "terminal" - const val ENTER = "\r" + companion object { + /** How often the servers the shell announced are asked whether they still answer. */ + internal const val SERVER_PROBE_INTERVAL_MILLIS = 2_000L + + /** How many of the newest screen lines are searched for server addresses after each burst of output. */ + private const val SCAN_TAIL_LINES = 200 + private const val TAG = "TerminalViewModel" + private const val TERMINAL_LEASE_TAG = "terminal" + private const val ENTER = "\r" } } + +private fun identitySetOf(lines: Collection): Set = + Collections.newSetFromMap(IdentityHashMap()).apply { addAll(lines) } diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/DevServerProbe.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/DevServerProbe.kt new file mode 100644 index 000000000..bd45541a6 --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/DevServerProbe.kt @@ -0,0 +1,42 @@ +package com.yugahashimoto.andcode.runtime.local + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException +import java.net.InetSocketAddress +import java.net.Socket + +/** Tells whether something on this device is accepting connections on a port. */ +fun interface DevServerProbe { + suspend fun isListening(port: Int): Boolean +} + +/** + * Connects to the port on the loopback addresses and hangs up. The guest's processes share the app's + * network, so a server started in the terminal is reachable here without any permission - unlike + * `/proc/net/tcp`, which apps cannot read since Android 10. + * + * Both address families are tried because a server that binds `localhost` may get either, depending on + * how the guest resolves the name. + */ +class LoopbackDevServerProbe( + private val timeoutMillis: Int = DEFAULT_TIMEOUT_MILLIS, +) : DevServerProbe { + override suspend fun isListening(port: Int): Boolean = withContext(Dispatchers.IO) { LOOPBACK_ADDRESSES.any { connects(it, port) } } + + private fun connects( + address: String, + port: Int, + ): Boolean = + try { + Socket().use { it.connect(InetSocketAddress(address, port), timeoutMillis) } + true + } catch (_: IOException) { + false + } + + private companion object { + const val DEFAULT_TIMEOUT_MILLIS = 300 + val LOOPBACK_ADDRESSES = listOf("127.0.0.1", "::1") + } +} diff --git a/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/WorkspaceNavGraph.kt b/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/WorkspaceNavGraph.kt index f13a147c2..c7c168e08 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/WorkspaceNavGraph.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/ui/navigation/WorkspaceNavGraph.kt @@ -202,6 +202,7 @@ fun NavGraphBuilder.workspaceNavGraph( onKey = terminalViewModel::sendKey, onRestart = terminalViewModel::restart, onClear = terminalViewModel::clear, + onOpenServer = { url -> navController.navigate(guestBrowserRoute(url)) }, ) } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index f4a773820..38689c6f0 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -834,6 +834,9 @@ سهم لأسفل سهم لليسار سهم لليمين + متوقف + فتح التطبيق الذي يعمل على المنفذ %1$d + المنفذ %1$d متوقف Agent diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index d0f6497bb..427d1f990 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -825,6 +825,9 @@ Flecha abajo Flecha izquierda Flecha derecha + detenido + Abrir la aplicación que se ejecuta en el puerto %1$d + El puerto %1$d está detenido Agent diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 020b8826f..cbd9bac45 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -825,6 +825,9 @@ Flèche bas Flèche gauche Flèche droite + arrêté + Ouvrir l’application qui tourne sur le port %1$d + Le port %1$d est arrêté Agent diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 1dce4fd55..8931d058c 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -831,6 +831,9 @@ 下矢印 左矢印 右矢印 + 停止 + ポート %1$d で動いているアプリを開く + ポート %1$d は停止しています エージェント diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 399cac600..a5b402fee 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -825,6 +825,9 @@ Seta para baixo Seta para a esquerda Seta para a direita + parado + Abrir o app em execução na porta %1$d + A porta %1$d está parada Agent diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 94d9c7ce4..d0e70387d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -828,6 +828,9 @@ Стрелка вниз Стрелка влево Стрелка вправо + остановлен + Открыть приложение, запущенное на порту %1$d + Порт %1$d остановлен Agent diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d13c93d1f..229c827bb 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -819,6 +819,9 @@ 下箭头 左箭头 右箭头 + 已停止 + 打开运行在端口 %1$d 上的应用 + 端口 %1$d 已停止 Agent diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f0572a8cb..46d822f6b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -822,6 +822,9 @@ Down arrow Left arrow Right arrow + stopped + Open the app running on port %1$d + Port %1$d is stopped Agent diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/terminal/DevServerDetectorTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/terminal/DevServerDetectorTest.kt new file mode 100644 index 000000000..3eeb81deb --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/terminal/DevServerDetectorTest.kt @@ -0,0 +1,73 @@ +package com.yugahashimoto.andcode.feature.terminal + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DevServerDetectorTest { + private fun portsIn(vararg lines: String): List = DevServerDetector.portsIn(lines.toList()) + + @Test + fun `finds the port in a Vite banner`() { + assertEquals(listOf(5173), portsIn(" ➜ Local: http://localhost:5173/")) + } + + @Test + fun `finds loopback and any-address urls`() { + assertEquals(listOf(3000), portsIn("ready - started server on http://127.0.0.1:3000")) + assertEquals(listOf(8000), portsIn("Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...")) + assertEquals(listOf(8000), portsIn("Serving HTTP on :: port 8000 (http://[::]:8000/) ...")) + assertEquals(listOf(4000), portsIn("Listening on http://[::1]:4000")) + } + + @Test + fun `finds a listening message with or without a host`() { + assertEquals(listOf(3000), portsIn("Server listening on port 3000")) + assertEquals(listOf(8080), portsIn("Listening on :8080")) + assertEquals(listOf(8080), portsIn("listening at 0.0.0.0:8080")) + assertEquals(listOf(9000), portsIn("App is listening on localhost:9000")) + } + + @Test + fun `ignores addresses that are not on this device`() { + assertTrue(portsIn(" ➜ Network: http://192.168.1.20:5173/").isEmpty()) + assertTrue(portsIn("see https://example.com:8443/docs").isEmpty()) + assertTrue(portsIn("listening on 192.168.1.20:3000").isEmpty()) + } + + @Test + fun `ignores ports that cannot exist`() { + assertTrue(portsIn("http://localhost:0/").isEmpty()) + assertTrue(portsIn("http://localhost:65536/").isEmpty()) + assertTrue(portsIn("http://localhost:99999999999/").isEmpty()) + } + + @Test + fun `ignores text with no address`() { + assertTrue(portsIn("", "$ npm run dev", "compiled successfully in 412 ms", "port 3000 is a number").isEmpty()) + } + + @Test + fun `lists each port once in the order it first appeared`() { + val found = + portsIn( + "Local: http://localhost:5173/", + "API: http://localhost:3000/", + "Local: http://localhost:5173/", + "listening on port 3000", + ) + + assertEquals(listOf(5173, 3000), found) + } + + @Test + fun `finds several addresses on one line`() { + assertEquals(listOf(3000, 3001), portsIn("proxy http://localhost:3000 -> http://localhost:3001")) + } + + @Test + fun `is not case sensitive`() { + assertEquals(listOf(3000), portsIn("HTTP://LOCALHOST:3000/")) + assertEquals(listOf(3000), portsIn("LISTENING ON PORT 3000")) + } +} diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/terminal/DevServerTrackerTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/terminal/DevServerTrackerTest.kt new file mode 100644 index 000000000..ff49b9e0e --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/terminal/DevServerTrackerTest.kt @@ -0,0 +1,117 @@ +package com.yugahashimoto.andcode.feature.terminal + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DevServerTrackerTest { + private fun DevServerTracker.up(vararg ports: Int) = applyProbe(ports.associateWith { true }) + + private fun DevServerTracker.down(vararg ports: Int) = applyProbe(ports.associateWith { false }) + + @Test + fun `a port that was only mentioned is probed but not shown`() { + val tracker = DevServerTracker().observe(listOf(8080)) + + assertEquals(listOf(8080), tracker.ports) + assertTrue(tracker.servers.isEmpty()) + } + + @Test + fun `a port is shown as running once it answers`() { + val tracker = DevServerTracker().observe(listOf(5173)).up(5173) + + assertEquals(listOf(DevServer(5173, DevServerStatus.RUNNING)), tracker.servers) + } + + @Test + fun `one missed probe does not stop a running server`() { + val tracker = DevServerTracker().observe(listOf(5173)).up(5173).down(5173) + + assertEquals(DevServerStatus.RUNNING, tracker.servers.single().status) + } + + @Test + fun `two missed probes in a row mark it stopped`() { + val tracker = DevServerTracker().observe(listOf(5173)).up(5173).down(5173).down(5173) + + assertEquals(DevServerStatus.STOPPED, tracker.servers.single().status) + } + + @Test + fun `a stopped server runs again when it answers again`() { + val tracker = DevServerTracker().observe(listOf(5173)).up(5173).down(5173).down(5173).up(5173) + + assertEquals(DevServerStatus.RUNNING, tracker.servers.single().status) + } + + @Test + fun `a port that never answered is forgotten after a while`() { + var tracker = DevServerTracker().observe(listOf(8080)) + repeat(DevServerTracker.DROP_AFTER_MISSES - 1) { tracker = tracker.down(8080) } + assertEquals(listOf(8080), tracker.ports) + + tracker = tracker.down(8080) + + assertTrue(tracker.ports.isEmpty()) + } + + @Test + fun `a stopped server disappears after a longer while`() { + var tracker = DevServerTracker().observe(listOf(5173)).up(5173) + repeat(DevServerTracker.DROP_AFTER_MISSES) { tracker = tracker.down(5173) } + + assertTrue(tracker.ports.isEmpty()) + assertTrue(tracker.servers.isEmpty()) + } + + @Test + fun `mentioning a known port again changes nothing`() { + val before = DevServerTracker().observe(listOf(5173)).up(5173) + + val after = before.observe(listOf(5173)) + + assertEquals(before.servers, after.servers) + assertEquals(before.ports, after.ports) + } + + @Test + fun `servers keep the order in which they were first seen`() { + val tracker = DevServerTracker().observe(listOf(3000, 5173)).up(5173, 3000) + + assertEquals(listOf(3000, 5173), tracker.servers.map { it.port }) + } + + @Test + fun `tracks no more than the limit and ignores the rest`() { + val many = (1..DevServerTracker.MAX_TRACKED + 5).map { 3000 + it } + + val tracker = DevServerTracker().observe(many) + + assertEquals(many.take(DevServerTracker.MAX_TRACKED), tracker.ports) + } + + @Test + fun `a freed slot can be taken by a new port`() { + var tracker = DevServerTracker().observe((1..DevServerTracker.MAX_TRACKED).map { 3000 + it }) + repeat(DevServerTracker.DROP_AFTER_MISSES) { tracker = tracker.down(3001) } + + tracker = tracker.observe(listOf(9000)) + + assertTrue(9000 in tracker.ports) + assertTrue(3001 !in tracker.ports) + } + + @Test + fun `a probe result for a port that is not tracked is ignored`() { + val tracker = DevServerTracker().up(1234) + + assertTrue(tracker.ports.isEmpty()) + assertTrue(tracker.servers.isEmpty()) + } + + @Test + fun `the server url opens the port on this device`() { + assertEquals("http://localhost:5173/", DevServer(5173, DevServerStatus.RUNNING).url) + } +} diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/workspace/TerminalViewModelTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/workspace/TerminalViewModelTest.kt index 6ae26c6f0..1ffaa6bc1 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/feature/workspace/TerminalViewModelTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/workspace/TerminalViewModelTest.kt @@ -1,7 +1,11 @@ package com.yugahashimoto.andcode.feature.workspace import com.yugahashimoto.andcode.core.runtime.RuntimeWorkTracker +import com.yugahashimoto.andcode.feature.terminal.DevServer +import com.yugahashimoto.andcode.feature.terminal.DevServerStatus +import com.yugahashimoto.andcode.feature.terminal.DevServerTracker import com.yugahashimoto.andcode.feature.terminal.TerminalKey +import com.yugahashimoto.andcode.runtime.local.DevServerProbe import com.yugahashimoto.andcode.runtime.local.LocalTerminalLauncher import com.yugahashimoto.andcode.runtime.local.TerminalOpenRequest import com.yugahashimoto.andcode.runtime.local.TerminalSession @@ -15,6 +19,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -84,14 +90,32 @@ class TerminalViewModelTest { } } + /** Answers for the ports in [listening]; remembers every port it was asked about. */ + private class FakeProbe : DevServerProbe { + val listening = mutableSetOf() + val asked = mutableListOf() + + override suspend fun isListening(port: Int): Boolean { + asked += port + return port in listening + } + } + private fun viewModel( factory: FakeFactory = FakeFactory(), work: RuntimeWorkTracker = RuntimeWorkTracker(), startDirectory: String? = "/workspace/app", - ): TerminalViewModel = TerminalViewModel(factory, work, startDirectory, ioDispatcher = dispatcher) + probe: DevServerProbe = FakeProbe(), + ): TerminalViewModel = TerminalViewModel(factory, work, startDirectory, probe, ioDispatcher = dispatcher) private fun TerminalViewModel.screenText(): List = state.value.lines.map { it.text } + /** Lets one probe interval pass, so the next round of probes has run. */ + private fun TestScope.nextProbeRound() { + advanceTimeBy(TerminalViewModel.SERVER_PROBE_INTERVAL_MILLIS) + runCurrent() + } + @Test fun `waits for the screen to report its size before starting`() = runTest(dispatcher) { @@ -467,4 +491,255 @@ class TerminalViewModelTest { runCurrent() assertEquals(listOf("after"), viewModel.screenText()) } + + @Test + fun `shows a server once the port it announced answers`() = + runTest(dispatcher) { + val probe = FakeProbe().apply { listening += 5173 } + val factory = FakeFactory() + val viewModel = viewModel(factory, probe = probe) + viewModel.open(60, 24) + runCurrent() + + factory.sessions.single().emit(" Local: http://localhost:5173/\r\n") + runCurrent() + + assertEquals(listOf(DevServer(5173, DevServerStatus.RUNNING)), viewModel.state.value.servers) + viewModel.close() + } + + @Test + fun `does not show a port that was only typed and echoed`() = + runTest(dispatcher) { + val probe = FakeProbe() + val factory = FakeFactory() + val viewModel = viewModel(factory, probe = probe) + viewModel.open(60, 24) + runCurrent() + + factory.sessions.single().emit("$ curl http://localhost:8080/health\r\n") + runCurrent() + + assertTrue(8080 in probe.asked) + assertTrue(viewModel.state.value.servers.isEmpty()) + viewModel.close() + } + + @Test + fun `finds an address even when the colour codes split it`() = + runTest(dispatcher) { + val probe = FakeProbe().apply { listening += 5173 } + val factory = FakeFactory() + val viewModel = viewModel(factory, probe = probe) + viewModel.open(60, 24) + runCurrent() + + // Vite bolds the port number inside the URL. + factory.sessions.single().emit("Local: \u001b[36mhttp://localhost:\u001b[1m5173\u001b[22m/\u001b[39m\r\n") + runCurrent() + + assertEquals(listOf(5173), viewModel.state.value.servers.map { it.port }) + viewModel.close() + } + + @Test + fun `finds an address that arrives in two chunks`() = + runTest(dispatcher) { + val probe = FakeProbe().apply { listening += 3000 } + val factory = FakeFactory() + val viewModel = viewModel(factory, probe = probe) + viewModel.open(60, 24) + runCurrent() + + factory.sessions.single().emit("ready on http://local") + runCurrent() + factory.sessions.single().emit("host:3000\r\n") + runCurrent() + + assertEquals(listOf(3000), viewModel.state.value.servers.map { it.port }) + viewModel.close() + } + + @Test + fun `lists every server the terminal announced`() = + runTest(dispatcher) { + val probe = FakeProbe().apply { listening += setOf(3000, 5173) } + val factory = FakeFactory() + val viewModel = viewModel(factory, probe = probe) + viewModel.open(60, 24) + runCurrent() + + factory.sessions.single().emit("api http://localhost:3000\r\nweb http://localhost:5173\r\n") + runCurrent() + + assertEquals(listOf(3000, 5173), viewModel.state.value.servers.map { it.port }) + viewModel.close() + } + + @Test + fun `marks a server stopped when it stops answering and running again when it comes back`() = + runTest(dispatcher) { + val probe = FakeProbe().apply { listening += 5173 } + val factory = FakeFactory() + val viewModel = viewModel(factory, probe = probe) + viewModel.open(60, 24) + runCurrent() + factory.sessions.single().emit("http://localhost:5173/\r\n") + runCurrent() + + probe.listening.clear() + nextProbeRound() + assertEquals(DevServerStatus.RUNNING, viewModel.state.value.servers.single().status) + nextProbeRound() + assertEquals(DevServerStatus.STOPPED, viewModel.state.value.servers.single().status) + + probe.listening += 5173 + nextProbeRound() + assertEquals(DevServerStatus.RUNNING, viewModel.state.value.servers.single().status) + viewModel.close() + } + + @Test + fun `forgets a server that stays down and then stops probing altogether`() = + runTest(dispatcher) { + val probe = FakeProbe().apply { listening += 5173 } + val factory = FakeFactory() + val viewModel = viewModel(factory, probe = probe) + viewModel.open(60, 24) + runCurrent() + factory.sessions.single().emit("http://localhost:5173/\r\n") + runCurrent() + + probe.listening.clear() + repeat(DevServerTracker.DROP_AFTER_MISSES) { nextProbeRound() } + assertTrue(viewModel.state.value.servers.isEmpty()) + + val askedSoFar = probe.asked.size + repeat(3) { nextProbeRound() } + assertEquals(askedSoFar, probe.asked.size) + viewModel.close() + } + + @Test + fun `probes again when a forgotten server is announced again`() = + runTest(dispatcher) { + val probe = FakeProbe().apply { listening += 5173 } + val factory = FakeFactory() + val viewModel = viewModel(factory, probe = probe) + viewModel.open(60, 24) + runCurrent() + factory.sessions.single().emit("http://localhost:5173/\r\n") + runCurrent() + probe.listening.clear() + repeat(DevServerTracker.DROP_AFTER_MISSES) { nextProbeRound() } + + probe.listening += 5173 + factory.sessions.single().emit("restarted: http://localhost:5173/\r\n") + runCurrent() + // The loop that gave up is still waiting out its last interval; it picks the port up on waking. + nextProbeRound() + + assertEquals(DevServerStatus.RUNNING, viewModel.state.value.servers.single().status) + viewModel.close() + } + + @Test + fun `an old announcement still on screen does not bring a forgotten server back`() = + runTest(dispatcher) { + val probe = FakeProbe().apply { listening += 5173 } + val factory = FakeFactory() + val viewModel = viewModel(factory, probe = probe) + viewModel.open(60, 24) + runCurrent() + factory.sessions.single().emit("http://localhost:5173/\r\n") + runCurrent() + probe.listening.clear() + repeat(DevServerTracker.DROP_AFTER_MISSES) { nextProbeRound() } + val askedSoFar = probe.asked.size + + factory.sessions.single().emit("GET / 200\r\nGET /app.js 200\r\n") + runCurrent() + repeat(3) { nextProbeRound() } + + assertEquals(askedSoFar, probe.asked.size) + viewModel.close() + } + + @Test + fun `never probes while the output names no server`() = + runTest(dispatcher) { + val probe = FakeProbe() + val factory = FakeFactory() + val viewModel = viewModel(factory, probe = probe) + viewModel.open(60, 24) + runCurrent() + + factory.sessions.single().emit("compiled successfully\r\n") + runCurrent() + repeat(5) { nextProbeRound() } + + assertTrue(probe.asked.isEmpty()) + viewModel.close() + } + + @Test + fun `the servers go away with the shell that started them`() = + runTest(dispatcher) { + val probe = FakeProbe().apply { listening += 5173 } + val factory = FakeFactory() + val viewModel = viewModel(factory, probe = probe) + viewModel.open(60, 24) + runCurrent() + factory.sessions.single().emit("http://localhost:5173/\r\n") + runCurrent() + + factory.sessions.single().finish(0) + runCurrent() + + assertEquals(TerminalStatus.EXITED, viewModel.state.value.status) + assertTrue(viewModel.state.value.servers.isEmpty()) + val askedSoFar = probe.asked.size + repeat(3) { nextProbeRound() } + assertEquals(askedSoFar, probe.asked.size) + } + + @Test + fun `closing the screen stops probing`() = + runTest(dispatcher) { + val probe = FakeProbe().apply { listening += 5173 } + val factory = FakeFactory() + val viewModel = viewModel(factory, probe = probe) + viewModel.open(60, 24) + runCurrent() + factory.sessions.single().emit("http://localhost:5173/\r\n") + runCurrent() + + viewModel.close() + runCurrent() + + val askedSoFar = probe.asked.size + repeat(3) { nextProbeRound() } + assertEquals(askedSoFar, probe.asked.size) + } + + @Test + fun `a restarted shell starts with no servers`() = + runTest(dispatcher) { + val probe = FakeProbe().apply { listening += 5173 } + val factory = FakeFactory() + val viewModel = viewModel(factory, probe = probe) + viewModel.open(60, 24) + runCurrent() + factory.sessions.single().emit("http://localhost:5173/\r\n") + runCurrent() + factory.sessions.single().finish(0) + runCurrent() + + viewModel.restart() + runCurrent() + + assertEquals(TerminalStatus.RUNNING, viewModel.state.value.status) + assertTrue(viewModel.state.value.servers.isEmpty()) + viewModel.close() + } } diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/LoopbackDevServerProbeTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/LoopbackDevServerProbeTest.kt new file mode 100644 index 000000000..581065dcf --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/LoopbackDevServerProbeTest.kt @@ -0,0 +1,36 @@ +package com.yugahashimoto.andcode.runtime.local + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.InetAddress +import java.net.ServerSocket + +class LoopbackDevServerProbeTest { + private val probe = LoopbackDevServerProbe(timeoutMillis = 500) + + @Test + fun `sees a server listening on the loopback address`() = + runTest { + ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")).use { server -> + assertTrue(probe.isListening(server.localPort)) + } + } + + @Test + fun `sees a server that listens on every address`() = + runTest { + ServerSocket(0).use { server -> + assertTrue(probe.isListening(server.localPort)) + } + } + + @Test + fun `finds nothing once the server is gone`() = + runTest { + val port = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")).use { it.localPort } + + assertFalse(probe.isListening(port)) + } +} diff --git a/docs/LOCAL_RUNTIME.md b/docs/LOCAL_RUNTIME.md index ed80e5841..65552b148 100644 --- a/docs/LOCAL_RUNTIME.md +++ b/docs/LOCAL_RUNTIME.md @@ -231,6 +231,10 @@ OpenCodeがOAuthの `auto` 方式を返した場合、Androidはブラウザを - **開始ディレクトリ**: 開いたワークスペースのパス(サンドボックス内の絶対パス)。`cd` できなければ `/root`。ワークスペース用の `/workspace` もバインドされる - **出力**: ストリーミングでANSI解釈し(`AnsiTerminalEmulator`)、SGRの色(16色・256色・トゥルーカラー)、`\r` による行の上書き、行内編集、画面クリアを扱う。スクロールバックは最大10,000行で、古い行から捨てる - **入力**: 入力欄の内容をEnter付きで送る。ソフトキーボードにない `Esc` / `Tab` / `Ctrl+C` / `Ctrl+D` / 矢印キーは入力欄の上のキー列から送る。Tab と矢印キーはシェルの行編集に作用するので、入力欄に残っている文字を先にシェルへ送ってから送る(シェルは入力欄の文字をまだ知らないため)。出力の追従は、指でスクロールして末尾から離れると止まり、末尾へ戻すか、送信・キー送信・クリアをすると再開する。マウスホイールやTalkBackのスクロールは追従の解除として扱われない +- **開発サーバーの検出**: 出力に出た `http://localhost:5173/` 形式のURL(`localhost` / `127.0.0.1` / `0.0.0.0` / `[::1]`)や `listening on port 3000` のような行から、**ポート番号だけ**を拾う(出力のホスト名は使わないので、出力でアプリ内ブラウザの行き先を変えられない)。拾っただけのポートは出さない: 入力したコマンドのエコーにも `curl http://localhost:8080` のようなURLが含まれるため、`127.0.0.1` か `::1` へのTCP接続が実際に通ったものだけを、ターミナル上部のチップ(`:5173`)として表示する。押すとアプリ内ブラウザで `http://localhost:PORT/` を開く(`DevServerDetector` / `DevServerTracker` / `LoopbackDevServerProbe`)。`/proc/net/tcp` はAndroid 10以降アプリから読めないので、接続確認で判断する + - 確認は2秒ごと。連続2回失敗すると「停止」表示(押せない)になり、約30秒応答がなければチップを消す。出力にサーバーの記述が現れるまで確認は始めず、記述がなければ何も動かない。同時に追うのは8ポートまで + - 調べるのは画面末尾200行のうち、前回から新しく書かれた・書き換わった行だけ。一度に200行以上流れる出力の手前にあるURLは見落とす。画面に残っている古いURLが、忘れたサーバーを呼び戻すことはない + - シェルが終わるとチップも消える。エージェントなど、ターミナル以外から起動したサーバーはここには出ない(アプリ内ブラウザのURL欄に直接入力する) - **ライフサイクル**: 画面を閉じるとシェルと配下のプロセス(開発サーバーなど)を終了する。PRoot本体だけをkillしてもゲスト側の `script` / `bash` は残る(`--kill-on-exit` はPRoot自身が終了するときにしか効かない。`ProcessTree.kt` の実機検証メモ参照)ため、`/proc` から子孫を辿って末端から順にkillする(`killProcessTree`)。画面を閉じるのはUIスレッドなので、このkillはバックグラウンドスレッドで行う - **電力**: シェルが生きている間は、ランタイムの作業リースを保持し、端末がサスペンドしないようにする。バックグラウンドでプロンプトに居るだけでも保持し続けるので、使い終わったら画面を閉じる。ランタイムの排他ロックはプロセス起動の間だけ取り、シェルの常駐中は取らない(更新が閉じるまで待たされないため) - **ランタイムの停止・再起動との関係**: OpenCodeランタイムの停止・再起動・クラッシュ後の再起動は、ランタイムディレクトリを含むPRootプロセスをまとめてkillする(`LocalRuntimeProcessLauncher`)ので、開いているターミナルのシェルも終了する。その場合は「セッションが終了しました」と再起動ボタンが出る。ランタイムの更新・削除とターミナルの調停は今のところない From 36830800b97978899f2da8648a609b6e732dc03d Mon Sep 17 00:00:00 2001 From: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:38:53 +0900 Subject: [PATCH 2/3] fix(terminal): find addresses across wrapped rows and keep slots for real servers Apply the pre-PR review's suggestions. The terminal stores a line wider than the screen as several rows, so an address crossing the wrap point was whole in none of them: python's "Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/)" was missed on a narrow phone, and a cut-off "80" could be read as a port. Rows that fill the width are now joined to the next one before searching, and the trailing digits of a line that may still be growing are held back until it ends. When the slots are full, a new port now pushes out the oldest candidate that never answered (echoed commands, cut-off fragments) instead of being dropped; a port that has answered is never pushed out. Also: a failure while searching for servers no longer takes the output stream down; the probe loop is created inside the lock that decides to start it, so a close or an exit that comes in between cancels it before it runs; the chip's touch target is 48dp and its port number is always left-to-right; and onOpenServer is a required parameter. Refs #402 Co-Authored-By: Claude Sonnet 5.5 --- .../feature/terminal/DevServerTracker.kt | 16 +++- .../andcode/feature/terminal/WrappedLines.kt | 40 ++++++++++ .../feature/workspace/TerminalScreen.kt | 11 ++- .../feature/workspace/TerminalViewModel.kt | 45 +++++------ .../feature/terminal/DevServerTrackerTest.kt | 41 +++++++++- .../feature/terminal/WrappedLinesTest.kt | 75 +++++++++++++++++++ .../workspace/TerminalViewModelTest.kt | 57 ++++++++++++++ docs/LOCAL_RUNTIME.md | 3 +- 8 files changed, 257 insertions(+), 31 deletions(-) create mode 100644 app/src/main/java/com/yugahashimoto/andcode/feature/terminal/WrappedLines.kt create mode 100644 app/src/test/java/com/yugahashimoto/andcode/feature/terminal/WrappedLinesTest.kt diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/terminal/DevServerTracker.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/terminal/DevServerTracker.kt index 047e8f4a5..dcba200b2 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/terminal/DevServerTracker.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/terminal/DevServerTracker.kt @@ -46,12 +46,22 @@ class DevServerTracker private constructor( .filter { it.seenUp } .map { DevServer(it.port, if (it.misses >= STOPPED_AFTER_MISSES) DevServerStatus.STOPPED else DevServerStatus.RUNNING) } - /** Starts watching ports the terminal mentioned. Programs can print any number of them; extras are ignored. */ + /** + * Starts watching ports the terminal mentioned. Programs can print any number of them, so when the + * slots are full a new port takes the place of the oldest one that never answered - that is where + * echoed commands and cut-off fragments end up. A port that has answered is never pushed out; if + * every slot holds one, the new port is ignored. + */ fun observe(mentioned: Collection): DevServerTracker { var next = entries for (port in mentioned) { - if (next.size >= MAX_TRACKED) break - if (next.none { it.port == port }) next = next + Entry(port, seenUp = false, misses = 0) + if (next.any { it.port == port }) continue + if (next.size >= MAX_TRACKED) { + val evicted = next.indexOfFirst { !it.seenUp } + if (evicted < 0) continue + next = next.filterIndexed { index, _ -> index != evicted } + } + next = next + Entry(port, seenUp = false, misses = 0) } return if (next === entries) this else DevServerTracker(next) } diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/terminal/WrappedLines.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/terminal/WrappedLines.kt new file mode 100644 index 000000000..25f34c481 --- /dev/null +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/terminal/WrappedLines.kt @@ -0,0 +1,40 @@ +package com.yugahashimoto.andcode.feature.terminal + +/** + * Puts back together the lines the terminal wrapped. + * + * A program prints one line; the emulator breaks it at the screen width into several rows. An address + * that crosses the break is whole in none of them, so searching row by row misses it (or reads half of + * a port number as a port). A row that fills the width is taken to continue on the next row, which is + * exactly what the emulator does when more text arrives after the last column. + */ +object WrappedLines { + /** + * The lines of [rows] that contain at least one row for which [include] is true, each as one string. + * [columns] is the width the rows were wrapped at; with no usable width every row is its own line. + * + * A line whose last row is full may still be growing in output that has not arrived, so its trailing + * digits are left out: they could be the front of a longer number. Once the line ends - the next row + * is not full, even if only because it is empty - they are included. + */ + fun join( + rows: List, + columns: Int, + include: (StyledLine) -> Boolean, + ): List { + if (columns <= 0) return rows.filter(include).map { it.text } + val lines = ArrayList() + var start = 0 + while (start < rows.size) { + var end = start + while (end + 1 < rows.size && rows[end].text.length >= columns) end++ + val group = rows.subList(start, end + 1) + if (group.any(include)) { + val text = group.joinToString(separator = "") { it.text } + lines += if (rows[end].text.length >= columns) text.trimEnd { it.isDigit() } else text + } + start = end + 1 + } + return lines + } +} diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/workspace/TerminalScreen.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/workspace/TerminalScreen.kt index ac5cd5072..6963290a2 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/workspace/TerminalScreen.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/workspace/TerminalScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -67,6 +68,7 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -96,7 +98,7 @@ fun TerminalScreen( onRestart: () -> Unit, onClear: () -> Unit, /** Opens a server the shell started (its [DevServer.url]) in the in-app browser. */ - onOpenServer: (url: String) -> Unit = {}, + onOpenServer: (url: String) -> Unit, ) { val running = state.status == TerminalStatus.RUNNING // Whoever types, presses a key or clears wants to see what happens next, wherever they had @@ -227,7 +229,9 @@ private fun TerminalServerChip( modifier = Modifier .testTag("terminal-server-${server.port}") - .heightIn(min = 40.dp) + // The chip is drawn smaller than a finger; the touch target is not. + .minimumInteractiveComponentSize() + .heightIn(min = 32.dp) .clip(shape) .border(1.dp, color, shape) // Only a server that answers has a page to open; a stopped one is just a status. @@ -238,7 +242,8 @@ private fun TerminalServerChip( ) { Box(modifier = Modifier.size(8.dp).background(color, CircleShape)) Spacer(modifier = Modifier.width(8.dp)) - Text(text = ":${server.port}", color = color, style = OutputTextStyle) + // A port is a number, not text: in a right-to-left language the colon must stay in front of it. + Text(text = ":${server.port}", color = color, style = OutputTextStyle.copy(textDirection = TextDirection.Ltr)) if (!running) { Spacer(modifier = Modifier.width(8.dp)) Text(text = stringResource(R.string.terminal_server_stopped), color = color, style = OutputTextStyle) diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/workspace/TerminalViewModel.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/workspace/TerminalViewModel.kt index 78e0882e3..73e5e0618 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/workspace/TerminalViewModel.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/workspace/TerminalViewModel.kt @@ -10,6 +10,7 @@ import com.yugahashimoto.andcode.feature.terminal.DevServerDetector import com.yugahashimoto.andcode.feature.terminal.DevServerTracker import com.yugahashimoto.andcode.feature.terminal.StyledLine import com.yugahashimoto.andcode.feature.terminal.TerminalKey +import com.yugahashimoto.andcode.feature.terminal.WrappedLines import com.yugahashimoto.andcode.runtime.local.DevServerProbe import com.yugahashimoto.andcode.runtime.local.LocalTerminalLauncher import com.yugahashimoto.andcode.runtime.local.LoopbackDevServerProbe @@ -19,6 +20,7 @@ import com.yugahashimoto.andcode.runtime.local.TerminalSessionFactory import com.yugahashimoto.andcode.runtime.local.TerminalUnavailableException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -205,7 +207,7 @@ class TerminalViewModel( * bring the cleared text back). */ private fun publish(change: AnsiTerminalEmulator.() -> Unit) { - val startProbing = + val probeToStart = synchronized(lock) { val terminal = emulator ?: return val snapshot = @@ -222,43 +224,42 @@ class TerminalViewModel( } watchForServers(snapshot.lines) } - if (startProbing) launchProbe() + // Started outside the lock. It was created, and recorded in [probeJob], inside it, so a close or + // an exit that comes in between cancels it before it ever runs. + probeToStart?.start() } /** - * Looks for servers in the lines that appeared since the last call. Returns whether probing has to - * be started for them. Called with [lock] held. + * Looks for servers in the lines that appeared since the last call. Returns the probe loop to start + * when there is now something to probe and no loop is running. Called with [lock] held. */ - private fun watchForServers(lines: List): Boolean { - val mentioned = DevServerDetector.portsIn(freshTextOf(lines)) - if (mentioned.isNotEmpty()) tracker = tracker.observe(mentioned) - val start = !probing && !closed && tracker.ports.isNotEmpty() - if (start) probing = true - return start + private fun watchForServers(lines: List): Job? { + try { + val mentioned = DevServerDetector.portsIn(freshTextOf(lines)) + if (mentioned.isNotEmpty()) tracker = tracker.observe(mentioned) + } catch (e: RuntimeException) { + // Finding servers is a convenience on top of untrusted output; if it breaks, the screen goes on. + Log.w(TAG, "Terminal output could not be searched for servers", e) + } + if (probing || closed || tracker.ports.isEmpty()) return null + probing = true + return viewModelScope.launch(start = CoroutineStart.LAZY) { probeServers() }.also { probeJob = it } } /** - * The text of the screen lines that were not there, or not like that, the last time this ran. + * The text of the lines the program printed that are new, or changed, since the last call. * * Only the end of the screen is searched, and only what changed in it: an address printed long ago * and still on screen must not bring back a server that has since been forgotten. The emulator * hands out the same [StyledLine] for a row until the row changes, so comparing by identity finds - * exactly the new and rewritten rows - a reprinted line is new even if its text is not. - * Called with [lock] held. + * exactly the new and rewritten rows - a reprinted line is new even if its text is not. Rows the + * screen width wrapped are put back together first ([WrappedLines]). Called with [lock] held. */ private fun freshTextOf(lines: List): List { val tail = lines.takeLast(SCAN_TAIL_LINES) val seen = scannedLines scannedLines = identitySetOf(tail) - return tail.filter { it !in seen }.map { it.text } - } - - private fun launchProbe() { - val job = viewModelScope.launch { probeServers() } - synchronized(lock) { - probeJob = job - if (closed) job.cancel() - } + return WrappedLines.join(tail, columns) { it !in seen } } /** diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/terminal/DevServerTrackerTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/terminal/DevServerTrackerTest.kt index ff49b9e0e..365424cd7 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/feature/terminal/DevServerTrackerTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/terminal/DevServerTrackerTest.kt @@ -83,12 +83,49 @@ class DevServerTrackerTest { } @Test - fun `tracks no more than the limit and ignores the rest`() { + fun `tracks no more than the limit, keeping the newest candidates`() { val many = (1..DevServerTracker.MAX_TRACKED + 5).map { 3000 + it } val tracker = DevServerTracker().observe(many) - assertEquals(many.take(DevServerTracker.MAX_TRACKED), tracker.ports) + assertEquals(many.takeLast(DevServerTracker.MAX_TRACKED), tracker.ports) + } + + @Test + fun `a new mention pushes out the oldest candidate that never answered`() { + // A fragment of an address cut by a chunk boundary names a port nobody listens on; it must not + // keep the real server's slot for half a minute. + val ports = (1..DevServerTracker.MAX_TRACKED).map { 3000 + it } + val tracker = DevServerTracker().observe(ports).up(3002) + + val next = tracker.observe(listOf(9000)) + + assertTrue(3001 !in next.ports) + assertTrue(3002 in next.ports) + assertTrue(9000 in next.ports) + assertEquals(DevServerTracker.MAX_TRACKED, next.ports.size) + } + + @Test + fun `a server that answered is never pushed out by a new mention`() { + val ports = (1..DevServerTracker.MAX_TRACKED).map { 3000 + it } + val tracker = DevServerTracker().observe(ports).up(*ports.toIntArray()) + + val next = tracker.observe(listOf(9000)) + + assertEquals(ports, next.ports) + assertEquals(ports, next.servers.map { it.port }) + } + + @Test + fun `a stopped server is not pushed out either`() { + val ports = (1..DevServerTracker.MAX_TRACKED).map { 3000 + it } + val tracker = DevServerTracker().observe(ports).up(*ports.toIntArray()).down(3001).down(3001) + + val next = tracker.observe(listOf(9000)) + + assertTrue(3001 in next.ports) + assertTrue(9000 !in next.ports) } @Test diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/terminal/WrappedLinesTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/terminal/WrappedLinesTest.kt new file mode 100644 index 000000000..11ef2638b --- /dev/null +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/terminal/WrappedLinesTest.kt @@ -0,0 +1,75 @@ +package com.yugahashimoto.andcode.feature.terminal + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WrappedLinesTest { + private fun rows(vararg texts: String) = texts.map { StyledLine(it) } + + private fun join( + rows: List, + columns: Int, + include: (StyledLine) -> Boolean = { true }, + ) = WrappedLines.join(rows, columns, include) + + @Test + fun `rows that were not wrapped stay separate`() { + assertEquals(listOf("one", "two", "three"), join(rows("one", "two", "three"), columns = 10)) + } + + @Test + fun `a row that fills the width is joined to the one after it`() { + // 10 columns: the first row is full, so the program's line continues on the second. + assertEquals(listOf("0123456789tail", "next"), join(rows("0123456789", "tail", "next"), columns = 10)) + } + + @Test + fun `a line wrapped over several rows is joined whole`() { + assertEquals(listOf("aaaaabbbbbccc"), join(rows("aaaaa", "bbbbb", "ccc"), columns = 5)) + } + + @Test + fun `only lines with an included row are returned`() { + val old = StyledLine("0123456789") + val tail = StyledLine("tail") + val other = StyledLine("other") + + val found = join(listOf(old, tail, other), columns = 10) { it === tail } + + assertEquals(listOf("0123456789tail"), found) + } + + @Test + fun `a line that may go on has its trailing digits held back`() { + // The row is full, so "80" may be the front of "8000": it must not be read as a port yet. + assertEquals(listOf("http://localhost:"), join(rows("http://localhost:80"), columns = 19)) + } + + @Test + fun `a line that ended keeps its trailing digits`() { + assertEquals(listOf("http://localhost:8000"), join(rows("http://localhost:8000"), columns = 40)) + } + + @Test + fun `a full row followed by an empty row is a finished line`() { + // The empty row is what a newline after a full-width line leaves behind. + assertEquals(listOf("http://localhost:80"), join(rows("http://localhost:80", ""), columns = 19)) + } + + @Test + fun `a full row at the very end of the screen is the end of the group`() { + assertEquals(listOf("abcde"), join(rows("abcde"), columns = 5)) + } + + @Test + fun `without a usable width nothing is joined`() { + assertEquals(listOf("a", "b"), join(rows("a", "b"), columns = 0)) + assertEquals(listOf("a", "b"), join(rows("a", "b"), columns = -3)) + } + + @Test + fun `no rows means no lines`() { + assertTrue(join(emptyList(), columns = 10).isEmpty()) + } +} diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/workspace/TerminalViewModelTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/workspace/TerminalViewModelTest.kt index 1ffaa6bc1..2316d0136 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/feature/workspace/TerminalViewModelTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/workspace/TerminalViewModelTest.kt @@ -722,6 +722,63 @@ class TerminalViewModelTest { assertEquals(askedSoFar, probe.asked.size) } + @Test + fun `finds an address that the screen width wrapped onto two rows`() = + runTest(dispatcher) { + val probe = FakeProbe().apply { listening += 8000 } + val factory = FakeFactory() + val viewModel = viewModel(factory, probe = probe) + viewModel.open(LocalTerminalLauncher.MIN_COLUMNS, 24) + runCurrent() + + // python's banner; at 20 columns the url is split across two rows. + factory.sessions.single().emit("Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...\r\n") + runCurrent() + + assertEquals(listOf(8000), viewModel.state.value.servers.map { it.port }) + viewModel.close() + } + + @Test + fun `does not take the front of a port for a port while its line is still arriving`() = + runTest(dispatcher) { + val probe = FakeProbe().apply { listening += 8000 } + val factory = FakeFactory() + val viewModel = viewModel(factory, probe = probe) + viewModel.open(LocalTerminalLauncher.MIN_COLUMNS, 24) + runCurrent() + + // The first 20 characters end inside the port: "8" is all that has arrived of "8000". + factory.sessions.single().emit("x http://localhost:8") + runCurrent() + assertTrue(probe.asked.isEmpty()) + + factory.sessions.single().emit("000/\r\n") + runCurrent() + + assertEquals(listOf(8000), viewModel.state.value.servers.map { it.port }) + assertTrue(8 !in probe.asked) + viewModel.close() + } + + @Test + fun `a probe that fails is a server that does not answer`() = + runTest(dispatcher) { + val failing = DevServerProbe { throw IllegalStateException("probe broke") } + val factory = FakeFactory() + val viewModel = viewModel(factory, probe = failing) + viewModel.open(60, 24) + runCurrent() + + factory.sessions.single().emit("http://localhost:5173/\r\n") + runCurrent() + repeat(DevServerTracker.DROP_AFTER_MISSES) { nextProbeRound() } + + assertEquals(TerminalStatus.RUNNING, viewModel.state.value.status) + assertTrue(viewModel.state.value.servers.isEmpty()) + viewModel.close() + } + @Test fun `a restarted shell starts with no servers`() = runTest(dispatcher) { diff --git a/docs/LOCAL_RUNTIME.md b/docs/LOCAL_RUNTIME.md index 65552b148..449d26446 100644 --- a/docs/LOCAL_RUNTIME.md +++ b/docs/LOCAL_RUNTIME.md @@ -232,8 +232,9 @@ OpenCodeがOAuthの `auto` 方式を返した場合、Androidはブラウザを - **出力**: ストリーミングでANSI解釈し(`AnsiTerminalEmulator`)、SGRの色(16色・256色・トゥルーカラー)、`\r` による行の上書き、行内編集、画面クリアを扱う。スクロールバックは最大10,000行で、古い行から捨てる - **入力**: 入力欄の内容をEnter付きで送る。ソフトキーボードにない `Esc` / `Tab` / `Ctrl+C` / `Ctrl+D` / 矢印キーは入力欄の上のキー列から送る。Tab と矢印キーはシェルの行編集に作用するので、入力欄に残っている文字を先にシェルへ送ってから送る(シェルは入力欄の文字をまだ知らないため)。出力の追従は、指でスクロールして末尾から離れると止まり、末尾へ戻すか、送信・キー送信・クリアをすると再開する。マウスホイールやTalkBackのスクロールは追従の解除として扱われない - **開発サーバーの検出**: 出力に出た `http://localhost:5173/` 形式のURL(`localhost` / `127.0.0.1` / `0.0.0.0` / `[::1]`)や `listening on port 3000` のような行から、**ポート番号だけ**を拾う(出力のホスト名は使わないので、出力でアプリ内ブラウザの行き先を変えられない)。拾っただけのポートは出さない: 入力したコマンドのエコーにも `curl http://localhost:8080` のようなURLが含まれるため、`127.0.0.1` か `::1` へのTCP接続が実際に通ったものだけを、ターミナル上部のチップ(`:5173`)として表示する。押すとアプリ内ブラウザで `http://localhost:PORT/` を開く(`DevServerDetector` / `DevServerTracker` / `LoopbackDevServerProbe`)。`/proc/net/tcp` はAndroid 10以降アプリから読めないので、接続確認で判断する - - 確認は2秒ごと。連続2回失敗すると「停止」表示(押せない)になり、約30秒応答がなければチップを消す。出力にサーバーの記述が現れるまで確認は始めず、記述がなければ何も動かない。同時に追うのは8ポートまで + - 確認は2秒ごと。連続2回失敗すると「停止」表示(押せない)になり、約30秒応答がなければチップを消す。出力にサーバーの記述が現れるまで確認は始めず、記述がなければ何も動かない。同時に追うのは8ポートまで。満杯のときは、一度も応答していない最も古い候補(コマンドのエコーや途中で切れた断片)を押し出して新しいポートを入れる。一度応答したポートは押し出さない - 調べるのは画面末尾200行のうち、前回から新しく書かれた・書き換わった行だけ。一度に200行以上流れる出力の手前にあるURLは見落とす。画面に残っている古いURLが、忘れたサーバーを呼び戻すことはない + - 画面幅で折り返された行(幅いっぱいまで埋まった行は次の行に続くとみなす)は、つなげてから調べる(`WrappedLines`)ので、`python -m http.server` のバナーのようにURLが折り返し位置をまたいでも検出できる。行がまだ続くかもしれない間は、行末の数字を(ポートの前半かもしれないので)ポートとして読まない - シェルが終わるとチップも消える。エージェントなど、ターミナル以外から起動したサーバーはここには出ない(アプリ内ブラウザのURL欄に直接入力する) - **ライフサイクル**: 画面を閉じるとシェルと配下のプロセス(開発サーバーなど)を終了する。PRoot本体だけをkillしてもゲスト側の `script` / `bash` は残る(`--kill-on-exit` はPRoot自身が終了するときにしか効かない。`ProcessTree.kt` の実機検証メモ参照)ため、`/proc` から子孫を辿って末端から順にkillする(`killProcessTree`)。画面を閉じるのはUIスレッドなので、このkillはバックグラウンドスレッドで行う - **電力**: シェルが生きている間は、ランタイムの作業リースを保持し、端末がサスペンドしないようにする。バックグラウンドでプロンプトに居るだけでも保持し続けるので、使い終わったら画面を閉じる。ランタイムの排他ロックはプロセス起動の間だけ取り、シェルの常駐中は取らない(更新が閉じるまで待たされないため) From 36e7b0ad314cb4e056216002293b9941185c7f40 Mon Sep 17 00:00:00 2001 From: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:44:18 +0900 Subject: [PATCH 3/3] docs(terminal): say that joining wrapped rows is an inference from their length The pre-PR re-review noted that a row filling the width is joined to the next one whether it wrapped or merely ended in the last column. Say so in the KDoc and in LOCAL_RUNTIME.md, with what it costs. Comments and docs only. Refs #402 Co-Authored-By: Claude Sonnet 5.5 --- .../andcode/feature/terminal/WrappedLines.kt | 10 ++++++++-- docs/LOCAL_RUNTIME.md | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/terminal/WrappedLines.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/terminal/WrappedLines.kt index 25f34c481..1028a34f8 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/terminal/WrappedLines.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/terminal/WrappedLines.kt @@ -5,8 +5,14 @@ package com.yugahashimoto.andcode.feature.terminal * * A program prints one line; the emulator breaks it at the screen width into several rows. An address * that crosses the break is whole in none of them, so searching row by row misses it (or reads half of - * a port number as a port). A row that fills the width is taken to continue on the next row, which is - * exactly what the emulator does when more text arrives after the last column. + * a port number as a port). A row that fills the width is taken to continue on the next row. + * + * That is an inference from the row's length, not a record of what the emulator did: it cannot tell a + * line that wrapped from one that happened to end exactly at the last column and was followed by a + * newline, so the latter is joined to the row after it as well. The cost is small - a port at the very + * end of such a line can be read together with digits that start the next line, and the stray + * candidate is dropped when nothing answers on it. Marking wrapped rows in the emulator would make it + * exact. */ object WrappedLines { /** diff --git a/docs/LOCAL_RUNTIME.md b/docs/LOCAL_RUNTIME.md index 449d26446..64d952e3d 100644 --- a/docs/LOCAL_RUNTIME.md +++ b/docs/LOCAL_RUNTIME.md @@ -234,7 +234,7 @@ OpenCodeがOAuthの `auto` 方式を返した場合、Androidはブラウザを - **開発サーバーの検出**: 出力に出た `http://localhost:5173/` 形式のURL(`localhost` / `127.0.0.1` / `0.0.0.0` / `[::1]`)や `listening on port 3000` のような行から、**ポート番号だけ**を拾う(出力のホスト名は使わないので、出力でアプリ内ブラウザの行き先を変えられない)。拾っただけのポートは出さない: 入力したコマンドのエコーにも `curl http://localhost:8080` のようなURLが含まれるため、`127.0.0.1` か `::1` へのTCP接続が実際に通ったものだけを、ターミナル上部のチップ(`:5173`)として表示する。押すとアプリ内ブラウザで `http://localhost:PORT/` を開く(`DevServerDetector` / `DevServerTracker` / `LoopbackDevServerProbe`)。`/proc/net/tcp` はAndroid 10以降アプリから読めないので、接続確認で判断する - 確認は2秒ごと。連続2回失敗すると「停止」表示(押せない)になり、約30秒応答がなければチップを消す。出力にサーバーの記述が現れるまで確認は始めず、記述がなければ何も動かない。同時に追うのは8ポートまで。満杯のときは、一度も応答していない最も古い候補(コマンドのエコーや途中で切れた断片)を押し出して新しいポートを入れる。一度応答したポートは押し出さない - 調べるのは画面末尾200行のうち、前回から新しく書かれた・書き換わった行だけ。一度に200行以上流れる出力の手前にあるURLは見落とす。画面に残っている古いURLが、忘れたサーバーを呼び戻すことはない - - 画面幅で折り返された行(幅いっぱいまで埋まった行は次の行に続くとみなす)は、つなげてから調べる(`WrappedLines`)ので、`python -m http.server` のバナーのようにURLが折り返し位置をまたいでも検出できる。行がまだ続くかもしれない間は、行末の数字を(ポートの前半かもしれないので)ポートとして読まない + - 画面幅で折り返された行(幅いっぱいまで埋まった行は次の行に続くとみなす)は、つなげてから調べる(`WrappedLines`)ので、`python -m http.server` のバナーのようにURLが折り返し位置をまたいでも検出できる。行がまだ続くかもしれない間は、行末の数字を(ポートの前半かもしれないので)ポートとして読まない。「幅いっぱい=続く」は行の長さからの推定で、エミュレータが折り返したかどうかの記録ではない。ちょうど幅で終わって改行された行も次の行とつなげるため、そういう行の末尾のポートが次の行の頭の数字と一緒に読まれることがある(応答しない候補は押し出されるか約30秒で消える) - シェルが終わるとチップも消える。エージェントなど、ターミナル以外から起動したサーバーはここには出ない(アプリ内ブラウザのURL欄に直接入力する) - **ライフサイクル**: 画面を閉じるとシェルと配下のプロセス(開発サーバーなど)を終了する。PRoot本体だけをkillしてもゲスト側の `script` / `bash` は残る(`--kill-on-exit` はPRoot自身が終了するときにしか効かない。`ProcessTree.kt` の実機検証メモ参照)ため、`/proc` から子孫を辿って末端から順にkillする(`killProcessTree`)。画面を閉じるのはUIスレッドなので、このkillはバックグラウンドスレッドで行う - **電力**: シェルが生きている間は、ランタイムの作業リースを保持し、端末がサスペンドしないようにする。バックグラウンドでプロンプトに居るだけでも保持し続けるので、使い終わったら画面を閉じる。ランタイムの排他ロックはプロセス起動の間だけ取り、シェルの常駐中は取らない(更新が閉じるまで待たされないため)