From 455083c246caa8af7639ff4a270dd400d8b4fd87 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:16:57 +0000 Subject: [PATCH] feat(remote-shell): mesh terminal for the firmware DMShell module (#6862) --- .skills/compose-ui/strings-index.txt | 39 ++ .../core/data/manager/MeshDataHandlerImpl.kt | 6 + .../manager/RemoteShellPacketHandlerImpl.kt | 52 ++ .../core/data/manager/MeshDataHandlerTest.kt | 6 +- .../org/meshtastic/core/model/Capabilities.kt | 7 + .../meshtastic/core/model/CapabilitiesTest.kt | 9 + .../org/meshtastic/core/navigation/Routes.kt | 2 + .../core/repository/RemoteShellHandler.kt | 51 ++ .../composeResources/values/strings.xml | 41 +- .../meshtastic/core/ui/util/ClipboardUtils.kt | 2 + .../meshtastic/core/ui/util/ClipboardUtils.kt | 3 + .../org/meshtastic/core/ui/util/NoopStubs.kt | 2 + .../meshtastic/core/ui/util/ClipboardUtils.kt | 8 + docs/en/user/nodes.md | 18 +- .../feature/docs/data/DocBundleLoader.kt | 2 +- .../node/component/AdministrationSection.kt | 98 ++-- .../node/component/NodeDetailComponents.kt | 27 +- .../feature/node/detail/HandleNodeAction.kt | 2 + .../node/detail/NodeDetailViewModel.kt | 24 +- .../node/metrics/terminal/LocalEcho.kt | 118 ++++ .../node/metrics/terminal/RemoteShellLink.kt | 541 ++++++++++++++++++ .../metrics/terminal/RemoteShellScreen.kt | 508 ++++++++++++++++ .../metrics/terminal/RemoteShellViewModel.kt | 499 ++++++++++++++++ .../node/metrics/terminal/TerminalKeys.kt | 127 ++++ .../node/metrics/terminal/TerminalKeysUi.kt | 334 +++++++++++ .../node/metrics/terminal/TerminalOutput.kt | 534 +++++++++++++++++ .../metrics/terminal/TerminalRendering.kt | 186 ++++++ .../feature/node/model/NodeDetailAction.kt | 2 + .../node/navigation/NodesNavigation.kt | 23 + .../node/metrics/terminal/LocalEchoTest.kt | 142 +++++ .../metrics/terminal/RemoteShellLinkTest.kt | 223 ++++++++ .../node/metrics/terminal/TerminalKeysTest.kt | 81 +++ .../metrics/terminal/TerminalOutputTest.kt | 190 ++++++ 33 files changed, 3853 insertions(+), 54 deletions(-) create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/RemoteShellPacketHandlerImpl.kt create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RemoteShellHandler.kt create mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/LocalEcho.kt create mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellLink.kt create mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellScreen.kt create mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellViewModel.kt create mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalKeys.kt create mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalKeysUi.kt create mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalOutput.kt create mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalRendering.kt create mode 100644 feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/terminal/LocalEchoTest.kt create mode 100644 feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellLinkTest.kt create mode 100644 feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalKeysTest.kt create mode 100644 feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalOutputTest.kt diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index dd43c9ded5..e25c7e70d6 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -1494,6 +1494,45 @@ remote_admin_unreachable remote_hardware remote_hardware_config remote_hardware_enabled +remote_shell +remote_shell_command_hint +remote_shell_error_notice +remote_shell_full_screen +remote_shell_input_dropped +remote_shell_key_alt +remote_shell_key_ctrl +remote_shell_key_down +remote_shell_key_end +remote_shell_key_escape +remote_shell_key_home +remote_shell_key_left +remote_shell_key_page_down +remote_shell_key_page_up +remote_shell_key_right +remote_shell_key_tab +remote_shell_key_up +remote_shell_last_contact +remote_shell_menu_line_mode +remote_shell_menu_paste +remote_shell_menu_text_larger +remote_shell_menu_text_smaller +remote_shell_modifier_locked +remote_shell_modifier_once +remote_shell_more_options +remote_shell_no_reply +remote_shell_no_reply_reason +remote_shell_open +remote_shell_open_description +remote_shell_reconnect +remote_shell_send +remote_shell_session_closed +remote_shell_session_closed_reason +remote_shell_status_closed +remote_shell_status_closing +remote_shell_status_failed +remote_shell_status_not_connected +remote_shell_status_opening +remote_shell_subtitle_round_trip remotely_administrating ### REMOVE ### remove diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt index da65039f6c..37696ec93b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt @@ -64,6 +64,7 @@ import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioSessionContext +import org.meshtastic.core.repository.RemoteShellHandler import org.meshtastic.core.repository.ServiceStateWriter import org.meshtastic.core.repository.StoreForwardPacketHandler import org.meshtastic.core.repository.TelemetryPacketHandler @@ -110,6 +111,7 @@ class MeshDataHandlerImpl( private val storeForwardHandler: StoreForwardPacketHandler, private val telemetryHandler: TelemetryPacketHandler, private val adminPacketHandler: AdminPacketHandler, + private val remoteShellHandler: RemoteShellHandler, private val collectorRegistry: DiscoveryPacketCollectorRegistry, private val geofenceMonitor: GeofenceMonitor, private val meshBeaconRepository: MeshBeaconRepository, @@ -199,6 +201,10 @@ class MeshDataHandlerImpl( adminPacketHandler.handleAdminMessage(packet, myNodeNum, session) } + PortNum.REMOTE_SHELL_APP -> { + remoteShellHandler.handleRemoteShell(packet) + } + PortNum.NEIGHBORINFO_APP -> { neighborInfoHandler.handleNeighborInfo(packet) } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/RemoteShellPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/RemoteShellPacketHandlerImpl.kt new file mode 100644 index 0000000000..1c3bed6e72 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/RemoteShellPacketHandlerImpl.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.data.manager + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import org.koin.core.annotation.Single +import org.meshtastic.core.model.util.decodeOrNull +import org.meshtastic.core.repository.ReceivedShellFrame +import org.meshtastic.core.repository.RemoteShellHandler +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.RemoteShell + +/** + * Decodes incoming [RemoteShell] frames (REMOTE_SHELL_APP) and hands them on; session state lives with the terminal + * screen. + */ +@Single +class RemoteShellPacketHandlerImpl : RemoteShellHandler { + + /** + * Emits every received [ReceivedShellFrame] (decoded frame + sender node number). + * + * Uses [MutableSharedFlow] with a buffer so that rapid or structurally-identical frames are never silently dropped + * (unlike `StateFlow` which conflates by equality). + */ + private val _lastFrame = MutableSharedFlow(extraBufferCapacity = 16) + override val lastFrame: SharedFlow = _lastFrame.asSharedFlow() + + override fun handleRemoteShell(packet: MeshPacket) { + val payload = packet.decoded?.payload ?: return + val frame = RemoteShell.ADAPTER.decodeOrNull(payload, Logger) ?: return + Logger.d { "RemoteShell frame from ${packet.from}: op=${frame.op} sessionId=${frame.session_id}" } + _lastFrame.tryEmit(ReceivedShellFrame(from = packet.from, frame = frame)) + } +} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt index 106b93edac..7c1cbefaf2 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt @@ -61,6 +61,7 @@ import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioSessionContext import org.meshtastic.core.repository.RadioSessionLease +import org.meshtastic.core.repository.RemoteShellHandler import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.StoreForwardPacketHandler import org.meshtastic.core.repository.TelemetryPacketHandler @@ -105,6 +106,7 @@ class MeshDataHandlerTest { private val storeForwardHandler: StoreForwardPacketHandler = mock(MockMode.autofill) private val telemetryHandler: TelemetryPacketHandler = mock(MockMode.autofill) private val adminPacketHandler: AdminPacketHandler = mock(MockMode.autofill) + private val remoteShellHandler: RemoteShellHandler = mock(MockMode.autofill) private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill) private val session = RadioSessionContext(generation = 7L, address = "tcp:test") @@ -164,6 +166,7 @@ class MeshDataHandlerTest { storeForwardHandler = storeForwardHandler, telemetryHandler = telemetryHandler, adminPacketHandler = adminPacketHandler, + remoteShellHandler = remoteShellHandler, collectorRegistry = mock(MockMode.autofill), // GeofenceMonitor is a final @Single (mokkery can't mock it) — use a real one over mocked // collaborators. With no geofence-bearing waypoints emitted, onPositionReceived is a no-op. @@ -829,8 +832,7 @@ class MeshDataHandlerTest { @Test fun `a genuine proof is still recorded after a forged ack has settled the packet`() = testScope.runTest { val updates = mutableListOf() - val forged = - sentPacket(MeshPacket.AckProofStatus.ACK_PROOF_INVALID.value).copy(status = MessageStatus.RECEIVED) + val forged = sentPacket(MeshPacket.AckProofStatus.ACK_PROOF_INVALID.value).copy(status = MessageStatus.RECEIVED) everySuspend { packetRepository.findPacketsWithId(99) } returns listOf(forged) everySuspend { packetRepository.update(any(), any()) } calls { call -> updates.add(call.arg(0)) } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt index 73d0ffa85e..3c56c36963 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt @@ -67,6 +67,12 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl /** Support for sharing contact information via QR codes. Supported since firmware v2.6.8. */ val supportsQrCodeSharing = atLeast(V2_6_8) + /** + * Support for the DMShell module (a PTY over the mesh, REMOTE_SHELL_APP). Firmware v2.8.2 is the first to carry it, + * and only Linux `meshtasticd` builds compile it in, which the version alone cannot tell. + */ + val supportsRemoteShell = atLeast(V2_8_2) + /** Support for the Status Message module, from the `since_firmware` its `ModuleConfig` field declares. */ val supportsStatusMessage = offers(ModuleConfig.statusmessage) @@ -149,6 +155,7 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl private val V2_7_12 = DeviceVersion("2.7.12") private val V2_7_18 = DeviceVersion("2.7.18") private val V2_8_0 = DeviceVersion("2.8.0") + private val V2_8_2 = DeviceVersion("2.8.2") private val UNRELEASED = DeviceVersion("9.9.9") } } diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt index d8232c63f3..f8c6fd6d01 100644 --- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt @@ -127,6 +127,13 @@ class CapabilitiesTest { assertTrue(caps("2.8.0").supportsTakConfig) } + @Test + fun supportsRemoteShell_requires_V2_8_2() { + assertFalse(caps("2.8.1").supportsRemoteShell) + assertTrue(caps("2.8.2").supportsRemoteShell) + assertTrue(caps("2.8.2.977b1d7").supportsRemoteShell) + } + @Test fun supportsEsp32Ota_requires_V2_7_18() { assertFalse(caps("2.7.17").supportsEsp32Ota) @@ -209,6 +216,7 @@ class CapabilitiesTest { assertFalse(c.supportsSecondaryChannelLocation) assertFalse(c.supportsStatusMessage) assertFalse(c.supportsTakConfig) + assertFalse(c.supportsRemoteShell) assertFalse(c.supportsEsp32Ota) } @@ -219,5 +227,6 @@ class CapabilitiesTest { assertTrue(c.canSendVerifiedContacts) assertTrue(c.supportsStatusMessage) assertTrue(c.supportsTakConfig) + assertTrue(c.supportsRemoteShell) } } diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt index b0192d69dd..e67b83e064 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt @@ -88,6 +88,8 @@ sealed interface NodeDetailRoute : Route { @Serializable data class AirQualityMetrics(val destNum: Int) : NodeDetailRoute @Serializable data class NeighborInfoLog(val destNum: Int) : NodeDetailRoute + + @Serializable data class RemoteShell(val destNum: Int) : NodeDetailRoute } @Serializable diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RemoteShellHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RemoteShellHandler.kt new file mode 100644 index 0000000000..b4b322f9c2 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RemoteShellHandler.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.repository + +import kotlinx.coroutines.flow.SharedFlow +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.RemoteShell + +/** + * A decoded [RemoteShell] frame together with the node number that sent it. + * + * Propagating [from] allows downstream consumers (e.g. the ViewModel) to verify that a frame actually originated from + * the expected peer rather than relying solely on [RemoteShell.session_id]. + */ +data class ReceivedShellFrame(val from: Int, val frame: RemoteShell) + +/** + * Interface for handling RemoteShell packets (REMOTE_SHELL_APP portnum = 13). + * + * RemoteShell is a PTY-over-mesh feature that relays a shell session across the mesh network, served by the firmware's + * DMShell module (see [Capabilities.supportsRemoteShell]). + */ +interface RemoteShellHandler { + /** + * The most recently received [ReceivedShellFrame], emitted to collectors. + * + * Uses [SharedFlow] (not `StateFlow`) so that rapid or identical frames are never silently dropped. + */ + val lastFrame: SharedFlow + + /** + * Processes an incoming RemoteShell packet. + * + * @param packet The received mesh packet carrying a [RemoteShell] payload. + */ + fun handleRemoteShell(packet: MeshPacket) +} diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 6513f12c7e..e9180b6db2 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -516,7 +516,7 @@ message,channel,encryption,direct,broadcast,quick-chat mqtt,broker,internet,bridge,uplink,downlink metrics,telemetry,signal,snr,rssi,battery,traceroute - node,mesh,list,role,status,favorite,filter + node,mesh,list,role,status,favorite,filter,shell,terminal notification,alert,sound,mute,reply,watch,wear os setup,welcome,permissions,first-launch module,serial,telemetry,canned,store-forward,administration @@ -1554,6 +1554,45 @@ Remote Hardware Remote Hardware Config Remote Hardware enabled + Remote Shell + Command + Error from the node: %1$s + Full-screen programs need more than the mesh can carry, so their output may not display correctly + %1$d bytes of input dropped, the node is not keeping up + Alt + Control + Down arrow + End + Escape + Home + Left arrow + Page down + Page up + Right arrow + Tab + Up arrow + Waiting for the node, last heard %1$d s ago + Line mode + Paste + Larger text + Smaller text + Locked + Applies to the next key + More options + No reply from the node. It must list your radio's public key as an admin key, and be in range. + Could not open the session (%1$s). The node must list your radio's public key as an admin key, and be in range. + Open terminal + Opens a shell on this node, starting a remote admin session first if needed + Reconnect + Send + Session closed + Session closed: %1$s + Session closed + Closing… + Session failed + Not connected + Opening session… + %1$s, %2$d ms round trip "[Remote] %1$s" Remove diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt index 3a3943849e..6f9378459d 100644 --- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt @@ -30,3 +30,5 @@ actual fun createClipEntry(text: String, label: String, sensitive: Boolean): Cli } return ClipEntry(clip) } + +actual fun ClipEntry.plainText(): String? = clipData.takeIf { it.itemCount > 0 }?.getItemAt(0)?.text?.toString() diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt index 5844691611..9e3386756a 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt @@ -26,3 +26,6 @@ import androidx.compose.ui.platform.ClipEntry * treat it as secret. Pass it for private keys and for channel URLs, which carry channel PSKs. */ expect fun createClipEntry(text: String, label: String = "", sensitive: Boolean = false): ClipEntry + +/** The plain text an entry carries, or null when it holds none (an image, a file, an empty clip). */ +expect fun ClipEntry.plainText(): String? diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt index 5d616cc88c..571b4174ad 100644 --- a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt +++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt @@ -27,6 +27,8 @@ import org.meshtastic.core.common.util.CommonUri actual fun createClipEntry(text: String, label: String, sensitive: Boolean): ClipEntry = throw UnsupportedOperationException("ClipEntry instantiation not supported on iOS stub") +actual fun ClipEntry.plainText(): String? = null + actual fun annotatedStringFromHtml(html: String, linkStyles: TextLinkStyles?): AnnotatedString = AnnotatedString(html) @Composable actual fun rememberOpenNfcSettings(): () -> Unit = {} diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt index 5ca23d236d..3a26127451 100644 --- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt +++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt @@ -17,9 +17,17 @@ package org.meshtastic.core.ui.util import androidx.compose.ui.platform.ClipEntry +import java.awt.datatransfer.DataFlavor import java.awt.datatransfer.StringSelection +import java.awt.datatransfer.Transferable // `sensitive` has no AWT equivalent — the desktop clipboard carries no such flag — so it is accepted and ignored. @OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) actual fun createClipEntry(text: String, label: String, sensitive: Boolean): ClipEntry = ClipEntry(StringSelection(text)) + +@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) +actual fun ClipEntry.plainText(): String? = (nativeClipEntry as? Transferable) + ?.takeIf { it.isDataFlavorSupported(DataFlavor.stringFlavor) } + // Another application can change or release the clipboard between the check and the read. + ?.let { runCatching { it.getTransferData(DataFlavor.stringFlavor) as? String }.getOrNull() } diff --git a/docs/en/user/nodes.md b/docs/en/user/nodes.md index 149929ba47..3121885c25 100644 --- a/docs/en/user/nodes.md +++ b/docs/en/user/nodes.md @@ -2,13 +2,15 @@ title: Nodes parent: User Guide nav_order: 4 -last_updated: 2026-09-19 +last_updated: 2026-10-02 description: Browse, filter, and sort mesh nodes — view details, signal quality, roles, and quick actions. aliases: - node-list - mesh-nodes - peers - hop-histogram + - remote-shell + - terminal --- # Nodes @@ -213,6 +215,20 @@ A full, browsable directory of every link is also available at **Settings → De Some of these are affiliate links. Both places say so above the links: product links may be affiliate links, and purchases may earn Meshtastic a commission. +### Remote shell + +**Open terminal**, in the node's **Administration** card, opens a shell on a Linux node running `meshtasticd` 2.8.2 or later, carried over the mesh. The node accepts it only from a radio whose public key is in its **Security → Admin key** list, the same list remote administration uses; the app starts that admin session first if there isn't one, and the card's title shows whether it is active. The terminal takes the whole window. If the node never answers, the screen says so after a minute. + +The mesh carries a few hundred bytes a second, and the terminal is built around that: + +- Once the node has echoed the first character of a line, what you type appears **underlined** at the cursor until the node echoes it back. At a prompt that doesn't echo, such as a password, nothing you type is shown; in line mode the command box masks its text and keeps it out of history. +- The subtitle shows the measured round trip. When the node stops answering, a line at the top of the terminal says how long ago it was last heard. +- The two rows of extra keys give **ESC**, **TAB**, arrows, **HOME**, **END**, **PGUP** and **PGDN**. **CTRL** and **ALT** are sticky: tap once to apply to the next key, twice to lock, a third time to release. **CTRL** then **c** interrupts a running command. A hardware keyboard's Ctrl and Alt work as usual. +- **Line mode**, in the **⋮** menu, gives you a command box: each command goes out in one transmission, **↑** and **↓** recall earlier commands without asking the node, and chips offer common commands for a `meshtasticd` host. It is the cheapest way to use the shell on a busy mesh. +- The **⋮** menu also pastes from the clipboard and changes the text size. + +Colour, progress lines and `clear` display as they would in a desktop terminal. Full-screen programs such as `vi` or `top` redraw the whole screen for each key, which the mesh cannot carry; the terminal shows a notice instead. + ## When no nodes appear The list stays empty until your node hears another node. diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt index c582ce0efc..68981e0f43 100644 --- a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt +++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt @@ -290,7 +290,7 @@ class DefaultDocBundleLoader : DocBundleLoader { CoreRes.string.doc_keywords_nodes, "en/user/nodes.html", 4, - listOf("node-list", "mesh-nodes", "peers", "hop-histogram"), + listOf("node-list", "mesh-nodes", "peers", "hop-histogram", "remote-shell", "terminal"), 3800, "nodes", ), diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt index 5b7272b246..500bc0a1dc 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.resources.vectorResource import org.koin.compose.koinInject import org.meshtastic.core.model.DeviceVersion import org.meshtastic.core.model.FirmwareRelease @@ -46,14 +47,16 @@ import org.meshtastic.core.resources.connect_radio_for_remote_admin import org.meshtastic.core.resources.establishing_session import org.meshtastic.core.resources.firmware import org.meshtastic.core.resources.firmware_edition +import org.meshtastic.core.resources.ic_terminal import org.meshtastic.core.resources.installed_firmware_version import org.meshtastic.core.resources.latest_alpha_firmware import org.meshtastic.core.resources.latest_stable_firmware import org.meshtastic.core.resources.refresh_metadata import org.meshtastic.core.resources.remote_admin +import org.meshtastic.core.resources.remote_shell_open +import org.meshtastic.core.resources.remote_shell_open_description import org.meshtastic.core.resources.session_active import org.meshtastic.core.resources.session_refresh_required -import org.meshtastic.core.ui.component.BasicListItem import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.icon.ForkLeft import org.meshtastic.core.ui.icon.Icecream @@ -67,6 +70,7 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusYellow import org.meshtastic.feature.node.model.MetricsState import org.meshtastic.feature.node.model.NodeDetailAction import org.meshtastic.proto.FirmwareEdition +import org.meshtastic.proto.HardwareModel @Composable fun AdministrationSection( @@ -79,7 +83,15 @@ fun AdministrationSection( modifier: Modifier = Modifier, ) { Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(24.dp)) { - SectionCard(title = Res.string.administration) { + SectionCard( + title = Res.string.administration, + titleTrailing = + if (metricsState.isLocal) { + null + } else { + { SessionChip(node.num, sessionStatus, isEnsuringSession, onAction) } + }, + ) { Column { // Local nodes don't need a session — they short-circuit straight to the settings screen. if (metricsState.isLocal) { @@ -96,6 +108,24 @@ fun AdministrationSection( onAction = onAction, ) + // The node gates the shell on the same admin_key list as remote admin, so the row + // establishes that session first, as the remote-admin row does. Only meshtasticd builds the + // module, and the firmware advertises it nowhere else. A node not yet asked for its metadata + // still gets the row: the tap's session check fetches it, and an older node says so on OPEN. + val firmwareKnown = !node.metadata?.firmware_version.isNullOrEmpty() + val offersShell = !firmwareKnown || node.capabilities.supportsRemoteShell + if (node.user.hw_model == HardwareModel.PORTDUINO && offersShell) { + SectionDivider() + + ListItem( + text = stringResource(Res.string.remote_shell_open), + leadingIcon = vectorResource(Res.drawable.ic_terminal), + supportingText = stringResource(Res.string.remote_shell_open_description), + enabled = !isEnsuringSession, + onClick = { onAction(NodeDetailAction.OpenRemoteShell(node.num)) }, + ) + } + SectionDivider() ListItem( @@ -118,10 +148,9 @@ fun AdministrationSection( } /** - * Single primary affordance for opening the remote-admin screen. Replaces the prior two-row, no-feedback flow that - * required the user to know they had to tap "Metadata" first to populate `node.metadata` before "Remote Administration" - * un-greyed out. The session passkey freshness — not the metadata insert — is the real gate (see - * `firmware/src/modules/AdminModule.cpp:1460-1481`), and is now reflected via an [AssistChip] + inline progress. + * The session passkey freshness, not the metadata insert, is the real gate (see + * `firmware/src/modules/AdminModule.cpp:1460-1481`). Every row in the card that needs it shares one session, so its + * state sits in the card's title row as a [SessionChip], and this row shows inline progress while it is established. */ @Composable private fun RemoteAdminListItem( @@ -136,38 +165,13 @@ private fun RemoteAdminListItem( is SessionStatus.Active -> null is SessionStatus.Stale -> Res.string.session_refresh_required } - val chipLabelRes = - when (sessionStatus) { - SessionStatus.NoSession -> null - is SessionStatus.Active -> Res.string.session_active - is SessionStatus.Stale -> Res.string.session_refresh_required - } Column { - BasicListItem( + ListItem( text = stringResource(Res.string.remote_admin), leadingIcon = MeshtasticIcons.Settings, supportingText = supportingTextRes?.let { stringResource(it) }, enabled = !isEnsuringSession, - trailingContent = - chipLabelRes?.let { res -> - { - AssistChip( - onClick = { onAction(NodeDetailAction.OpenRemoteAdmin(nodeNum)) }, - label = { androidx.compose.material3.Text(stringResource(res)) }, - enabled = !isEnsuringSession, - colors = - if (sessionStatus is SessionStatus.Active) { - AssistChipDefaults.assistChipColors( - labelColor = MaterialTheme.colorScheme.onPrimaryContainer, - containerColor = MaterialTheme.colorScheme.primaryContainer, - ) - } else { - AssistChipDefaults.assistChipColors() - }, - ) - } - }, onClick = { onAction(NodeDetailAction.OpenRemoteAdmin(nodeNum)) }, ) AnimatedVisibility(visible = isEnsuringSession) { @@ -184,6 +188,36 @@ private fun RemoteAdminListItem( } } +/** Nothing to show before a session exists; the remote-admin row's supporting text covers that case. */ +@Composable +private fun SessionChip( + nodeNum: Int, + sessionStatus: SessionStatus, + isEnsuringSession: Boolean, + onAction: (NodeDetailAction) -> Unit, +) { + val labelRes = + when (sessionStatus) { + SessionStatus.NoSession -> return + is SessionStatus.Active -> Res.string.session_active + is SessionStatus.Stale -> Res.string.session_refresh_required + } + AssistChip( + onClick = { onAction(NodeDetailAction.OpenRemoteAdmin(nodeNum)) }, + label = { androidx.compose.material3.Text(stringResource(labelRes)) }, + enabled = !isEnsuringSession, + colors = + if (sessionStatus is SessionStatus.Active) { + AssistChipDefaults.assistChipColors( + labelColor = MaterialTheme.colorScheme.onPrimaryContainer, + containerColor = MaterialTheme.colorScheme.primaryContainer, + ) + } else { + AssistChipDefaults.assistChipColors() + }, + ) +} + @Composable private fun FirmwareSection( metricsState: MetricsState, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponents.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponents.kt index 3d2548eb1f..59a87789aa 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponents.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponents.kt @@ -62,6 +62,7 @@ import org.meshtastic.core.ui.util.createClipEntry internal fun SectionCard( title: StringResource, modifier: Modifier = Modifier, + titleTrailing: (@Composable () -> Unit)? = null, content: @Composable ColumnScope.() -> Unit, ) { ElevatedCard( @@ -70,16 +71,22 @@ internal fun SectionCard( shape = MaterialTheme.shapes.extraLarge, ) { Column(modifier = Modifier.padding(vertical = 16.dp)) { - Text( - text = stringResource(title), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - modifier = - Modifier.padding(horizontal = 20.dp, vertical = 8.dp).semantics { - heading() - }, // Proper navigation for screen reader users - ) + Row( + modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = + Modifier.weight(1f).padding(vertical = 8.dp).semantics { + heading() + }, // Proper navigation for screen reader users + ) + titleTrailing?.invoke() + } content() } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt index 275122786e..decfa298ae 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt @@ -40,6 +40,8 @@ internal fun handleNodeAction( is NodeDetailAction.OpenRemoteAdmin -> viewModel.openRemoteAdmin(action.nodeNum) + is NodeDetailAction.OpenRemoteShell -> viewModel.openRemoteShell(action.nodeNum) + is NodeDetailAction.RefreshMetadata -> viewModel.refreshMetadata(action.nodeNum) is NodeDetailAction.HandleNodeMenuAction -> { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt index 70de80ebd2..4288b96a67 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt @@ -38,6 +38,7 @@ import org.meshtastic.core.model.ContactKey import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.SessionStatus +import org.meshtastic.core.navigation.NodeDetailRoute import org.meshtastic.core.navigation.Route import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.repository.LocalNodeUnavailableException @@ -114,10 +115,9 @@ class NodeDetailViewModel( private val isEnsuringSession = MutableStateFlow(false) - private val sessionStatusFlow = - activeNodeId.flatMapLatest { nodeId -> - if (nodeId == null) flowOf(SessionStatus.NoSession) else observeRemoteAdminSessionStatus(nodeId) - } + private val sessionStatusFlow = activeNodeId.flatMapLatest { nodeId -> + if (nodeId == null) flowOf(SessionStatus.NoSession) else observeRemoteAdminSessionStatus(nodeId) + } /** One-shot navigation events from session-bearing actions (e.g. successful remote-admin opens). */ private val _navigationEvents = Channel(capacity = Channel.BUFFERED) @@ -225,15 +225,25 @@ class NodeDetailViewModel( * Ensure a remote-admin session passkey is fresh, then request navigation to the remote-admin screen. Surfaces a * snackbar with the appropriate guidance on [EnsureSessionResult.Disconnected] or [EnsureSessionResult.Timeout]. */ - fun openRemoteAdmin(destNum: Int) { + fun openRemoteAdmin(destNum: Int) = + ensureSessionThenNavigate(destNum, "openRemoteAdmin") { SettingsRoute.Settings(destNum) } + + /** + * The shell is gated by the node's `security.admin_key` list, the same list the admin passkey exchange goes + * through, so it needs the identical session check - just a different destination. + */ + fun openRemoteShell(destNum: Int) = + ensureSessionThenNavigate(destNum, "openRemoteShell") { NodeDetailRoute.RemoteShell(destNum) } + + private fun ensureSessionThenNavigate(destNum: Int, tag: String, destination: () -> Route) { // Atomic check-and-flip prevents a double-tap from queuing two passkey exchanges + two navigation events. if (!isEnsuringSession.compareAndSet(expect = false, update = true)) return - safeLaunch(tag = "openRemoteAdmin") { + safeLaunch(tag = tag) { try { when (ensureRemoteAdminSession(destNum)) { EnsureSessionResult.AlreadyActive, EnsureSessionResult.Refreshed, - -> _navigationEvents.trySend(SettingsRoute.Settings(destNum)) + -> _navigationEvents.trySend(destination()) EnsureSessionResult.Disconnected -> { val text = Res.string.connect_radio_for_remote_admin diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/LocalEcho.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/LocalEcho.kt new file mode 100644 index 0000000000..ac61d25e77 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/LocalEcho.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics.terminal + +/** Floor on how long a prediction may wait for its echo, before a round trip has been measured. */ +internal const val PREDICTION_TIMEOUT_MIN_MS = 4_000L +internal const val PREDICTION_TIMEOUT_MAX_MS = 20_000L +private const val PREDICTION_TIMEOUT_RTT_MULTIPLIER = 3 + +/** Prompts whose input the remote does not echo, so nothing typed into them may be drawn locally. */ +private val SECRET_PROMPT = Regex("""(password|passphrase|passcode|\bpin\b)[^:]*:\s*$""", RegexOption.IGNORE_CASE) + +/** Whether the text before the cursor reads as a prompt for a secret, such as sudo's. */ +internal fun looksLikeSecretPrompt(lineBeforeCursor: String): Boolean = SECRET_PROMPT.containsMatchIn(lineBeforeCursor) + +/** + * Predictive local echo in the manner of mosh: characters already sent are drawn at once, marked as unconfirmed, and + * retired as the remote echo arrives. + * + * Nothing is drawn on a line until the remote has echoed one of its characters, mosh's own rule: a prompt that does not + * echo, such as a password, never confirms one, so what is typed into it is never shown. Once confirmed, predictions + * are checked one character at a time against what the PTY prints. A different character - a completion, a redraw, a + * program reading keys itself - means the guess was wrong, so they are dropped rather than corrected and the line must + * confirm again; cursor movement and erases are how a shell echoes a backspace, so those pass. A prediction that waits + * longer than [timeoutMs] is dropped too, and prediction stays off until the next Enter. Only the line being typed is + * predicted; after Enter the next prompt is unknown. + * + * Not thread-safe, and owns no clock. + */ +internal class LocalEcho { + private val predicted = StringBuilder() + private var oldestSentMs = 0L + private var suppressed = false + private var echoConfirmed = false + + /** Whether typing on this line may be drawn before the remote echoes it. */ + val showsTyping: Boolean + get() = echoConfirmed && !suppressed + + /** Characters sent but not yet echoed, to draw after the cursor; empty until the line has confirmed an echo. */ + val pending: String + get() = if (showsTyping) predicted.toString() else "" + + fun onSent(text: String, nowMs: Long) { + for (c in text) { + when { + c == '\r' || c == '\n' -> { + predicted.clear() + suppressed = false + echoConfirmed = false + } + + c == '\u007f' || c == '\b' -> if (predicted.isNotEmpty()) predicted.deleteAt(predicted.lastIndex) + + c.isISOControl() -> { + predicted.clear() + echoConfirmed = false + } + + !suppressed -> { + if (predicted.isEmpty()) oldestSentMs = nowMs + predicted.append(c) + } + } + } + } + + fun onOutput(events: List, nowMs: Long) { + for (event in events) { + if (predicted.isEmpty()) return + when (event) { + is EchoEvent.Printed -> + if (event.char == predicted[0]) { + predicted.deleteAt(0) + echoConfirmed = true + // The echo is flowing, so the rest of the line gets a fresh wait. + oldestSentMs = nowMs + } else { + predicted.clear() + echoConfirmed = false + } + + // A backspace or CR is how a shell echoes an erase; anything else here is the shell rewriting the line. + EchoEvent.Edited -> Unit + } + } + } + + /** Drops predictions that have waited too long, and keeps prediction off for the rest of the line. */ + fun tick(nowMs: Long, roundTripMs: Long?) { + if (predicted.isEmpty()) return + if (nowMs - oldestSentMs >= timeoutMs(roundTripMs)) { + predicted.clear() + suppressed = true + echoConfirmed = false + } + } + + private fun timeoutMs(roundTripMs: Long?): Long = + ((roundTripMs ?: 0L) * PREDICTION_TIMEOUT_RTT_MULTIPLIER).coerceIn( + PREDICTION_TIMEOUT_MIN_MS, + PREDICTION_TIMEOUT_MAX_MS, + ) +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellLink.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellLink.kt new file mode 100644 index 0000000000..04ab0fe7e0 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellLink.kt @@ -0,0 +1,541 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics.terminal + +import okio.Buffer +import okio.ByteString +import org.meshtastic.proto.RemoteShell + +/** Frames a peer may hold unacknowledged before it must wait; the firmware's `DEFAULT_TX_WINDOW_FRAMES`. */ +internal const val INPUT_WINDOW_FRAMES = 4 + +/** In-order frames received with nothing sent back before a bare ACK is owed; the firmware's `ACK_AFTER_RX_FRAMES`. */ +internal const val ACK_AFTER_FRAMES = 2 + +/** Repeats of one unacknowledged frame before the peer is declared gone; the firmware's bound too. */ +internal const val MAX_RETRANSMITS = 25 + +internal const val MAX_INPUT_CHUNK_BYTES = 64 +internal const val PENDING_INPUT_MAX_BYTES = 4096 +internal const val TX_HISTORY_LEN = 50 +internal const val REORDER_SLOTS = 8 + +internal const val RETRY_MIN_MS = 1_000L +internal const val RETRY_MAX_MS = 8_000L +internal const val RETRY_FLAT_ATTEMPTS = 4 +private const val RETRANSMIT_LATENCY_MULTIPLIER = 2.0 +private const val ACK_LATENCY_SMOOTHING = 0.25 + +internal const val OPEN_RETRY_FIRST_MS = 5_000L +internal const val OPEN_RETRY_MAX_MS = 30_000L +internal const val OPEN_TIMEOUT_MS = 60_000L + +internal const val HEARTBEAT_IDLE_DELAY_MS = 5_000L +internal const val HEARTBEAT_REPEAT_MS = 15_000L + +internal sealed interface ShellEvent { + data object Opened : ShellEvent + + data class Output(val bytes: ByteString) : ShellEvent + + /** A sequenced ERROR: the session survives it. */ + data class RemoteError(val message: String) : ShellEvent + + data class Closed(val reason: String) : ShellEvent + + data class InputDropped(val bytes: Int) : ShellEvent +} + +internal class ShellStep(val send: List, val events: List) + +/** What the link knows about the path to the node, for the UI to show a slow link as slow rather than broken. */ +data class LinkHealth( + /** Smoothed time for a frame of ours to be acknowledged, or null before the first measurement. */ + val roundTripMs: Long? = null, + /** Frames of ours the node has not acknowledged yet. */ + val unacknowledged: Int = 0, + /** When anything last arrived from the node. */ + val lastInboundMs: Long = 0L, + /** When the oldest unacknowledged frame of ours was last sent, or null when the node is level with us. */ + val waitingSinceMs: Long? = null, +) + +internal fun shellFrame(op: RemoteShell.OpCode, configure: (RemoteShell.Builder) -> Unit = {}): RemoteShell = + RemoteShell.Builder() + .also { wb -> + wb.op = op + configure(wb) + } + .build() + +/** + * The client half of the firmware DMShell reliability layer, as `bin/dmshell_client.py` implements it. + * + * Every sequenced frame carries our receive cursor in `ack_seq`; the peer's cursor over our frames is the larger of + * `ack_seq` and `last_rx_seq` on anything it sends. Either side holds at most [INPUT_WINDOW_FRAMES] unacknowledged + * frames, so a stream only keeps flowing while the receiver answers it - hence the bare ACK every [ACK_AFTER_FRAMES] + * in-order frames and on every duplicate. An ACK with `last_rx_seq` N > 0 asks for a replay from N + 1. + * + * Not thread-safe, and owns no clock: callers serialise access and pass `nowMs`. + */ +@Suppress("TooManyFunctions") +internal class RemoteShellLink(val sessionId: Int, private val inputWindowFrames: Int = INPUT_WINDOW_FRAMES) { + + private class Sent(val frame: RemoteShell, var sentMs: Long) + + var isOpen = false + private set + + var isClosed = false + private set + + private var nextTxSeq = 1 + private val txHistory = ArrayDeque() + private var peerAcked = 0 + + private var lastRxSeq = 0 + private var nextExpectedRxSeq = 1 + private var highestSeenRxSeq = 0 + private val pendingRx = mutableMapOf() + private var framesSinceOutbound = 0 + + private var lastRequestedMissingSeq = 0 + private var lastMissingRequestMs = 0L + private var missingRequestIntervalMs = RETRY_MIN_MS + private var missingRequestAttempts = 0 + + private var ackLatencyMs: Double? = null + private var retransmitSeq = 0 + private var retransmits = 0 + private var retransmitIntervalMs = RETRY_MIN_MS + private var nextRetransmitMs = 0L + + private val pendingInput = Buffer() + + private var lastInboundMs = 0L + private var lastHeartbeatMs = 0L + + private var openSeq = 0 + private var openDeadlineMs = 0L + private var nextOpenRetryMs = 0L + private var openRetryIntervalMs = OPEN_RETRY_FIRST_MS + + private val out = mutableListOf() + private val events = mutableListOf() + + val health: LinkHealth + get() = + LinkHealth( + roundTripMs = ackLatencyMs?.toLong(), + unacknowledged = highestSentSeq() - peerAcked, + lastInboundMs = lastInboundMs, + waitingSinceMs = txHistory.firstOrNull { it.frame.seq == peerAcked + 1 }?.sentMs, + ) + + // region --- Commands --- + + fun open(cols: Int, rows: Int, nowMs: Long): ShellStep = step { + lastInboundMs = nowMs + openDeadlineMs = nowMs + OPEN_TIMEOUT_MS + nextOpenRetryMs = nowMs + OPEN_RETRY_FIRST_MS + openSeq = + sendSequenced( + shellFrame(RemoteShell.OpCode.OPEN) { wb -> + wb.cols = cols + wb.rows = rows + }, + nowMs, + ) + } + + /** Queue typed bytes and send what the window allows. Everything goes through the queue, so order holds. */ + fun input(bytes: ByteString, nowMs: Long): ShellStep = step { + if (isClosed) return@step + val room = (PENDING_INPUT_MAX_BYTES - pendingInput.size).toInt().coerceAtLeast(0) + val kept = bytes.size.coerceAtMost(room) + pendingInput.write(bytes, 0, kept) + if (kept < bytes.size) events += ShellEvent.InputDropped(bytes.size - kept) + flushInput(nowMs) + } + + fun resize(cols: Int, rows: Int, nowMs: Long): ShellStep = step { + if (isOpen && !isClosed) { + sendSequenced( + shellFrame(RemoteShell.OpCode.RESIZE) { wb -> + wb.cols = cols + wb.rows = rows + }, + nowMs, + ) + } + } + + fun close(): ShellStep = step { if (!isClosed) closeLocally("") } + + // endregion + + // region --- Inbound --- + + @Suppress("CyclomaticComplexMethod", "ReturnCount") + fun receive(frame: RemoteShell, nowMs: Long): ShellStep = step { + if (isClosed || frame.session_id != sessionId) return@step + lastInboundMs = nowMs + // Before any ordering: an out-of-order frame still carries a valid cursor, and during a gap it may be the + // only kind arriving. + notePeerCursor(maxOf(frame.ack_seq, frame.last_rx_seq), nowMs) + + // Terminal, so not held behind a gap the peer has just said it cannot fill. + if (frame.op == RemoteShell.OpCode.CLOSED) { + isClosed = true + events += ShellEvent.Closed(frame.payload.utf8()) + return@step + } + + if (frame.op == RemoteShell.OpCode.ACK) { + if (frame.last_rx_seq > 0) replayFrom(frame.last_rx_seq + 1, nowMs) + flushInput(nowMs) + return@step + } + + when (noteReceivedSeq(frame.seq)) { + RxAction.DUPLICATE -> { + // Already in order here, so the peer has not seen our cursor: a sender with its window shut. + sendAck(requestMissingSeqOnce(nowMs)) + return@step + } + + RxAction.GAP -> { + rememberOutOfOrder(frame) + requestMissingSeqOnce(nowMs)?.let { sendAck(it) } + return@step + } + + RxAction.PROCESS -> Unit + } + + handleInOrder(frame, nowMs) + drainPendingRx(nowMs) + if (isClosed) return@step + flushInput(nowMs) + val missing = requestMissingSeqOnce(nowMs) + if (missing != null) { + sendAck(missing) + } else if (framesSinceOutbound >= ACK_AFTER_FRAMES) { + sendAck(null) + } + } + + private fun handleInOrder(frame: RemoteShell, nowMs: Long) { + when (frame.op) { + RemoteShell.OpCode.OPEN_OK -> + if (!isOpen) { + isOpen = true + events += ShellEvent.Opened + } + + RemoteShell.OpCode.OUTPUT -> if (frame.payload.size > 0) events += ShellEvent.Output(frame.payload) + + RemoteShell.OpCode.ERROR -> + if (frame.seq == 0) { + // Sessionless: the node has no session for us (open_failed, invalid_session). + isClosed = true + events += ShellEvent.Closed(frame.payload.utf8()) + } else { + events += ShellEvent.RemoteError(frame.payload.utf8()) + } + + RemoteShell.OpCode.PONG -> { + if (frame.last_rx_seq != 0 && frame.last_rx_seq < highestSentSeq()) { + replayFrom(frame.last_rx_seq + 1, nowMs) + } + if (frame.last_tx_seq > lastRxSeq && frame.last_tx_seq > highestSeenRxSeq) { + highestSeenRxSeq = frame.last_tx_seq + } + } + + else -> Unit + } + } + + private fun drainPendingRx(nowMs: Long) { + while (!isClosed) { + val next = pendingRx.remove(nextExpectedRxSeq) ?: return + if (noteReceivedSeq(next.seq) != RxAction.PROCESS) { + rememberOutOfOrder(next) + return + } + handleInOrder(next, nowMs) + } + } + + // endregion + + // region --- Timers --- + + /** Drive OPEN retries, a shut window, and the heartbeat. Call every few hundred milliseconds. */ + fun tick(nowMs: Long): ShellStep = step { + if (isClosed) return@step + if (!isOpen) { + serviceOpen(nowMs) + return@step + } + flushInput(nowMs) + if (!windowOpen()) serviceShutWindow(nowMs) + if (!isClosed && heartbeatDue(nowMs)) { + lastHeartbeatMs = nowMs + sendSequenced( + shellFrame(RemoteShell.OpCode.PING) { wb -> + wb.last_tx_seq = highestSentSeq() + wb.last_rx_seq = lastRxSeq + }, + nowMs, + ) + } + } + + private fun serviceOpen(nowMs: Long) { + if (nowMs >= openDeadlineMs) { + // The node may have opened a session and be streaming into it; CLOSE is acted on out of order. + closeLocally("no reply from the node") + return + } + if (nowMs < nextOpenRetryMs) return + // Same session id and seq, which is how the firmware tells a lost OPEN_OK from a new session. + txHistory.firstOrNull { it.frame.seq == openSeq }?.let { resend(it, nowMs) } + openRetryIntervalMs = (openRetryIntervalMs * 2).coerceAtMost(OPEN_RETRY_MAX_MS) + nextOpenRetryMs = nowMs + openRetryIntervalMs + } + + /** + * With our window full the peer never sees a seq above the gap, so it never asks for the replay that would reopen + * us. Repeat the oldest unacknowledged frame instead, and give up after [MAX_RETRANSMITS]. + */ + @Suppress("ReturnCount") + private fun serviceShutWindow(nowMs: Long) { + val missing = peerAcked + 1 + if (missing > highestSentSeq() || nowMs < nextRetransmitMs) return + if (missing != retransmitSeq) { + retransmitSeq = missing + retransmits = 0 + retransmitIntervalMs = baseIntervalMs() + // The wait belongs to the frame, not to the tick that noticed it. + val sentAt = txHistory.firstOrNull { it.frame.seq == missing }?.sentMs + if (sentAt != null && nowMs < sentAt + retransmitIntervalMs) { + nextRetransmitMs = sentAt + retransmitIntervalMs + return + } + } + if (retransmits >= MAX_RETRANSMITS) { + closeLocally("the node stopped answering") + return + } + retransmits++ + if (retransmits > RETRY_FLAT_ATTEMPTS) { + retransmitIntervalMs = (retransmitIntervalMs * 2).coerceAtMost(RETRY_MAX_MS) + } + nextRetransmitMs = nowMs + retransmitIntervalMs + replayFrom(missing, nowMs) + } + + /** Keyed on inbound silence alone, so typing into a stalled session cannot suppress its own recovery. */ + private fun heartbeatDue(nowMs: Long): Boolean { + if (nowMs - lastInboundMs < HEARTBEAT_IDLE_DELAY_MS) return false + return lastHeartbeatMs <= lastInboundMs || nowMs - lastHeartbeatMs >= HEARTBEAT_REPEAT_MS + } + + // endregion + + // region --- Sending --- + + private fun windowOpen(): Boolean = inputWindowFrames == 0 || highestSentSeq() - peerAcked < inputWindowFrames + + private fun flushInput(nowMs: Long) { + while (isOpen && !isClosed && pendingInput.size > 0 && windowOpen()) { + val chunk = pendingInput.readByteString(pendingInput.size.coerceAtMost(MAX_INPUT_CHUNK_BYTES.toLong())) + sendSequenced(shellFrame(RemoteShell.OpCode.INPUT) { wb -> wb.payload = chunk }, nowMs) + } + } + + private fun sendSequenced(template: RemoteShell, nowMs: Long): Int { + val seq = nextTxSeq++ + val frame = + template + .newBuilder() + .also { wb -> + wb.session_id = sessionId + wb.seq = seq + wb.ack_seq = lastRxSeq + } + .build() + txHistory.addLast(Sent(frame, nowMs)) + if (txHistory.size > TX_HISTORY_LEN) txHistory.removeFirst() + emit(frame) + return seq + } + + /** + * [replayFrom] null is a bare ACK: `last_rx_seq` 0 is not a replay request, but `ack_seq` still moves the window. + */ + private fun sendAck(replayFrom: Int?) { + emit( + shellFrame(RemoteShell.OpCode.ACK) { wb -> + wb.session_id = sessionId + wb.seq = 0 + wb.ack_seq = lastRxSeq + wb.last_rx_seq = replayFrom?.let { it - 1 } ?: 0 + }, + ) + } + + private fun replayFrom(startSeq: Int, nowMs: Long) { + val sent = txHistory.firstOrNull { it.frame.seq == startSeq } + if (sent != null) { + resend(sent, nowMs) + return + } + val oldest = txHistory.firstOrNull()?.frame?.seq ?: return + if (startSeq in 1.. wb.ack_seq = lastRxSeq }.build()) + } + + private fun closeLocally(reason: String) { + emit( + shellFrame(RemoteShell.OpCode.CLOSE) { wb -> + wb.session_id = sessionId + wb.ack_seq = lastRxSeq + }, + ) + isClosed = true + events += ShellEvent.Closed(reason) + } + + /** Anything we send carries our receive cursor, so it settles the flow-control debt whatever its op. */ + private fun emit(frame: RemoteShell) { + framesSinceOutbound = 0 + out += frame + } + + private fun highestSentSeq(): Int = nextTxSeq - 1 + + // endregion + + // region --- Sequence bookkeeping --- + + private enum class RxAction { + PROCESS, + GAP, + DUPLICATE, + } + + @Suppress("ReturnCount") + private fun noteReceivedSeq(seq: Int): RxAction { + if (seq == 0) return RxAction.PROCESS + if (seq < nextExpectedRxSeq) { + return if (highestSeenRxSeq >= nextExpectedRxSeq) RxAction.GAP else RxAction.DUPLICATE + } + if (seq > nextExpectedRxSeq) { + if (seq > highestSeenRxSeq) highestSeenRxSeq = seq + return RxAction.GAP + } + lastRxSeq = seq + nextExpectedRxSeq = seq + 1 + if (lastRequestedMissingSeq != 0 && nextExpectedRxSeq > lastRequestedMissingSeq) { + lastRequestedMissingSeq = 0 + missingRequestIntervalMs = baseIntervalMs() + missingRequestAttempts = 0 + } + if (seq > highestSeenRxSeq) highestSeenRxSeq = seq + if (highestSeenRxSeq < nextExpectedRxSeq) highestSeenRxSeq = 0 + // Only an in-order frame moves the cursor the peer is waiting on. + framesSinceOutbound++ + return RxAction.PROCESS + } + + /** Lowest seqs are needed soonest, so when full the highest held is the one given up, and only for a lower one. */ + private fun rememberOutOfOrder(frame: RemoteShell) { + if (frame.seq <= nextExpectedRxSeq || frame.seq in pendingRx) return + if (pendingRx.size < REORDER_SLOTS) { + pendingRx[frame.seq] = frame + } else { + val highest = pendingRx.keys.max() + if (frame.seq < highest) { + pendingRx.remove(highest) + pendingRx[frame.seq] = frame + } + } + if (frame.seq > highestSeenRxSeq) highestSeenRxSeq = frame.seq + } + + @Suppress("ReturnCount") + private fun requestMissingSeqOnce(nowMs: Long): Int? { + if (highestSeenRxSeq < nextExpectedRxSeq) return null + val sameSeq = lastRequestedMissingSeq == nextExpectedRxSeq + if (sameSeq && nowMs - lastMissingRequestMs < missingRequestIntervalMs) return null + if (sameSeq) { + missingRequestAttempts++ + if (missingRequestAttempts > RETRY_FLAT_ATTEMPTS) { + missingRequestIntervalMs = (missingRequestIntervalMs * 2).coerceAtMost(RETRY_MAX_MS) + } + } else { + missingRequestAttempts = 1 + missingRequestIntervalMs = baseIntervalMs() + } + lastRequestedMissingSeq = nextExpectedRxSeq + lastMissingRequestMs = nowMs + return nextExpectedRxSeq + } + + /** Monotone and clamped to what we have sent, so a stale or confused cursor can neither close nor overgrant. */ + private fun notePeerCursor(rawCursor: Int, nowMs: Long) { + val cursor = rawCursor.coerceAtMost(highestSentSeq()) + if (cursor <= peerAcked) return + txHistory + .firstOrNull { it.frame.seq == cursor } + ?.let { sent -> + val sample = (nowMs - sent.sentMs).toDouble() + if (sample > 0) { + val current = ackLatencyMs + ackLatencyMs = if (current == null) sample else current + ACK_LATENCY_SMOOTHING * (sample - current) + } + } + peerAcked = cursor + nextRetransmitMs = 0L + retransmitSeq = 0 + retransmits = 0 + } + + private fun baseIntervalMs(): Long { + val latency = ackLatencyMs ?: return RETRY_MIN_MS + return (RETRANSMIT_LATENCY_MULTIPLIER * latency).toLong().coerceIn(RETRY_MIN_MS, RETRY_MAX_MS) + } + + // endregion + + private inline fun step(block: () -> Unit): ShellStep { + out.clear() + events.clear() + block() + return ShellStep(out.toList(), events.toList()) + } +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellScreen.kt new file mode 100644 index 0000000000..6c0d13d85f --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellScreen.kt @@ -0,0 +1,508 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics.terminal + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.remote_shell +import org.meshtastic.core.resources.remote_shell_command_hint +import org.meshtastic.core.resources.remote_shell_last_contact +import org.meshtastic.core.resources.remote_shell_menu_line_mode +import org.meshtastic.core.resources.remote_shell_menu_paste +import org.meshtastic.core.resources.remote_shell_menu_text_larger +import org.meshtastic.core.resources.remote_shell_menu_text_smaller +import org.meshtastic.core.resources.remote_shell_more_options +import org.meshtastic.core.resources.remote_shell_reconnect +import org.meshtastic.core.resources.remote_shell_send +import org.meshtastic.core.resources.remote_shell_status_closed +import org.meshtastic.core.resources.remote_shell_status_closing +import org.meshtastic.core.resources.remote_shell_status_failed +import org.meshtastic.core.resources.remote_shell_status_not_connected +import org.meshtastic.core.resources.remote_shell_status_opening +import org.meshtastic.core.resources.remote_shell_subtitle_round_trip +import org.meshtastic.core.ui.component.MainAppBar +import org.meshtastic.core.ui.icon.Add +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.More +import org.meshtastic.core.ui.icon.Remove +import org.meshtastic.core.ui.icon.Send +import org.meshtastic.core.ui.util.plainText +import org.meshtastic.feature.node.metrics.terminal.RemoteShellViewModel.InputMode +import org.meshtastic.feature.node.metrics.terminal.RemoteShellViewModel.SessionState + +private val TERMINAL_PADDING = 8.dp +private val BAR_PADDING = 4.dp +private val CHIP_SPACING = 8.dp + +/** Let the composition settle before taking focus, or the request is dropped. */ +private const val FOCUS_REQUEST_DELAY_MS = 100L + +/** Inbound silence, with something of ours in flight, before the screen says the node has gone quiet. */ +private const val STALE_AFTER_MS = 4_000L +private const val STALE_POLL_MS = 1_000L +private const val MS_PER_SECOND = 1_000L +private const val RECENT_HISTORY_CHIPS = 5 + +/** + * Terminal screen for a DMShell session. + * + * In character mode a zero-size field holds keyboard focus, so hardware keys and the soft keyboard both reach the + * session; what was sent shows underlined at the cursor until the node echoes it. In line mode a composer replaces it + * and each command goes out as one frame. The extra-keys rows serve both. + */ +@Suppress("LongMethod") +@Composable +fun RemoteShellScreen(viewModel: RemoteShellViewModel, onNavigateUp: () -> Unit, modifier: Modifier = Modifier) { + val screen by viewModel.screen.collectAsStateWithLifecycle() + val sessionState by viewModel.sessionState.collectAsStateWithLifecycle() + val health by viewModel.health.collectAsStateWithLifecycle() + val modifiers by viewModel.modifiers.collectAsStateWithLifecycle() + val inputMode by viewModel.inputMode.collectAsStateWithLifecycle() + val fontSize by viewModel.fontSizeSp.collectAsStateWithLifecycle() + val composer by viewModel.composer.collectAsStateWithLifecycle() + val history by viewModel.history.collectAsStateWithLifecycle() + + // The remote PTY wraps to whatever size we declare, so measure the viewport in monospace cells rather than + // shipping a hardcoded 80x24. Opening waits for the measurement so OPEN carries the real size. + var terminalSize by remember { mutableStateOf(IntSize.Zero) } + val (cols, rows) = rememberTerminalGrid(terminalSize, fontSize) + val grid by rememberUpdatedState(cols to rows) + val measuredGrid = remember { snapshotFlow { grid }.filter { (c, r) -> c > 0 && r > 0 }.distinctUntilChanged() } + LaunchedEffect(Unit) { measuredGrid.collect { (c, r) -> viewModel.resize(c, r) } } + + // Opens once, on the first measurement: the IME resizes the viewport, and reopening the session every time the + // keyboard moves would churn a session per keystroke burst. + LaunchedEffect(Unit) { + measuredGrid.first() + viewModel.openSession() + } + + val focusRequester = remember { FocusRequester() } + LaunchedEffect(inputMode) { + if (inputMode == InputMode.CHARACTER) { + delay(FOCUS_REQUEST_DELAY_MS) + focusRequester.requestFocus() + } + } + + val haptics = LocalHapticFeedback.current + LaunchedEffect(Unit) { viewModel.bell.collect { haptics.performHapticFeedback(HapticFeedbackType.LongPress) } } + + val roundTrip = health.roundTripMs + val subtitle = + if (roundTrip != null && sessionState == SessionState.OPEN) { + stringResource( + Res.string.remote_shell_subtitle_round_trip, + stringResource(Res.string.remote_shell), + roundTrip, + ) + } else { + stringResource(Res.string.remote_shell) + } + + Scaffold( + modifier = modifier, + topBar = { + MainAppBar( + title = viewModel.nodeLongName, + subtitle = subtitle, + ourNode = null, + showNodeChip = false, + canNavigateUp = true, + onNavigateUp = onNavigateUp, + actions = { + ShellMenu( + inputMode = inputMode, + onToggleLineMode = { + viewModel.setInputMode( + if (inputMode == InputMode.LINE) InputMode.CHARACTER else InputMode.LINE, + ) + }, + onPaste = viewModel::paste, + onFontSize = viewModel::adjustFontSize, + ) + }, + onClickChip = {}, + ) + }, + ) { paddingValues -> + Column(modifier = Modifier.fillMaxSize().padding(paddingValues).imePadding()) { + SessionStatusBar(state = sessionState, onReconnect = viewModel::openSession) + + Box(modifier = Modifier.weight(1f).fillMaxWidth().onSizeChanged { terminalSize = it }) { + TerminalPane( + screen = screen, + fontSizeSp = fontSize, + showCursor = inputMode == InputMode.CHARACTER, + onTap = { if (inputMode == InputMode.CHARACTER) focusRequester.requestFocus() }, + ) + if (inputMode == InputMode.CHARACTER) { + KeyboardSink( + focusRequester = focusRequester, + handler = + TerminalKeyHandler( + onChar = viewModel::typeKey, + onEnter = viewModel::typeEnter, + onBackspace = viewModel::typeBackspace, + onKey = viewModel::sendKey, + onChord = viewModel::typeChord, + ), + ) + } + if (sessionState == SessionState.OPEN) { + LastContactBanner(health, modifier = Modifier.align(Alignment.TopCenter)) + } + } + + if (inputMode == InputMode.LINE) { + CommandComposer( + composer = composer, + history = history, + masked = screen.secretPrompt, + actions = + ComposerActions( + onChange = viewModel::setComposer, + onInsert = viewModel::insertCommand, + onSubmit = viewModel::submitComposer, + onRecall = viewModel::recallHistory, + ), + ) + } + + // A tapped key cap can take keyboard focus, after which typing would reach nothing; give it straight back. + val refocus = { if (inputMode == InputMode.CHARACTER) focusRequester.requestFocus() } + ExtraKeysBar( + modifiers = modifiers, + onKey = { key -> + when { + inputMode == InputMode.LINE && key == TerminalKey.UP -> viewModel.recallHistory(older = true) + inputMode == InputMode.LINE && key == TerminalKey.DOWN -> viewModel.recallHistory(older = false) + else -> viewModel.sendKey(key) + } + refocus() + }, + onChar = { c -> + if (inputMode == InputMode.LINE && modifiers.isEmpty) { + viewModel.setComposer(composer + c) + } else { + viewModel.typeKey(c) + } + refocus() + }, + onToggleCtrl = { + viewModel.toggleCtrl() + refocus() + }, + onToggleAlt = { + viewModel.toggleAlt() + refocus() + }, + ) + } + } +} + +@Composable +private fun TerminalPane(screen: TerminalScreenState, fontSizeSp: Int, showCursor: Boolean, onTap: () -> Unit) { + val palette = TerminalPalette.from(MaterialTheme.colorScheme) + val listState = rememberLazyListState() + + // Follow new output only while the reader is at the bottom; scrolling up to read must not be yanked back. + var follow by remember { mutableStateOf(true) } + LaunchedEffect(listState) { + snapshotFlow { listState.isScrollInProgress to listState.canScrollForward } + .collect { (scrolling, canScrollForward) -> if (scrolling) follow = !canScrollForward } + } + LaunchedEffect(screen) { if (follow && screen.lines.isNotEmpty()) listState.scrollToItem(screen.lines.lastIndex) } + + val textStyle = TextStyle(fontFamily = FontFamily.Monospace, fontSize = fontSizeSp.sp, color = palette.foreground) + SelectionContainer { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().padding(TERMINAL_PADDING).clickable(onClick = onTap), + ) { + val last = screen.lines.lastIndex + itemsIndexed(screen.lines) { index, line -> + val text = + if (index == last) { + line.withCursor( + cursorColumn = screen.cursorColumn, + predicted = screen.predicted, + unsent = if (screen.typingVisible) screen.unsent else "", + showCursor = showCursor, + palette = palette, + ) + } else { + line.toAnnotatedString(palette) + } + Text(text = text, style = textStyle, modifier = Modifier.fillMaxWidth()) + } + } + } +} + +/** What the line-mode composer can do, grouped so the composable takes state and one handler. */ +private class ComposerActions( + val onChange: (String) -> Unit, + val onInsert: (String) -> Unit, + val onSubmit: () -> Unit, + val onRecall: (older: Boolean) -> Unit, +) + +@Composable +private fun CommandComposer(composer: String, history: List, masked: Boolean, actions: ComposerActions) { + val chips = remember(history) { (history.take(RECENT_HISTORY_CHIPS) + QUICK_COMMANDS).distinct() } + + Column(modifier = Modifier.fillMaxWidth()) { + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(CHIP_SPACING), + contentPadding = PaddingValues(horizontal = TERMINAL_PADDING), + ) { + items(chips) { command -> + AssistChip( + onClick = { actions.onInsert(command) }, + label = { Text(command, fontFamily = FontFamily.Monospace, maxLines = 1) }, + ) + } + } + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = TERMINAL_PADDING), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = composer, + onValueChange = actions.onChange, + modifier = Modifier.weight(1f).onPreviewKeyEvent { event -> composerKey(event, actions) }, + placeholder = { Text(stringResource(Res.string.remote_shell_command_hint)) }, + textStyle = TextStyle(fontFamily = FontFamily.Monospace), + singleLine = true, + // At a password prompt the answer must not be readable, nor offered to the keyboard's suggestions. + visualTransformation = if (masked) PasswordVisualTransformation() else VisualTransformation.None, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = if (masked) KeyboardType.Password else KeyboardType.Text, + imeAction = ImeAction.Send, + ), + keyboardActions = KeyboardActions(onSend = { actions.onSubmit() }), + ) + IconButton(onClick = actions.onSubmit) { + Icon(MeshtasticIcons.Send, contentDescription = stringResource(Res.string.remote_shell_send)) + } + } + } +} + +/** Up and Down walk local history, Enter sends; everything else is ordinary text editing. */ +private fun composerKey(event: KeyEvent, actions: ComposerActions): Boolean { + val handled = event.type == KeyEventType.KeyDown && event.key in COMPOSER_KEYS + if (handled) { + when (event.key) { + Key.DirectionUp -> actions.onRecall(true) + Key.DirectionDown -> actions.onRecall(false) + else -> actions.onSubmit() + } + } + return handled +} + +private val COMPOSER_KEYS = setOf(Key.DirectionUp, Key.DirectionDown, Key.Enter, Key.NumPadEnter) + +@Composable +private fun ShellMenu( + inputMode: InputMode, + onToggleLineMode: () -> Unit, + onPaste: (String) -> Unit, + onFontSize: (deltaSp: Int) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + Box { + IconButton(onClick = { expanded = true }) { + Icon(MeshtasticIcons.More, contentDescription = stringResource(Res.string.remote_shell_more_options)) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text(stringResource(Res.string.remote_shell_menu_line_mode)) }, + trailingIcon = { Checkbox(checked = inputMode == InputMode.LINE, onCheckedChange = null) }, + onClick = { + onToggleLineMode() + expanded = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(Res.string.remote_shell_menu_paste)) }, + onClick = { + expanded = false + scope.launch { clipboard.getClipEntry()?.plainText()?.let(onPaste) } + }, + ) + DropdownMenuItem( + text = { Text(stringResource(Res.string.remote_shell_menu_text_larger)) }, + leadingIcon = { Icon(MeshtasticIcons.Add, contentDescription = null) }, + onClick = { onFontSize(1) }, + ) + DropdownMenuItem( + text = { Text(stringResource(Res.string.remote_shell_menu_text_smaller)) }, + leadingIcon = { Icon(MeshtasticIcons.Remove, contentDescription = null) }, + onClick = { onFontSize(-1) }, + ) + } + } +} + +/** Reports what the session is doing; without it a refused or stalled session is just a blank screen. */ +@Composable +private fun SessionStatusBar(state: SessionState, onReconnect: () -> Unit) { + val label = + when (state) { + SessionState.OPEN -> return + SessionState.IDLE -> Res.string.remote_shell_status_not_connected + SessionState.OPENING -> Res.string.remote_shell_status_opening + SessionState.CLOSING -> Res.string.remote_shell_status_closing + SessionState.CLOSED -> Res.string.remote_shell_status_closed + SessionState.ERROR -> Res.string.remote_shell_status_failed + } + val reconnectable = state == SessionState.CLOSED || state == SessionState.ERROR || state == SessionState.IDLE + StatusRow(text = stringResource(label)) { + if (reconnectable) TextButton(onClick = onReconnect) { Text(stringResource(Res.string.remote_shell_reconnect)) } + } +} + +/** + * Mosh's "last contact" line, shown once a frame of ours has gone unacknowledged for [STALE_AFTER_MS], so a slow mesh + * reads as slow rather than broken. It overlays the terminal instead of taking a row: a banner that resized the + * viewport would resize the remote PTY every time it came and went. + */ +@Composable +private fun LastContactBanner(health: LinkHealth, modifier: Modifier = Modifier) { + var now by remember { mutableLongStateOf(nowMillis) } + LaunchedEffect(Unit) { + while (true) { + delay(STALE_POLL_MS) + now = nowMillis + } + } + val waitingSince = health.waitingSinceMs ?: return + if (now - waitingSince < STALE_AFTER_MS) return + val silentSeconds = ((now - health.lastInboundMs) / MS_PER_SECOND).toInt() + StatusRow(text = stringResource(Res.string.remote_shell_last_contact, silentSeconds), modifier = modifier) +} + +@Composable +private fun StatusRow(text: String, modifier: Modifier = Modifier, trailing: @Composable () -> Unit = {}) { + Row( + modifier = + modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = TERMINAL_PADDING, vertical = BAR_PADDING), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + trailing() + } +} + +/** Viewport size in monospace cells, so the remote PTY can be told how wide to wrap. */ +@Composable +private fun rememberTerminalGrid(size: IntSize, fontSizeSp: Int): Pair { + val textMeasurer = rememberTextMeasurer() + val cell = + remember(textMeasurer, fontSizeSp) { + textMeasurer.measure("0", TextStyle(fontFamily = FontFamily.Monospace, fontSize = fontSizeSp.sp)).size + } + if (cell.width <= 0 || cell.height <= 0) return 0 to 0 + return (size.width / cell.width) to (size.height / cell.height) +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellViewModel.kt new file mode 100644 index 0000000000..bccf3524f3 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellViewModel.kt @@ -0,0 +1,499 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics.terminal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okio.ByteString.Companion.encodeUtf8 +import okio.ByteString.Companion.toByteString +import org.jetbrains.compose.resources.getString +import org.koin.core.annotation.InjectedParam +import org.koin.core.annotation.KoinViewModel +import org.meshtastic.core.common.di.ApplicationCoroutineScope +import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.common.util.safeCatching +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.NodeAddress +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.RemoteShellHandler +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.remote_shell_error_notice +import org.meshtastic.core.resources.remote_shell_full_screen +import org.meshtastic.core.resources.remote_shell_input_dropped +import org.meshtastic.core.resources.remote_shell_no_reply +import org.meshtastic.core.resources.remote_shell_no_reply_reason +import org.meshtastic.core.resources.remote_shell_session_closed +import org.meshtastic.core.resources.remote_shell_session_closed_reason +import org.meshtastic.core.ui.viewmodel.safeLaunch +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.RemoteShell + +private const val MAX_OUTPUT_LINES = 1_000 +private const val DEFAULT_COLS = 80 +private const val DEFAULT_ROWS = 24 + +/** Keystroke debounce in character mode, matching the python client's `INPUT_BATCH_WINDOW_SEC`. */ +private const val FLUSH_WINDOW_MS = 500L + +/** How often [RemoteShellLink.tick] runs: OPEN retries, a shut window, the heartbeat, prediction expiry. */ +private const val TICK_MS = 250L + +private const val MAX_HISTORY = 50 +private const val DEL = "\u007f" + +internal const val FONT_SIZE_DEFAULT_SP = 13 +internal const val FONT_SIZE_MIN_SP = 9 +internal const val FONT_SIZE_MAX_SP = 22 + +/** Commands worth one tap on a meshtasticd host; inserted into the composer, never sent unseen. */ +internal val QUICK_COMMANDS = + listOf( + "uptime", + "df -h", + "free -h", + "ip -br addr", + "systemctl status meshtasticd --no-pager", + "journalctl -u meshtasticd -n 30 --no-pager", + ) + +/** + * Terminal session against the firmware DMShell module. + * + * The protocol lives in [RemoteShellLink], the screen model in [TerminalOutput] and predictive echo in [LocalEcho]; all + * three are mutated only under [linkMutex], so a frame, a tick and a keystroke never interleave. This class owns the + * clock, the radio and the UI state. + * + * Two input modes. Character mode streams keystrokes (debounced into one frame per burst) so tab completion, line + * editing and prompts behave as on any terminal, and draws what was sent with [LocalEcho] until the node echoes it. + * Line mode composes the whole command locally and sends it in one frame, the cheapest way to spend airtime; it keeps a + * local history so recalling a command costs no round trip. + */ +@Suppress("TooManyFunctions") +@KoinViewModel +class RemoteShellViewModel( + @InjectedParam val destNum: Int, + private val dispatchers: CoroutineDispatchers, + private val nodeRepository: NodeRepository, + private val commandSender: CommandSender, + private val remoteShellHandler: RemoteShellHandler, + private val applicationScope: ApplicationCoroutineScope, +) : ViewModel() { + + enum class SessionState { + IDLE, + OPENING, + OPEN, + CLOSING, + CLOSED, + ERROR, + } + + enum class InputMode { + CHARACTER, + LINE, + } + + private val _sessionState = MutableStateFlow(SessionState.IDLE) + val sessionState: StateFlow = _sessionState.asStateFlow() + + private val screenState = MutableStateFlow(TerminalScreenState()) + internal val screen: StateFlow = screenState.asStateFlow() + + private val _health = MutableStateFlow(LinkHealth()) + val health: StateFlow = _health.asStateFlow() + + private val _modifiers = MutableStateFlow(Modifiers()) + val modifiers: StateFlow = _modifiers.asStateFlow() + + private val _inputMode = MutableStateFlow(InputMode.CHARACTER) + val inputMode: StateFlow = _inputMode.asStateFlow() + + private val _composer = MutableStateFlow("") + val composer: StateFlow = _composer.asStateFlow() + + private val _history = MutableStateFlow>(emptyList()) + val history: StateFlow> = _history.asStateFlow() + private var historyCursor = -1 + + private val _fontSizeSp = MutableStateFlow(FONT_SIZE_DEFAULT_SP) + val fontSizeSp: StateFlow = _fontSizeSp.asStateFlow() + + private val _bell = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val bell: SharedFlow = _bell.asSharedFlow() + + val nodeLongName: String + get() = nodeRepository.nodeDBbyNum.value[destNum]?.user?.long_name ?: destNum.toString() + + private val linkMutex = Mutex() + private var link: RemoteShellLink? = null + private val output = TerminalOutput(MAX_OUTPUT_LINES) + private val echo = LocalEcho() + private var tickJob: Job? = null + + /** Character mode: typed but not yet handed to the link, still editable locally. */ + private val inputBuffer = StringBuilder() + private var flushJob: Job? = null + + private var cols = DEFAULT_COLS + private var rows = DEFAULT_ROWS + + init { + safeLaunch(context = dispatchers.io, tag = "remoteShellFrameCollector") { + remoteShellHandler.lastFrame.collect { (from, frame) -> + if (from == destNum) withLink { it.receive(frame, nowMillis) } + } + } + } + + // region --- Session control --- + + fun openSession() { + if (_sessionState.value !in OPENABLE_STATES) return + _sessionState.value = SessionState.OPENING + safeLaunch(context = dispatchers.io, tag = "remoteShellOpen") { + val fresh = RemoteShellLink(sessionId = commandSender.generatePacketId()) + linkMutex.withLock { link = fresh } + withLink { it.open(cols, rows, nowMillis) } + startTicking() + } + } + + fun closeSession() { + if (_sessionState.value != SessionState.OPEN) return + _sessionState.value = SessionState.CLOSING + safeLaunch(context = dispatchers.io, tag = "remoteShellClose") { withLink { it.close() } } + } + + fun resize(cols: Int, rows: Int) { + this.cols = cols + this.rows = rows + safeLaunch(context = dispatchers.io, tag = "remoteShellResize") { + withLink { it.resize(cols, rows, nowMillis) } + } + } + + private fun startTicking() { + tickJob?.cancel() + tickJob = + safeLaunch(context = dispatchers.io, tag = "remoteShellTick") { + while (true) { + delay(TICK_MS) + val closed = withLink { link -> + val now = nowMillis + echo.tick(now, link.health.roundTripMs) + link.tick(now) + } + if (closed) break + } + } + } + + // endregion + + // region --- Character-mode input --- + + /** A typed character. With a sticky modifier armed it is sent at once as the modified byte(s). */ + fun typeKey(char: Char) { + val mods = _modifiers.value + if (!mods.isEmpty) { + _modifiers.value = mods.consumed() + sendNow(char.withModifiers(mods)) + return + } + inputBuffer.append(char) + publishPending() + when { + char == '\t' -> flushBuffer() + inputBuffer.length >= MAX_INPUT_CHUNK_BYTES -> flushBuffer() + else -> scheduleFlush() + } + } + + fun typeEnter() { + inputBuffer.append('\r') + flushBuffer() + } + + /** Edits the unsent buffer while there is one; past it, the remote line, by sending DEL. */ + fun typeBackspace() { + if (inputBuffer.isEmpty()) { + sendNow(DEL) + return + } + inputBuffer.deleteAt(inputBuffer.lastIndex) + publishPending() + if (inputBuffer.isEmpty()) { + flushJob?.cancel() + flushJob = null + } else { + scheduleFlush() + } + } + + /** An extra-keys or hardware key. Pending typing goes first so the bytes reach the PTY in the order pressed. */ + fun sendKey(key: TerminalKey) { + val mods = _modifiers.value + _modifiers.value = mods.consumed() + val sequence = key.sequence(screenState.value.applicationCursorKeys) + sendNow(if (mods.alt != ModifierState.OFF) "\u001b$sequence" else sequence) + } + + /** A hardware Ctrl/Alt chord. Any armed sticky modifier applies on top, as it would to a typed key. */ + fun typeChord(char: Char, ctrl: Boolean, alt: Boolean) { + val sticky = _modifiers.value + _modifiers.value = sticky.consumed() + val chord = + Modifiers( + ctrl = if (ctrl || sticky.ctrl != ModifierState.OFF) ModifierState.ONCE else ModifierState.OFF, + alt = if (alt || sticky.alt != ModifierState.OFF) ModifierState.ONCE else ModifierState.OFF, + ) + sendNow(char.withModifiers(chord)) + } + + fun toggleCtrl() = _modifiers.update { it.copy(ctrl = it.ctrl.next()) } + + fun toggleAlt() = _modifiers.update { it.copy(alt = it.alt.next()) } + + /** Clipboard text: typed in character mode, appended to the composer in line mode. */ + fun paste(text: String) { + if (text.isEmpty()) return + if (_inputMode.value == InputMode.LINE) { + _composer.update { it + text } + } else { + sendNow(text.replace("\r\n", "\r").replace('\n', '\r')) + } + } + + private fun scheduleFlush() { + flushJob?.cancel() + flushJob = viewModelScope.launch { + delay(FLUSH_WINDOW_MS) + flushBuffer() + } + } + + private fun flushBuffer() { + flushJob?.cancel() + flushJob = null + val text = inputBuffer.toString() + inputBuffer.clear() + publishPending() + if (text.isNotEmpty()) send(text) + } + + private fun sendNow(text: String) { + val pending = inputBuffer.toString() + inputBuffer.clear() + flushJob?.cancel() + flushJob = null + publishPending() + send(pending + text) + } + + private fun send(text: String) { + if (_sessionState.value != SessionState.OPEN) return + safeLaunch(context = dispatchers.io, tag = "remoteShellInput") { + withLink { link -> + val now = nowMillis + echo.onSent(text, now) + link.input(text.encodeUtf8(), now) + } + } + } + + // endregion + + // region --- Line mode --- + + fun setInputMode(mode: InputMode) { + if (mode == InputMode.LINE) flushBuffer() + _inputMode.value = mode + } + + fun setComposer(text: String) { + _composer.value = text + historyCursor = -1 + } + + fun insertCommand(command: String) = setComposer(command) + + /** Sends the composed line in one frame; an empty line still sends Enter, which a prompt may be waiting for. */ + fun submitComposer() { + val line = _composer.value + _composer.value = "" + historyCursor = -1 + // An answer to a password prompt is not a command, and history would keep it on screen as a chip. + if (line.isNotBlank() && !screenState.value.secretPrompt) { + _history.update { (listOf(line) + it.filterNot { old -> old == line }).take(MAX_HISTORY) } + } + sendNow(line + "\r") + } + + /** Steps the composer through local history: [older] true walks back, false walks forward to an empty line. */ + fun recallHistory(older: Boolean) { + val entries = _history.value + if (entries.isEmpty()) return + historyCursor = (if (older) historyCursor + 1 else historyCursor - 1).coerceIn(-1, entries.lastIndex) + _composer.value = if (historyCursor < 0) "" else entries[historyCursor] + } + + // endregion + + fun adjustFontSize(deltaSp: Int) = _fontSizeSp.update { + (it + deltaSp).coerceIn(FONT_SIZE_MIN_SP, FONT_SIZE_MAX_SP) + } + + // region --- Link plumbing --- + + /** + * Runs [block] against the live link and applies what it produced, all under [linkMutex], then sends its frames. + * Returns true once the link is closed or gone. + */ + private suspend fun withLink(block: (RemoteShellLink) -> ShellStep): Boolean { + val (step, closed) = + linkMutex.withLock { + val current = link ?: return true + val step = block(current) + step.events.forEach { applyEvent(it) } + _health.value = current.health + publishScreen() + step to current.isClosed + } + step.send.forEach(::transmit) + return closed + } + + /** Caller holds [linkMutex]. */ + private suspend fun applyEvent(event: ShellEvent) { + when (event) { + ShellEvent.Opened -> { + _sessionState.value = SessionState.OPEN + Logger.i { "RemoteShell opened with $destNum" } + } + + is ShellEvent.Output -> { + val result = output.append(event.bytes) + echo.onOutput(result.echo, nowMillis) + if (result.bell) _bell.tryEmit(Unit) + if (result.enteredFullScreen) output.notice(getString(Res.string.remote_shell_full_screen)) + } + + is ShellEvent.RemoteError -> + output.notice(getString(Res.string.remote_shell_error_notice, event.message.ifEmpty { "?" })) + + is ShellEvent.InputDropped -> output.notice(getString(Res.string.remote_shell_input_dropped, event.bytes)) + + is ShellEvent.Closed -> { + val wasOpening = _sessionState.value == SessionState.OPENING + output.notice(closedNotice(event.reason, wasOpening)) + _sessionState.value = if (wasOpening) SessionState.ERROR else SessionState.CLOSED + } + } + } + + // A node that has not authorized us drops OPEN without replying, and so does one out of range. + private suspend fun closedNotice(reason: String, wasOpening: Boolean): String = when { + wasOpening && reason.isEmpty() -> getString(Res.string.remote_shell_no_reply) + wasOpening -> getString(Res.string.remote_shell_no_reply_reason, reason) + reason.isEmpty() -> getString(Res.string.remote_shell_session_closed) + else -> getString(Res.string.remote_shell_session_closed_reason, reason) + } + + /** Caller holds [linkMutex]. */ + private fun publishScreen() { + val lines = output.lines() + val secret = looksLikeSecretPrompt(lines.last().text.take(output.cursorColumn)) + screenState.update { + it.copy( + lines = lines, + cursorColumn = output.cursorColumn, + applicationCursorKeys = output.applicationCursorKeys, + predicted = if (secret) "" else echo.pending, + typingVisible = echo.showsTyping && !secret, + secretPrompt = secret, + ) + } + } + + private fun publishPending() = screenState.update { it.copy(unsent = inputBuffer.toString()) } + + private fun transmit(frame: RemoteShell) { + val myNum = nodeRepository.myNodeInfo.value?.myNodeNum ?: 0 + val packet = + DataPacket( + to = NodeAddress.numToDefaultId(destNum), + from = NodeAddress.numToDefaultId(myNum), + bytes = RemoteShell.ADAPTER.encode(frame).toByteString(), + dataType = PortNum.REMOTE_SHELL_APP.value, + channel = NodeAddress.PKC_CHANNEL_INDEX, + wantAck = false, + ) + // Not viewModelScope: the CLOSE sent from onCleared would otherwise never leave. + applicationScope.launch(dispatchers.io) { + safeCatching { commandSender.sendData(packet) } + .onFailure { Logger.w(it) { "RemoteShell send failed op=${frame.op} seq=${frame.seq}" } } + } + } + + // endregion + + override fun onCleared() { + super.onCleared() + tickJob?.cancel() + // viewModelScope is already cancelled, and a step already running on it is not interrupted by that, so the + // close takes linkMutex on applicationScope like every other caller of the link. + applicationScope.launch(dispatchers.io) { + val frames = linkMutex.withLock { link?.takeIf { !it.isClosed }?.close()?.send.orEmpty() } + frames.forEach(::transmit) + } + Logger.d { "RemoteShellViewModel cleared for destNum=$destNum" } + } + + private companion object { + val OPENABLE_STATES = setOf(SessionState.IDLE, SessionState.CLOSED, SessionState.ERROR) + } +} + +/** + * Everything the terminal pane draws. [predicted] was sent and awaits echo; [unsent] is still in the debounce. Neither + * is drawn unless [typingVisible]: the line has confirmed that the remote echoes, and is not a [secretPrompt]. + */ +internal data class TerminalScreenState( + val lines: List = listOf(TerminalLine.Empty), + val cursorColumn: Int = 0, + val applicationCursorKeys: Boolean = false, + val predicted: String = "", + val unsent: String = "", + val typingVisible: Boolean = false, + val secretPrompt: Boolean = false, +) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalKeys.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalKeys.kt new file mode 100644 index 0000000000..64172c3074 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalKeys.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics.terminal + +/** Keys a soft keyboard lacks and a shell needs. */ +enum class TerminalKey { + ESCAPE, + TAB, + UP, + DOWN, + LEFT, + RIGHT, + HOME, + END, + PAGE_UP, + PAGE_DOWN, + DELETE, +} + +/** Sticky modifiers from the extra-keys row: each applies to the next key, then releases unless locked. */ +data class Modifiers(val ctrl: ModifierState = ModifierState.OFF, val alt: ModifierState = ModifierState.OFF) { + val isEmpty: Boolean + get() = ctrl == ModifierState.OFF && alt == ModifierState.OFF + + /** What remains after a key has used them: one-shot modifiers release, locked ones stay. */ + fun consumed(): Modifiers = Modifiers(ctrl.afterUse(), alt.afterUse()) +} + +enum class ModifierState { + OFF, + ONCE, + LOCKED, + ; + + /** A tap cycles off -> once -> locked -> off, as Termux and Blink do. */ + fun next(): ModifierState = when (this) { + OFF -> ONCE + ONCE -> LOCKED + LOCKED -> OFF + } + + fun afterUse(): ModifierState = if (this == ONCE) OFF else this +} + +private const val ESC = "\u001b" +private const val CTRL_MASK = 0x1f +private const val DEL = '\u007f' + +/** What one edit to the keyboard sink typed: [deleted] backspaces, then [inserted] text. */ +internal data class SinkEdit(val deleted: Int, val inserted: String) + +/** One changed range of the keyboard sink: [replacedLength] characters of the old text gave way to [inserted]. */ +internal class SinkChange(val replacedLength: Int, val inserted: String) + +/** + * The input a set of sink changes amounts to. A change that only removes text is a backspace; one that inserts text + * types exactly what it inserted, so an IME replacing the sentinel, or a paste over it, sends no stray backspace and + * keeps a leading space. + */ +internal fun sinkEdit(changes: List): SinkEdit { + var deleted = 0 + val inserted = StringBuilder() + for (change in changes) { + if (change.inserted.isEmpty()) deleted += change.replacedLength else inserted.append(change.inserted) + } + return SinkEdit(deleted, inserted.toString()) +} + +/** The bytes a VT100-family terminal sends for a key, honouring DECCKM for the cursor keys. */ +internal fun TerminalKey.sequence(applicationCursorKeys: Boolean): String { + val cursorPrefix = if (applicationCursorKeys) "${ESC}O" else "$ESC[" + return when (this) { + TerminalKey.ESCAPE -> ESC + TerminalKey.TAB -> "\t" + TerminalKey.UP -> "${cursorPrefix}A" + TerminalKey.DOWN -> "${cursorPrefix}B" + TerminalKey.RIGHT -> "${cursorPrefix}C" + TerminalKey.LEFT -> "${cursorPrefix}D" + TerminalKey.HOME -> "${cursorPrefix}H" + TerminalKey.END -> "${cursorPrefix}F" + TerminalKey.PAGE_UP -> "$ESC[5~" + TerminalKey.PAGE_DOWN -> "$ESC[6~" + TerminalKey.DELETE -> "$ESC[3~" + } +} + +/** + * A typed character with [modifiers] applied: Ctrl maps letters and `@[\]^_?` to their C0 codes (`?` to DEL), and Alt + * prefixes ESC, as xterm's default `metaSendsEscape` does. + */ +internal fun Char.withModifiers(modifiers: Modifiers): String { + var c = this + if (modifiers.ctrl != ModifierState.OFF) { + c = + when (val upper = c.uppercaseChar()) { + in 'A'..'Z', + '@', + '[', + '\\', + ']', + '^', + '_', + -> (upper.code and CTRL_MASK).toChar() + + '?' -> DEL + + ' ' -> '\u0000' + + else -> c + } + } + return if (modifiers.alt != ModifierState.OFF) "$ESC$c" else c.toString() +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalKeysUi.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalKeysUi.kt new file mode 100644 index 0000000000..18ef3b4056 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalKeysUi.kt @@ -0,0 +1,334 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics.terminal + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isAltPressed +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.remote_shell_key_alt +import org.meshtastic.core.resources.remote_shell_key_ctrl +import org.meshtastic.core.resources.remote_shell_key_down +import org.meshtastic.core.resources.remote_shell_key_end +import org.meshtastic.core.resources.remote_shell_key_escape +import org.meshtastic.core.resources.remote_shell_key_home +import org.meshtastic.core.resources.remote_shell_key_left +import org.meshtastic.core.resources.remote_shell_key_page_down +import org.meshtastic.core.resources.remote_shell_key_page_up +import org.meshtastic.core.resources.remote_shell_key_right +import org.meshtastic.core.resources.remote_shell_key_tab +import org.meshtastic.core.resources.remote_shell_key_up +import org.meshtastic.core.resources.remote_shell_modifier_locked +import org.meshtastic.core.resources.remote_shell_modifier_once + +private val KEY_HEIGHT = 48.dp +private val KEY_SPACING = 2.dp +private val KEY_LABEL_SIZE = 13.sp + +/** What the keyboard sink always holds between edits. */ +private const val SINK_SENTINEL = " " + +private sealed interface ExtraKey { + data class Special(val key: TerminalKey, val label: String, val description: StringResource) : ExtraKey + + data class Typed(val char: Char) : ExtraKey + + data object Ctrl : ExtraKey + + data object Alt : ExtraKey +} + +/** Termux's default extra-keys layout: the two rows its users already have in their thumbs. */ +private val EXTRA_KEY_ROWS: List> = + listOf( + listOf( + ExtraKey.Special(TerminalKey.ESCAPE, "ESC", Res.string.remote_shell_key_escape), + ExtraKey.Typed('/'), + ExtraKey.Typed('-'), + ExtraKey.Special(TerminalKey.HOME, "HOME", Res.string.remote_shell_key_home), + ExtraKey.Special(TerminalKey.UP, "↑", Res.string.remote_shell_key_up), + ExtraKey.Special(TerminalKey.END, "END", Res.string.remote_shell_key_end), + ExtraKey.Special(TerminalKey.PAGE_UP, "PGUP", Res.string.remote_shell_key_page_up), + ), + listOf( + ExtraKey.Special(TerminalKey.TAB, "TAB", Res.string.remote_shell_key_tab), + ExtraKey.Ctrl, + ExtraKey.Alt, + ExtraKey.Special(TerminalKey.LEFT, "←", Res.string.remote_shell_key_left), + ExtraKey.Special(TerminalKey.DOWN, "↓", Res.string.remote_shell_key_down), + ExtraKey.Special(TerminalKey.RIGHT, "→", Res.string.remote_shell_key_right), + ExtraKey.Special(TerminalKey.PAGE_DOWN, "PGDN", Res.string.remote_shell_key_page_down), + ), + ) + +/** + * The extra-keys rows. CTRL and ALT are sticky: one tap applies to the next key, a second tap locks them, a third + * releases. + */ +@Composable +internal fun ExtraKeysBar( + modifiers: Modifiers, + onKey: (TerminalKey) -> Unit, + onChar: (Char) -> Unit, + onToggleCtrl: () -> Unit, + onToggleAlt: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth().padding(horizontal = KEY_SPACING)) { + EXTRA_KEY_ROWS.forEach { row -> + Row(modifier = Modifier.fillMaxWidth()) { + row.forEach { key -> + when (key) { + is ExtraKey.Special -> + KeyCap(label = key.label, description = stringResource(key.description)) { onKey(key.key) } + + is ExtraKey.Typed -> + KeyCap(label = key.char.toString(), description = null) { onChar(key.char) } + + ExtraKey.Ctrl -> + KeyCap( + label = "CTRL", + description = stringResource(Res.string.remote_shell_key_ctrl), + state = modifiers.ctrl, + onClick = onToggleCtrl, + ) + + ExtraKey.Alt -> + KeyCap( + label = "ALT", + description = stringResource(Res.string.remote_shell_key_alt), + state = modifiers.alt, + onClick = onToggleAlt, + ) + } + } + } + } + } +} + +@Composable +private fun RowScope.KeyCap(label: String, description: String?, state: ModifierState? = null, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val (container, content) = + when (state) { + ModifierState.ONCE -> colors.primaryContainer to colors.onPrimaryContainer + ModifierState.LOCKED -> colors.primary to colors.onPrimary + else -> Color.Transparent to colors.primary + } + val stateText = + when (state) { + ModifierState.ONCE -> stringResource(Res.string.remote_shell_modifier_once) + ModifierState.LOCKED -> stringResource(Res.string.remote_shell_modifier_locked) + else -> null + } + Box( + modifier = + Modifier.weight(1f) + .height(KEY_HEIGHT) + .padding(KEY_SPACING) + .background(container, MaterialTheme.shapes.small) + .clickable(role = Role.Button, onClick = onClick) + .semantics { + description?.let { contentDescription = it } + stateText?.let { stateDescription = it } + }, + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = content, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + fontSize = KEY_LABEL_SIZE, + maxLines = 1, + ) + } +} + +/** Hardware keys a terminal must send itself, rather than let the text field interpret. */ +private val NAVIGATION_KEYS = + mapOf( + Key.DirectionUp to TerminalKey.UP, + Key.DirectionDown to TerminalKey.DOWN, + Key.DirectionLeft to TerminalKey.LEFT, + Key.DirectionRight to TerminalKey.RIGHT, + Key.MoveHome to TerminalKey.HOME, + Key.MoveEnd to TerminalKey.END, + Key.PageUp to TerminalKey.PAGE_UP, + Key.PageDown to TerminalKey.PAGE_DOWN, + Key.Escape to TerminalKey.ESCAPE, + Key.Delete to TerminalKey.DELETE, + ) + +private val LETTER_KEYS = + listOf( + Key.A, + Key.B, + Key.C, + Key.D, + Key.E, + Key.F, + Key.G, + Key.H, + Key.I, + Key.J, + Key.K, + Key.L, + Key.M, + Key.N, + Key.O, + Key.P, + Key.Q, + Key.R, + Key.S, + Key.T, + Key.U, + Key.V, + Key.W, + Key.X, + Key.Y, + Key.Z, + ) + .mapIndexed { i, key -> key to ('a' + i) } + .toMap() + +/** Callbacks from the keyboard sink, grouped so the composable stays readable. */ +internal class TerminalKeyHandler( + val onChar: (Char) -> Unit, + val onEnter: () -> Unit, + val onBackspace: () -> Unit, + val onKey: (TerminalKey) -> Unit, + val onChord: (Char, ctrl: Boolean, alt: Boolean) -> Unit, +) + +/** + * Zero-size field that holds keyboard focus so both hardware keys and the soft keyboard reach the session. + * + * It never holds what was typed. Each edit - a typed character, an IME commit, a soft-keyboard backspace, a paste - is + * read as terminal input and reverted in the same transformation, so the field stays at [SINK_SENTINEL] with the caret + * at its end; the sentinel is there so a soft backspace has something to delete. Keys the terminal sends itself are + * taken in the preview pass, before the field could move its caret or edit with them. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun KeyboardSink(focusRequester: FocusRequester, handler: TerminalKeyHandler) { + val state = rememberTextFieldState(SINK_SENTINEL) + val currentHandler by rememberUpdatedState(handler) + val transformation = remember { + InputTransformation { + val text = asCharSequence() + val edit = + sinkEdit( + (0 until changes.changeCount).map { i -> + val range = changes.getRange(i) + SinkChange( + changes.getOriginalRange(i).length, + text.subSequence(range.min, range.max).toString(), + ) + }, + ) + revertAllChanges() + repeat(edit.deleted) { currentHandler.onBackspace() } + edit.inserted.forEach { c -> + if (c == '\n' || c == '\r') currentHandler.onEnter() else currentHandler.onChar(c) + } + } + } + BasicTextField( + state = state, + inputTransformation = transformation, + modifier = + Modifier.size(1.dp).focusRequester(focusRequester).onPreviewKeyEvent { handleKey(it, currentHandler) }, + textStyle = TextStyle(color = Color.Transparent, fontSize = 1.sp), + cursorBrush = SolidColor(Color.Transparent), + // No suggestions or composing: an IME rewriting a word in place would replay it as keystrokes. + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + ), + ) +} + +/** Handles the key if it is one the terminal sends itself; returns whether it did. */ +private fun handleKey(event: KeyEvent, handler: TerminalKeyHandler): Boolean { + val navigation = NAVIGATION_KEYS[event.key] + val chordLetter = LETTER_KEYS[event.key]?.takeIf { event.isCtrlPressed || event.isAltPressed } + val isEnter = event.key == Key.Enter || event.key == Key.NumPadEnter + val handled = + event.type == KeyEventType.KeyDown && + (navigation != null || chordLetter != null || isEnter || event.key in SINK_EDIT_KEYS) + if (handled) { + when { + navigation != null -> handler.onKey(navigation) + chordLetter != null -> handler.onChord(chordLetter, event.isCtrlPressed, event.isAltPressed) + isEnter -> handler.onEnter() + event.key == Key.Tab -> handler.onKey(TerminalKey.TAB) + else -> handler.onBackspace() + } + } + return handled +} + +private val SINK_EDIT_KEYS = setOf(Key.Tab, Key.Backspace) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalOutput.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalOutput.kt new file mode 100644 index 0000000000..3fa75d5e74 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalOutput.kt @@ -0,0 +1,534 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics.terminal + +import okio.ByteString + +/** Colour of a cell: [DEFAULT_COLOR], an xterm palette index 0-255, or [TRUECOLOR_FLAG] or-ed with 0xRRGGBB. */ +internal const val DEFAULT_COLOR = -1 +internal const val TRUECOLOR_FLAG = 0x1000000 + +internal data class CellStyle( + val fg: Int = DEFAULT_COLOR, + val bg: Int = DEFAULT_COLOR, + val bold: Boolean = false, + val dim: Boolean = false, + val italic: Boolean = false, + val underline: Boolean = false, + val inverse: Boolean = false, +) { + companion object { + val Plain = CellStyle() + } +} + +/** A run of [style] over `[start, end)` of a [TerminalLine]'s text. */ +internal data class StyleRun(val start: Int, val end: Int, val style: CellStyle) + +internal data class TerminalLine(val text: String, val runs: List, val isNotice: Boolean = false) { + companion object { + val Empty = TerminalLine("", emptyList()) + } +} + +/** What one chunk of output did, for the parts of the UI that react to it rather than render it. */ +internal sealed interface EchoEvent { + /** A printable character written at the cursor. */ + data class Printed(val char: Char) : EchoEvent + + /** Anything that moved or rewrote the line other than printing: CR, LF, BS, an erase, a cursor move. */ + data object Edited : EchoEvent +} + +internal class AppendResult(val echo: List, val bell: Boolean, val enteredFullScreen: Boolean) + +private const val ESC = '\u001b' +private const val BEL = '\u0007' +private const val TAB_STOP = 8 + +/** Longest parameter string kept for one CSI sequence; a hostile or broken stream cannot grow it past this. */ +private const val MAX_CSI_LENGTH = 64 + +/** + * Widest a line may grow. Cursor and insert parameters come from the remote, so `ESC[999999999C` must not be able to + * pad a line with a billion cells; printing past it wraps to a new line. + */ +internal const val MAX_LINE_WIDTH = 1024 +private const val SGR_TRUECOLOR = 2 +private const val SGR_PALETTE = 5 +private const val SGR_RGB_ARGS = 3 +private const val MAX_PALETTE = 255 +private const val BYTE_SHIFT = 8 +private const val ALT_SCREEN_1049 = 1049 +private const val ALT_SCREEN_47 = 47 +private const val ALT_SCREEN_1047 = 1047 +private const val DECCKM = 1 +private const val ERASE_TO_END = 0 +private const val ERASE_TO_START = 1 +private const val ERASE_ALL = 2 + +private const val UTF8_CONTINUATION_MASK = 0xC0 +private const val UTF8_CONTINUATION = 0x80 +private const val UTF8_LEAD_2 = 0xE0 +private const val UTF8_LEAD_2_BITS = 0xC0 +private const val UTF8_LEAD_3 = 0xF0 +private const val UTF8_LEAD_3_BITS = 0xE0 +private const val UTF8_LEAD_4 = 0xF8 +private const val UTF8_LEAD_4_BITS = 0xF0 +private const val BYTE_MASK = 0xFF +private const val UTF8_LEN_2 = 2 +private const val UTF8_LEN_3 = 3 +private const val UTF8_LEN_4 = 4 + +/** + * PTY output as styled lines - a line-oriented subset of a VT100/xterm screen. + * + * It keeps what a shell over a slow link actually produces: SGR colour and attributes, `\r` overwrite, backspace, tabs, + * erase-in-line, horizontal cursor moves, insert/delete characters and `clear`. Vertical cursor addressing and the + * alternate screen are not modelled - a full-screen program redraws a whole screen per keystroke, which the mesh cannot + * carry - so their sequences are consumed and [AppendResult.enteredFullScreen] lets the UI say so. + * + * An OUTPUT frame is whatever one PTY read returned, so frame boundaries fall mid-line, mid-sequence and mid-character: + * parser state and a split UTF-8 tail both carry across [append] calls. The last line is always the one being written, + * which is where the screen draws the cursor. + */ +@Suppress("TooManyFunctions") +internal class TerminalOutput(private val maxLines: Int) { + + private class Row(var isNotice: Boolean = false) { + val chars = StringBuilder() + val styles = ArrayList() + var snapshot: TerminalLine? = null + } + + private enum class State { + GROUND, + ESCAPE, + ESCAPE_INTERMEDIATE, + CSI, + OSC, + OSC_ESCAPE, + STRING, + STRING_ESCAPE, + } + + private val rows = ArrayDeque().apply { addLast(Row()) } + private var carry = ByteArray(0) + private var state = State.GROUND + private val csi = StringBuilder() + private var style = CellStyle.Plain + + /** Column of the cursor on the last line. */ + var cursorColumn = 0 + private set + + /** DECCKM: arrow keys must be sent as `ESC O x` rather than `ESC [ x` while a program asks for it. */ + var applicationCursorKeys = false + private set + + private val echo = ArrayList() + private var bell = false + private var enteredFullScreen = false + + fun lines(): List = rows.map { row -> + row.snapshot ?: snapshot(row).also { row.snapshot = it } + } + + fun append(bytes: ByteString): AppendResult { + echo.clear() + bell = false + enteredFullScreen = false + val all = carry + bytes.toByteArray() + val complete = completeUtf8Length(all) + carry = all.copyOfRange(complete, all.size) + all.decodeToString(0, complete).forEach(::feed) + return AppendResult(echo.toList(), bell, enteredFullScreen) + } + + /** A line of our own, never merged into what the PTY is writing. */ + fun notice(text: String) { + val current = rows.last() + val target = if (current.chars.isEmpty()) current else Row().also { rows.addLast(it) } + target.isNotice = true + target.chars.append(text) + repeat(text.length) { target.styles.add(CellStyle.Plain) } + target.snapshot = null + newLine() + } + + // region --- Parser --- + + @Suppress("CyclomaticComplexMethod") + private fun feed(c: Char) { + when (state) { + State.GROUND -> ground(c) + + State.ESCAPE -> escape(c) + + State.ESCAPE_INTERMEDIATE -> state = State.GROUND + + State.CSI -> + if (c in '@'..'~') { + dispatchCsi(c) + state = State.GROUND + } else if (csi.length < MAX_CSI_LENGTH) { + csi.append(c) + } + + State.OSC -> + when (c) { + BEL -> state = State.GROUND + ESC -> state = State.OSC_ESCAPE + else -> Unit + } + + State.OSC_ESCAPE -> state = if (c == '\\') State.GROUND else State.OSC + + State.STRING -> if (c == ESC) state = State.STRING_ESCAPE + + State.STRING_ESCAPE -> state = if (c == '\\') State.GROUND else State.STRING + } + } + + private fun ground(c: Char) { + when (c) { + ESC -> state = State.ESCAPE + + '\n' -> { + newLine() + echo += EchoEvent.Edited + } + + '\r' -> { + cursorColumn = 0 + echo += EchoEvent.Edited + } + + '\b' -> { + if (cursorColumn > 0) cursorColumn-- + echo += EchoEvent.Edited + } + + '\t' -> { + val next = (cursorColumn / TAB_STOP + 1) * TAB_STOP + while (cursorColumn < next) put(' ') + echo += EchoEvent.Edited + } + + BEL -> bell = true + + else -> + if (!c.isISOControl()) { + put(c) + echo += EchoEvent.Printed(c) + } + } + } + + private fun escape(c: Char) { + state = + when (c) { + '[' -> { + csi.clear() + State.CSI + } + + ']' -> State.OSC + + // DCS, SOS, PM, APC: consumed up to the string terminator. + 'P', + 'X', + '^', + '_', + -> State.STRING + + // Charset designations and other two-byte sequences carry one more byte. + '(', + ')', + '*', + '+', + '#', + '%', + -> State.ESCAPE_INTERMEDIATE + + 'c' -> { + clearScreen() + State.GROUND + } + + else -> State.GROUND + } + } + + @Suppress("CyclomaticComplexMethod") + private fun dispatchCsi(final: Char) { + val raw = csi.toString() + val private = raw.startsWith('?') + val params = raw.trimStart('?', '>', '=', '<').split(';').map { it.toIntOrNull() } + fun arg(i: Int, default: Int = 1): Int = params.getOrNull(i)?.takeIf { it > 0 } ?: default + + if (private) { + if (final == 'h' || final == 'l') setPrivateModes(params, final == 'h') + return + } + when (final) { + 'm' -> sgr(params) + + 'K' -> eraseInLine(params.firstOrNull() ?: ERASE_TO_END) + + 'J' -> if ((params.firstOrNull() ?: ERASE_TO_END) >= ERASE_ALL) clearScreen() + + 'C' -> moveCursor(cursorColumn + arg(0)) + + 'D' -> moveCursor(cursorColumn - arg(0)) + + 'G', + '`', + -> moveCursor(arg(0) - 1) + + // Line-oriented: only the column of an absolute position is meaningful. + 'H', + 'f', + -> moveCursor(arg(1) - 1) + + 'P' -> deleteChars(arg(0)) + + '@' -> insertBlanks(arg(0)) + + 'X' -> eraseChars(arg(0)) + + else -> return + } + echo += EchoEvent.Edited + } + + private fun setPrivateModes(params: List, on: Boolean) { + params.forEach { mode -> + when (mode) { + DECCKM -> applicationCursorKeys = on + + ALT_SCREEN_1049, + ALT_SCREEN_47, + ALT_SCREEN_1047, + -> if (on) enteredFullScreen = true + + else -> Unit + } + } + } + + // The numbers are ECMA-48's SGR parameters; naming each would only restate the table. + @Suppress("CyclomaticComplexMethod", "MagicNumber") + private fun sgr(params: List) { + var i = 0 + if (params.isEmpty()) style = CellStyle.Plain + while (i < params.size) { + val p = params[i] ?: 0 + style = + when (p) { + 0 -> CellStyle.Plain + + 1 -> style.copy(bold = true) + + 2 -> style.copy(dim = true) + + 3 -> style.copy(italic = true) + + 4 -> style.copy(underline = true) + + 7 -> style.copy(inverse = true) + + 22 -> style.copy(bold = false, dim = false) + + 23 -> style.copy(italic = false) + + 24 -> style.copy(underline = false) + + 27 -> style.copy(inverse = false) + + in 30..37 -> style.copy(fg = p - 30) + + 39 -> style.copy(fg = DEFAULT_COLOR) + + in 40..47 -> style.copy(bg = p - 40) + + 49 -> style.copy(bg = DEFAULT_COLOR) + + in 90..97 -> style.copy(fg = p - 90 + 8) + + in 100..107 -> style.copy(bg = p - 100 + 8) + + 38, + 48, + -> { + val (color, used) = extendedColor(params, i + 1) + i += used + if (color == null) { + style + } else if (p == 38) { + style.copy(fg = color) + } else { + style.copy(bg = color) + } + } + + else -> style + } + i++ + } + } + + /** `5;n` or `2;r;g;b` after a 38/48. Returns the colour and how many parameters it consumed. */ + private fun extendedColor(params: List, at: Int): Pair = when (params.getOrNull(at)) { + SGR_PALETTE -> params.getOrNull(at + 1)?.coerceIn(0, MAX_PALETTE) to 2 + + SGR_TRUECOLOR -> { + val rgb = (1..SGR_RGB_ARGS).map { (params.getOrNull(at + it) ?: 0).coerceIn(0, BYTE_MASK) } + (TRUECOLOR_FLAG or (rgb[0] shl (2 * BYTE_SHIFT)) or (rgb[1] shl BYTE_SHIFT) or rgb[2]) to + (1 + SGR_RGB_ARGS) + } + + else -> null to 0 + } + + // endregion + + // region --- Line editing --- + + private fun current(): Row = rows.last() + + private fun put(c: Char) { + if (cursorColumn >= MAX_LINE_WIDTH) newLine() + val row = current() + padTo(row, cursorColumn) + if (cursorColumn < row.chars.length) { + row.chars[cursorColumn] = c + row.styles[cursorColumn] = style + } else { + row.chars.append(c) + row.styles.add(style) + } + row.snapshot = null + cursorColumn++ + } + + private fun padTo(row: Row, column: Int) { + while (row.chars.length < column) { + row.chars.append(' ') + row.styles.add(CellStyle.Plain) + } + } + + private fun moveCursor(column: Int) { + cursorColumn = column.coerceIn(0, MAX_LINE_WIDTH - 1) + } + + private fun eraseInLine(mode: Int) { + val row = current() + when (mode) { + ERASE_TO_END -> truncate(row, cursorColumn) + + ERASE_TO_START -> { + padTo(row, cursorColumn) + for (i in 0 until minOf(cursorColumn + 1, row.chars.length)) { + row.chars[i] = ' ' + row.styles[i] = CellStyle.Plain + } + } + + else -> truncate(row, 0) + } + row.snapshot = null + } + + private fun truncate(row: Row, length: Int) { + if (length < row.chars.length) { + row.chars.setLength(length) + while (row.styles.size > length) row.styles.removeAt(row.styles.lastIndex) + } + } + + private fun deleteChars(count: Int) { + val row = current() + val end = minOf(cursorColumn + count, row.chars.length) + if (cursorColumn >= end) return + row.chars.deleteRange(cursorColumn, end) + repeat(end - cursorColumn) { row.styles.removeAt(cursorColumn) } + row.snapshot = null + } + + private fun insertBlanks(count: Int) { + val row = current() + val blanks = count.coerceAtMost(MAX_LINE_WIDTH - row.chars.length) + if (cursorColumn >= row.chars.length || blanks <= 0) return + row.chars.insert(cursorColumn, " ".repeat(blanks)) + repeat(blanks) { row.styles.add(cursorColumn, CellStyle.Plain) } + row.snapshot = null + } + + private fun eraseChars(count: Int) { + val row = current() + for (i in cursorColumn until minOf(cursorColumn + count, row.chars.length)) { + row.chars[i] = ' ' + row.styles[i] = CellStyle.Plain + } + row.snapshot = null + } + + private fun newLine() { + rows.addLast(Row()) + cursorColumn = 0 + while (rows.size > maxLines) rows.removeFirst() + } + + private fun clearScreen() { + rows.clear() + rows.addLast(Row()) + cursorColumn = 0 + } + + // endregion + + private fun snapshot(row: Row): TerminalLine { + if (row.chars.isEmpty()) return if (row.isNotice) TerminalLine("", emptyList(), true) else TerminalLine.Empty + val runs = ArrayList() + var start = 0 + for (i in 1..row.styles.size) { + if (i == row.styles.size || row.styles[i] != row.styles[start]) { + if (row.styles[start] != CellStyle.Plain) runs += StyleRun(start, i, row.styles[start]) + start = i + } + } + return TerminalLine(row.chars.toString(), runs, row.isNotice) + } + + /** Length of [bytes] up to, not including, a trailing UTF-8 sequence that is still missing bytes. */ + private fun completeUtf8Length(bytes: ByteArray): Int { + var start = bytes.size - 1 + while (start >= 0 && (bytes[start].toInt() and UTF8_CONTINUATION_MASK) == UTF8_CONTINUATION) start-- + if (start < 0) return bytes.size + val lead = bytes[start].toInt() and BYTE_MASK + val needed = + when { + lead and UTF8_LEAD_2 == UTF8_LEAD_2_BITS -> UTF8_LEN_2 + lead and UTF8_LEAD_3 == UTF8_LEAD_3_BITS -> UTF8_LEN_3 + lead and UTF8_LEAD_4 == UTF8_LEAD_4_BITS -> UTF8_LEN_4 + else -> 1 + } + return if (bytes.size - start < needed) start else bytes.size + } +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalRendering.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalRendering.kt new file mode 100644 index 0000000000..5995faa23b --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalRendering.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics.terminal + +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle + +/** Block drawn at the cursor. */ +internal const val CURSOR_GLYPH = "█" + +private const val ANSI_BRIGHT_OFFSET = 8 +private const val DIM_ALPHA = 0.6f +private const val UNSENT_ALPHA = 0.45f +private const val LUMINANCE_DARK = 0.5f +private const val CUBE_BASE = 16 +private const val CUBE_SIDE = 6 +private const val GRAY_BASE = 232 +private const val CUBE_FIRST_STEP = 55 +private const val CUBE_STEP = 40 +private const val GRAY_FIRST = 8 +private const val GRAY_STEP = 10 +private const val RGB_MASK = 0xFFFFFF +private const val OPAQUE = 0xFF000000L +private const val BYTE = 8 + +/** The 16 ANSI colours for the theme's surface: Tango-derived, with a darker set for light themes. */ +@Immutable +internal class TerminalPalette( + val ansi: List, + val foreground: Color, + val background: Color, + val notice: Color, +) { + /** Colour for a [CellStyle] colour value, or null for the default. */ + fun resolve(value: Int): Color? = when { + value == DEFAULT_COLOR -> null + value and TRUECOLOR_FLAG != 0 -> Color(OPAQUE or (value and RGB_MASK).toLong()) + value < CUBE_BASE -> ansi[value] + value < GRAY_BASE -> cube(value - CUBE_BASE) + else -> gray(value - GRAY_BASE) + } + + private fun cube(index: Int): Color { + fun step(v: Int) = if (v == 0) 0 else CUBE_FIRST_STEP + v * CUBE_STEP + val r = step(index / (CUBE_SIDE * CUBE_SIDE)) + val g = step(index / CUBE_SIDE % CUBE_SIDE) + val b = step(index % CUBE_SIDE) + return Color(OPAQUE or (r.toLong() shl (2 * BYTE)) or (g.toLong() shl BYTE) or b.toLong()) + } + + private fun gray(index: Int): Color { + val v = (GRAY_FIRST + index * GRAY_STEP).toLong() + return Color(OPAQUE or (v shl (2 * BYTE)) or (v shl BYTE) or v) + } + + companion object { + private val DARK = + listOf( + 0xFF2E3436, + 0xFFEF5350, + 0xFF8AE234, + 0xFFFCE94F, + 0xFF729FCF, + 0xFFC397D8, + 0xFF34E2E2, + 0xFFD3D7CF, + 0xFF888A85, + 0xFFFF7B7B, + 0xFFB5F27A, + 0xFFFFF59D, + 0xFF9EC3F0, + 0xFFE0B8F0, + 0xFF8AF0F0, + 0xFFEEEEEC, + ) + .map(::Color) + + private val LIGHT = + listOf( + 0xFF2E3436, + 0xFFB71C1C, + 0xFF2E7D32, + 0xFF8D6E00, + 0xFF1E5AA8, + 0xFF7B3F9E, + 0xFF00796B, + 0xFF5F6368, + 0xFF555753, + 0xFFC62828, + 0xFF1B5E20, + 0xFF795548, + 0xFF0D47A1, + 0xFF6A1B9A, + 0xFF00695C, + 0xFF202124, + ) + .map(::Color) + + fun from(scheme: ColorScheme): TerminalPalette = TerminalPalette( + ansi = if (scheme.surface.luminance() < LUMINANCE_DARK) DARK else LIGHT, + foreground = scheme.onSurface, + background = scheme.surface, + notice = scheme.onSurfaceVariant, + ) + } +} + +internal fun CellStyle.toSpanStyle(palette: TerminalPalette): SpanStyle { + var fg = palette.resolve(fg) + var bg = palette.resolve(bg) + // Bold brightens the eight base colours, as xterm's boldColors does. + if (bold && this.fg in 0 until ANSI_BRIGHT_OFFSET) fg = palette.ansi[this.fg + ANSI_BRIGHT_OFFSET] + if (inverse) { + val swappedFg = bg ?: palette.background + bg = fg ?: palette.foreground + fg = swappedFg + } + if (dim) fg = (fg ?: palette.foreground).copy(alpha = DIM_ALPHA) + return SpanStyle( + color = fg ?: Color.Unspecified, + background = bg ?: Color.Unspecified, + fontWeight = if (bold) FontWeight.Bold else null, + fontStyle = if (italic) FontStyle.Italic else null, + textDecoration = if (underline) TextDecoration.Underline else null, + ) +} + +/** One output line with its style runs applied. */ +internal fun TerminalLine.toAnnotatedString(palette: TerminalPalette): AnnotatedString = buildAnnotatedString { + if (isNotice) { + withStyle(SpanStyle(color = palette.notice, fontStyle = FontStyle.Italic)) { append(text) } + return@buildAnnotatedString + } + append(text) + runs.forEach { addStyle(it.style.toSpanStyle(palette), it.start, it.end) } +} + +/** + * The line being written, with what is still in flight drawn at the cursor: [predicted] (sent, awaiting echo) + * underlined in the text colour, [unsent] (still in the keystroke debounce) underlined and faded. Text already right of + * the cursor follows, as the remote line would show it once the echo lands. + */ +internal fun TerminalLine.withCursor( + cursorColumn: Int, + predicted: String, + unsent: String, + showCursor: Boolean, + palette: TerminalPalette, +): AnnotatedString = buildAnnotatedString { + val split = cursorColumn.coerceIn(0, text.length) + val full = toAnnotatedString(palette) + append(full.subSequence(0, split)) + if (cursorColumn > text.length) append(" ".repeat(cursorColumn - text.length)) + withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) { append(predicted) } + withStyle( + SpanStyle(color = palette.foreground.copy(alpha = UNSENT_ALPHA), textDecoration = TextDecoration.Underline), + ) { + append(unsent) + } + if (showCursor) append(CURSOR_GLYPH) + val after = split + predicted.length + unsent.length + if (after < text.length) append(full.subSequence(after, text.length)) +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt index da7407b45d..523ecc9d8b 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt @@ -29,6 +29,8 @@ sealed interface NodeDetailAction { /** Open the remote-administration screen, ensuring a fresh session passkey first. */ data class OpenRemoteAdmin(val nodeNum: Int) : NodeDetailAction + data class OpenRemoteShell(val nodeNum: Int) : NodeDetailAction + /** Force-refresh device metadata (firmware version, edition, role) for the given node. */ data class RefreshMetadata(val nodeNum: Int) : NodeDetailAction diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt index ee312702b4..11eba202cd 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt @@ -48,10 +48,12 @@ import org.meshtastic.core.resources.ic_memory import org.meshtastic.core.resources.ic_perm_scan_wifi import org.meshtastic.core.resources.ic_power import org.meshtastic.core.resources.ic_router +import org.meshtastic.core.resources.ic_terminal import org.meshtastic.core.resources.neighbor_info import org.meshtastic.core.resources.pax import org.meshtastic.core.resources.position_log import org.meshtastic.core.resources.power +import org.meshtastic.core.resources.remote_shell import org.meshtastic.core.resources.signal import org.meshtastic.core.resources.traceroute import org.meshtastic.core.ui.component.ScrollToTopEvent @@ -70,6 +72,8 @@ import org.meshtastic.feature.node.metrics.PowerMetricsScreen import org.meshtastic.feature.node.metrics.SignalMetricsScreen import org.meshtastic.feature.node.metrics.TracerouteLogScreen import org.meshtastic.feature.node.metrics.TracerouteMapScreen +import org.meshtastic.feature.node.metrics.terminal.RemoteShellScreen +import org.meshtastic.feature.node.metrics.terminal.RemoteShellViewModel import kotlin.reflect.KClass @OptIn(ExperimentalMaterial3AdaptiveApi::class) @@ -140,6 +144,16 @@ fun EntryProviderScope.nodeDetailGraph(backStack: NavBackStack) ) } + // RemoteShell uses its own ViewModel and is wired up separately from the MetricsViewModel-based screens. No pane + // metadata: a terminal wants the whole window, so it takes the single-pane fallback instead of the extra pane. + entry { args -> + val remoteShellViewModel = koinViewModel { parametersOf(args.destNum) } + RemoteShellScreen( + viewModel = remoteShellViewModel, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, + ) + } + NodeDetailScreen.entries.forEach { routeInfo -> when (routeInfo.routeClass) { NodeDetailRoute.DeviceMetrics::class -> @@ -169,6 +183,7 @@ fun EntryProviderScope.nodeDetailGraph(backStack: NavBackStack) NodeDetailRoute.NeighborInfoLog::class -> addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } + // NodeDetailRoute.RemoteShell is handled by the dedicated entry above. else -> Unit } } @@ -257,4 +272,12 @@ enum class NodeDetailScreen( Res.drawable.ic_air, { metricsVM, onNavigateUp -> AirQualityMetricsScreen(metricsVM, onNavigateUp) }, ), + REMOTE_SHELL( + Res.string.remote_shell, + NodeDetailRoute.RemoteShell::class, + Res.drawable.ic_terminal, + // Navigation for RemoteShell is handled by a dedicated entry + // block in nodeDetailGraph() that resolves RemoteShellViewModel instead of MetricsViewModel. + { _, _ -> }, + ), } diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/terminal/LocalEchoTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/terminal/LocalEchoTest.kt new file mode 100644 index 0000000000..bbc494e289 --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/terminal/LocalEchoTest.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics.terminal + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +private fun printed(text: String) = text.map { EchoEvent.Printed(it) } + +/** A line whose first character the remote has echoed, so predictions on it are drawn. */ +private fun confirmedEcho(): LocalEcho = LocalEcho().apply { + onSent("e", 0) + onOutput(printed("e"), 50) +} + +class LocalEchoTest { + + @Test + fun nothingIsDrawnUntilTheLineHasEchoedOnce() { + val echo = LocalEcho() + echo.onSent("ls -l", 0) + assertEquals("", echo.pending) + assertFalse(echo.showsTyping) + echo.onOutput(printed("l"), 100) + assertEquals("s -l", echo.pending) + assertTrue(echo.showsTyping) + } + + @Test + fun sentCharactersArePredictedUntilEchoed() { + val echo = confirmedEcho() + echo.onSent("cho hi", 100) + assertEquals("cho hi", echo.pending) + echo.onOutput(printed("cho"), 200) + assertEquals(" hi", echo.pending) + echo.onOutput(printed(" hi"), 300) + assertEquals("", echo.pending) + } + + @Test + fun aPasswordTypedAtAPromptThatDoesNotEchoIsNeverDrawn() { + val echo = LocalEcho() + echo.onSent("sudo -v\r", 0) + echo.onSent("hunter2", 500) + assertEquals("", echo.pending) + echo.tick(PREDICTION_TIMEOUT_MIN_MS + 500, roundTripMs = null) + assertEquals("", echo.pending) + assertFalse(echo.showsTyping) + } + + @Test + fun enterStartsAnUnconfirmedLine() { + val echo = confirmedEcho() + echo.onSent("\rpass", 100) + assertEquals("", echo.pending) + } + + @Test + fun aDivergingEchoDropsThePredictionAndTheConfirmation() { + val echo = confirmedEcho() + echo.onSent("ab", 100) + echo.onOutput(printed("ax"), 200) + assertEquals("", echo.pending) + echo.onSent("c", 300) + assertEquals("", echo.pending) + } + + @Test + fun backspaceRetractsThePredictionAndItsEchoPasses() { + val echo = confirmedEcho() + echo.onSent("abc\u007f", 100) + assertEquals("ab", echo.pending) + echo.onOutput(printed("ab"), 200) + echo.onOutput(listOf(EchoEvent.Printed('c'), EchoEvent.Edited, EchoEvent.Printed(' '), EchoEvent.Edited), 220) + assertEquals("", echo.pending) + } + + @Test + fun aControlKeyDropsThePrediction() { + val echo = confirmedEcho() + echo.onSent("cd /us", 100) + echo.onSent("\t", 110) + assertEquals("", echo.pending) + } + + @Test + fun anUnconfirmedLineTimesOutAndStaysOffUntilEnter() { + val echo = confirmedEcho() + echo.onSent("xyz", 100) + echo.tick(100 + PREDICTION_TIMEOUT_MIN_MS, roundTripMs = null) + assertEquals("", echo.pending) + echo.onSent("more", PREDICTION_TIMEOUT_MIN_MS + 200) + echo.onOutput(printed("m"), PREDICTION_TIMEOUT_MIN_MS + 300) + assertEquals("", echo.pending) + } + + @Test + fun aConfirmedCharacterRestartsTheWait() { + val echo = confirmedEcho() + echo.onSent("abc", 100) + echo.onOutput(printed("a"), PREDICTION_TIMEOUT_MIN_MS) + echo.tick(PREDICTION_TIMEOUT_MIN_MS + 100, roundTripMs = null) + assertEquals("bc", echo.pending) + } + + @Test + fun theTimeoutScalesWithTheRoundTrip() { + val echo = confirmedEcho() + echo.onSent("x", 100) + echo.tick(100 + PREDICTION_TIMEOUT_MIN_MS + 1, roundTripMs = 3_000) + assertEquals("x", echo.pending) + echo.tick(100 + 9_000, roundTripMs = 3_000) + assertEquals("", echo.pending) + } + + @Test + fun secretPromptsAreRecognised() { + assertTrue(looksLikeSecretPrompt("[sudo] password for james: ")) + assertTrue(looksLikeSecretPrompt("Password:")) + assertTrue(looksLikeSecretPrompt("Enter passphrase for key '/root/.ssh/id_ed25519': ")) + assertTrue(looksLikeSecretPrompt("Enter PIN: ")) + assertFalse(looksLikeSecretPrompt("root@node:~# ")) + assertFalse(looksLikeSecretPrompt("# cat password.txt")) + assertFalse(looksLikeSecretPrompt("Pinging host: ")) + } +} diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellLinkTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellLinkTest.kt new file mode 100644 index 0000000000..9a82c0f501 --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellLinkTest.kt @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics.terminal + +import okio.ByteString.Companion.encodeUtf8 +import org.meshtastic.proto.RemoteShell +import org.meshtastic.proto.RemoteShell.OpCode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +private const val SID = 0x1234 + +private fun server( + op: OpCode, + seq: Int, + ack: Int = 0, + payload: String = "", + lastRx: Int = 0, + lastTx: Int = 0, + session: Int = SID, +): RemoteShell = shellFrame(op) { wb -> + wb.session_id = session + wb.seq = seq + wb.ack_seq = ack + wb.payload = payload.encodeUtf8() + wb.last_rx_seq = lastRx + wb.last_tx_seq = lastTx +} + +/** OPEN at t=0, OPEN_OK (seq 1, acking our OPEN) at t=100. */ +private fun openedLink(): RemoteShellLink = RemoteShellLink(SID).apply { + open(cols = 80, rows = 24, nowMs = 0) + receive(server(OpCode.OPEN_OK, seq = 1, ack = 1), nowMs = 100) +} + +private fun ShellStep.only(op: OpCode) = send.filter { it.op == op } + +private fun typed(bytes: Int) = "x".repeat(bytes).encodeUtf8() + +class RemoteShellLinkTest { + + @Test + fun openCarriesTheTerminalSizeAsSequenceOne() { + val step = RemoteShellLink(SID).open(cols = 54, rows = 29, nowMs = 0) + val open = step.send.single() + assertEquals(OpCode.OPEN, open.op) + assertEquals(1, open.seq) + assertEquals(54, open.cols) + assertEquals(29, open.rows) + assertEquals(0, open.flags) + } + + @Test + fun openOkOpensTheSession() { + val link = RemoteShellLink(SID) + link.open(80, 24, 0) + val step = link.receive(server(OpCode.OPEN_OK, seq = 1, ack = 1), 100) + assertTrue(link.isOpen) + assertEquals(listOf(ShellEvent.Opened), step.events) + } + + @Test + fun aOneWayStreamIsAcknowledgedEverySecondFrame() { + val link = openedLink() + val first = link.receive(server(OpCode.OUTPUT, seq = 2, ack = 1, payload = "a"), 200) + val ack = first.only(OpCode.ACK).single() + assertEquals(2, ack.ack_seq) + assertEquals(0, ack.last_rx_seq) + + assertTrue(link.receive(server(OpCode.OUTPUT, seq = 3, ack = 1, payload = "b"), 300).send.isEmpty()) + assertEquals(4, link.receive(server(OpCode.OUTPUT, seq = 4, ack = 1, payload = "c"), 400).send.single().ack_seq) + } + + @Test + fun aDuplicateIsAnsweredWithTheCursor() { + val link = openedLink() + link.receive(server(OpCode.OUTPUT, seq = 2, ack = 1, payload = "a"), 200) + val ack = link.receive(server(OpCode.OUTPUT, seq = 2, ack = 1, payload = "a"), 1_200).send.single() + assertEquals(OpCode.ACK, ack.op) + assertEquals(2, ack.ack_seq) + assertEquals(0, ack.last_rx_seq) + } + + @Test + fun aGapAsksForTheMissingFrameAndDeliversInOrderOnceFilled() { + val link = openedLink() + val gap = link.receive(server(OpCode.OUTPUT, seq = 3, ack = 1, payload = "c"), 200) + assertEquals(1, gap.only(OpCode.ACK).single().last_rx_seq) + assertTrue(gap.events.isEmpty()) + + val filled = link.receive(server(OpCode.OUTPUT, seq = 2, ack = 1, payload = "b"), 300) + assertEquals( + listOf("b", "c"), + filled.events.filterIsInstance().map { it.bytes.utf8() }, + ) + } + + @Test + fun aReplayRequestResendsThatFrame() { + val link = openedLink() + link.input("ls\r".encodeUtf8(), 200) + val replay = link.receive(server(OpCode.ACK, seq = 0, ack = 1, lastRx = 1), 300).send.single() + assertEquals(OpCode.INPUT, replay.op) + assertEquals(2, replay.seq) + assertEquals("ls\r", replay.payload.utf8()) + } + + @Test + fun inputBeyondTheWindowWaitsForTheCursor() { + val link = openedLink() + val burst = link.input(typed(MAX_INPUT_CHUNK_BYTES * 6), 200) + assertEquals(listOf(2, 3, 4, 5), burst.only(OpCode.INPUT).map { it.seq }) + + val reopened = link.receive(server(OpCode.OUTPUT, seq = 2, ack = 3, payload = "x"), 300) + assertEquals(listOf(6, 7), reopened.only(OpCode.INPUT).map { it.seq }) + } + + @Test + fun aShutWindowRetransmitsItsOldestFrameThenGivesUp() { + val link = openedLink() + link.input(typed(MAX_INPUT_CHUNK_BYTES * 4), 200) + + var resends = 0 + var closed: ShellEvent.Closed? = null + var sentClose = false + var now = 250L + while (now < 600_000 && closed == null) { + val step = link.tick(now) + resends += step.send.count { it.op == OpCode.INPUT && it.seq == 2 } + sentClose = sentClose || step.send.any { it.op == OpCode.CLOSE } + closed = step.events.filterIsInstance().firstOrNull() + now += 250 + } + assertEquals(MAX_RETRANSMITS, resends) + assertTrue(sentClose) + assertTrue(link.isClosed) + } + + @Test + fun aMissingOpenOkIsRecoveredByRepeatingOpenThenTimesOut() { + val link = RemoteShellLink(SID) + link.open(80, 24, 0) + val opens = mutableListOf() + var closed = false + var now = 250L + while (!closed) { + val step = link.tick(now) + step.only(OpCode.OPEN).forEach { + assertEquals(1, it.seq) + opens += now + } + closed = step.events.any { it is ShellEvent.Closed } + now += 250 + } + assertEquals(listOf(5_000L, 15_000L, 35_000L), opens) + assertEquals(OPEN_TIMEOUT_MS, now - 250) + } + + @Test + fun closedIsActedOnOutOfOrder() { + val link = openedLink() + val step = link.receive(server(OpCode.CLOSED, seq = 9, ack = 1, payload = "pty_eof"), 200) + assertEquals(listOf(ShellEvent.Closed("pty_eof")), step.events) + assertTrue(link.isClosed) + } + + @Test + fun aSequencedErrorKeepsTheSessionAndASessionlessOneEndsIt() { + val link = openedLink() + val error = link.receive(server(OpCode.ERROR, seq = 2, ack = 1, payload = "unsupported_op"), 200) + assertEquals(listOf(ShellEvent.RemoteError("unsupported_op")), error.events) + assertFalse(link.isClosed) + + link.receive(server(OpCode.ERROR, seq = 0, payload = "invalid_session"), 300) + assertTrue(link.isClosed) + } + + @Test + fun inboundSilenceSendsAPingWithBothCursors() { + val link = openedLink() + assertTrue(link.tick(5_000).send.isEmpty()) + val ping = link.tick(5_100).only(OpCode.PING).single() + assertEquals(1, ping.last_rx_seq) + assertEquals(1, ping.last_tx_seq) + assertTrue(link.tick(5_350).only(OpCode.PING).isEmpty()) + assertEquals(1, link.tick(20_100).only(OpCode.PING).size) + } + + @Test + fun healthReportsHowLongTheOldestUnacknowledgedFrameHasWaited() { + val link = openedLink() + assertEquals(null, link.health.waitingSinceMs) + link.input("x".encodeUtf8(), 300) + assertEquals(300L, link.health.waitingSinceMs) + assertEquals(1, link.health.unacknowledged) + link.receive(server(OpCode.OUTPUT, seq = 2, ack = 2, payload = "x"), 450) + assertEquals(null, link.health.waitingSinceMs) + assertEquals(450L, link.health.lastInboundMs) + } + + @Test + fun framesForAnotherSessionAreIgnored() { + val link = openedLink() + val step = link.receive(server(OpCode.OUTPUT, seq = 2, payload = "x", session = SID + 1), 200) + assertTrue(step.send.isEmpty() && step.events.isEmpty()) + } +} diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalKeysTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalKeysTest.kt new file mode 100644 index 0000000000..2be240bce2 --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalKeysTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics.terminal + +import kotlin.test.Test +import kotlin.test.assertEquals + +class TerminalKeysTest { + + @Test + fun cursorKeysFollowTheApplicationCursorMode() { + assertEquals("\u001b[A", TerminalKey.UP.sequence(applicationCursorKeys = false)) + assertEquals("\u001bOA", TerminalKey.UP.sequence(applicationCursorKeys = true)) + assertEquals("\u001b[5~", TerminalKey.PAGE_UP.sequence(applicationCursorKeys = true)) + } + + @Test + fun ctrlMapsLettersAndPunctuationToControlCodes() { + val ctrl = Modifiers(ctrl = ModifierState.ONCE) + assertEquals("\u0003", 'c'.withModifiers(ctrl)) + assertEquals("\u0003", 'C'.withModifiers(ctrl)) + assertEquals("\u001b", '['.withModifiers(ctrl)) + assertEquals("\u007f", '?'.withModifiers(ctrl)) + assertEquals("1", '1'.withModifiers(ctrl)) + } + + @Test + fun altPrefixesEscape() { + assertEquals("\u001bb", 'b'.withModifiers(Modifiers(alt = ModifierState.ONCE))) + assertEquals("\u001b\u0002", 'b'.withModifiers(Modifiers(ModifierState.ONCE, ModifierState.LOCKED))) + } + + @Test + fun aOneShotModifierReleasesAndALockedOneStays() { + val mods = Modifiers(ctrl = ModifierState.ONCE, alt = ModifierState.LOCKED).consumed() + assertEquals(Modifiers(ctrl = ModifierState.OFF, alt = ModifierState.LOCKED), mods) + } + + @Test + fun aTypedCharacterIsInsertedText() { + assertEquals(SinkEdit(deleted = 0, inserted = "a"), sinkEdit(listOf(SinkChange(0, "a")))) + assertEquals(SinkEdit(deleted = 0, inserted = "ls -l\n"), sinkEdit(listOf(SinkChange(0, "ls -l\n")))) + } + + @Test + fun aDeletionWithNothingInsertedIsABackspace() { + assertEquals(SinkEdit(deleted = 1, inserted = ""), sinkEdit(listOf(SinkChange(1, "")))) + } + + @Test + fun replacingTheSentinelSendsNoBackspaceAndKeepsALeadingSpace() { + assertEquals(SinkEdit(deleted = 0, inserted = "x"), sinkEdit(listOf(SinkChange(1, "x")))) + assertEquals(SinkEdit(deleted = 0, inserted = " foo"), sinkEdit(listOf(SinkChange(1, " foo")))) + } + + @Test + fun noChangesTypeNothing() { + assertEquals(SinkEdit(deleted = 0, inserted = ""), sinkEdit(emptyList())) + } + + @Test + fun aTapCyclesOffOnceLocked() { + assertEquals(ModifierState.ONCE, ModifierState.OFF.next()) + assertEquals(ModifierState.LOCKED, ModifierState.ONCE.next()) + assertEquals(ModifierState.OFF, ModifierState.LOCKED.next()) + } +} diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalOutputTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalOutputTest.kt new file mode 100644 index 0000000000..c81a490942 --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalOutputTest.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics.terminal + +import okio.ByteString.Companion.encodeUtf8 +import okio.ByteString.Companion.toByteString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +private const val E = "\u001b" + +private fun TerminalOutput.feed(text: String) = append(text.encodeUtf8()) + +private fun TerminalOutput.texts() = lines().map { it.text } + +class TerminalOutputTest { + + @Test + fun framesSplitMidLineJoinIntoOneLine() { + val out = TerminalOutput(maxLines = 10) + out.feed("$ wh") + out.feed("oami\r\njames\r\n$ ") + assertEquals(listOf("$ whoami", "james", "$ "), out.texts()) + assertEquals(2, out.cursorColumn) + } + + @Test + fun aCharacterSplitAcrossFramesDecodesWhole() { + val out = TerminalOutput(maxLines = 10) + val bytes = "°C".encodeUtf8().toByteArray() + out.append(bytes.copyOfRange(0, 1).toByteString()) + assertEquals(listOf(""), out.texts()) + out.append(bytes.copyOfRange(1, bytes.size).toByteString()) + assertEquals(listOf("°C"), out.texts()) + } + + @Test + fun backspaceEchoErasesTheLastCharacter() { + val out = TerminalOutput(maxLines = 10) + out.feed("lss\b \b") + assertEquals(listOf("ls "), out.texts()) + assertEquals(2, out.cursorColumn) + } + + @Test + fun carriageReturnOverwritesLikeAProgressBar() { + val out = TerminalOutput(maxLines = 10) + out.feed(" 10%\r 55%\r100%\n") + assertEquals(listOf("100%", ""), out.texts()) + } + + @Test + fun eraseInLineClearsFromTheCursor() { + val out = TerminalOutput(maxLines = 10) + out.feed("hello world\r$E[6C$E[K") + assertEquals(listOf("hello "), out.texts()) + } + + @Test + fun sgrColoursBecomeStyleRuns() { + val out = TerminalOutput(maxLines = 10) + out.feed("$E[1;32mok$E[0m plain $E[38;5;208mx$E[38;2;1;2;3my$E[m") + val line = out.lines().single() + assertEquals("ok plain xy", line.text) + assertEquals( + listOf( + StyleRun(0, 2, CellStyle(fg = 2, bold = true)), + StyleRun(9, 10, CellStyle(fg = 208)), + StyleRun(10, 11, CellStyle(fg = TRUECOLOR_FLAG or 0x010203)), + ), + line.runs, + ) + } + + @Test + fun aSequenceSplitAcrossFramesStillApplies() { + val out = TerminalOutput(maxLines = 10) + out.feed("$E[3") + out.feed("1mred") + assertEquals(listOf(StyleRun(0, 3, CellStyle(fg = 1))), out.lines().single().runs) + } + + @Test + fun titleAndOtherStringsAreConsumed() { + val out = TerminalOutput(maxLines = 10) + out.feed("$E]0;user@host: ~\u0007${E}P1\$r${E}\\$E(B$ ") + assertEquals(listOf("$ "), out.texts()) + } + + @Test + fun clearEmptiesTheScreen() { + val out = TerminalOutput(maxLines = 10) + out.feed("one\r\ntwo\r\n$E[H$E[2J$ ") + assertEquals(listOf("$ "), out.texts()) + } + + @Test + fun fullScreenAndCursorKeyModesAreReported() { + val out = TerminalOutput(maxLines = 10) + assertTrue(out.feed("$E[?1049h$E[?1h").enteredFullScreen) + assertTrue(out.applicationCursorKeys) + assertFalse(out.feed("$E[?1l").enteredFullScreen) + assertFalse(out.applicationCursorKeys) + } + + @Test + fun bellIsReportedNotPrinted() { + val out = TerminalOutput(maxLines = 10) + assertTrue(out.feed("a\u0007b").bell) + assertEquals(listOf("ab"), out.texts()) + } + + @Test + fun tabsAdvanceToTheNextStop() { + val out = TerminalOutput(maxLines = 10) + out.feed("a\tb") + assertEquals(listOf("a b"), out.texts()) + } + + @Test + fun aNoticeTakesItsOwnLineAndLeavesAFreshOne() { + val out = TerminalOutput(maxLines = 10) + out.feed("$ ") + out.notice("Session closed") + assertEquals(listOf("$ ", "Session closed", ""), out.texts()) + assertTrue(out.lines()[1].isNotice) + } + + @Test + fun oldestLinesAreDroppedPastTheLimit() { + val out = TerminalOutput(maxLines = 3) + out.feed("1\n2\n3\n4") + assertEquals(listOf("2", "3", "4"), out.texts()) + } + + @Test + fun aHugeCursorMoveIsClampedToTheLineWidth() { + val out = TerminalOutput(maxLines = 10) + out.feed("$E[999999999Cx") + val line = out.lines().last() + assertEquals(MAX_LINE_WIDTH, line.text.length) + assertEquals('x', line.text.last()) + } + + @Test + fun printingPastTheLineWidthWraps() { + val out = TerminalOutput(maxLines = 10) + out.feed("$E[${MAX_LINE_WIDTH}Gab") + assertEquals(listOf(MAX_LINE_WIDTH, 1), out.lines().map { it.text.length }) + } + + @Test + fun aHugeInsertCountIsClampedToTheLineWidth() { + val out = TerminalOutput(maxLines = 10) + out.feed("abc\r$E[999999999@") + assertEquals(MAX_LINE_WIDTH, out.lines().last().text.length) + } + + @Test + fun printedCharactersAreReportedForEchoMatching() { + val out = TerminalOutput(maxLines = 10) + val echo = out.feed("ab\b \b").echo + assertEquals( + listOf( + EchoEvent.Printed('a'), + EchoEvent.Printed('b'), + EchoEvent.Edited, + EchoEvent.Printed(' '), + EchoEvent.Edited, + ), + echo, + ) + } +}