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 384f722d8..d9debef6a 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 @@ -48,6 +48,7 @@ import org.meshtastic.core.repository.PacketHandler import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.RemoteShellHandler import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.StoreForwardPacketHandler @@ -96,6 +97,7 @@ class MeshDataHandlerImpl( private val storeForwardHandler: StoreForwardPacketHandler, private val telemetryHandler: TelemetryPacketHandler, private val adminPacketHandler: AdminPacketHandler, + private val remoteShellHandler: RemoteShellHandler, @Named("ServiceScope") private val scope: CoroutineScope, ) : MeshDataHandler { @@ -181,6 +183,12 @@ class MeshDataHandlerImpl( adminPacketHandler.handleAdminMessage(packet, myNodeNum) } + PortNum.REMOTE_SHELL_APP -> { + remoteShellHandler.handleRemoteShell(packet) + // Do not broadcast — RemoteShell frames are point-to-point PTY I/O + shouldBroadcast = false + } + PortNum.NEIGHBORINFO_APP -> { neighborInfoHandler.handleNeighborInfo(packet) shouldBroadcast = true 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 000000000..db5c6c3da --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/RemoteShellPacketHandlerImpl.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025-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 + +/** + * Handles incoming [RemoteShell] packets (REMOTE_SHELL_APP portnum = 13). + * + * This is a scaffold implementation. The RemoteShell firmware feature is currently unreleased (gated to + * [org.meshtastic.core.model.Capabilities.supportsRemoteShell]). When the firmware ships, this handler should be + * expanded to manage PTY session state and relay I/O to the UI. + */ +@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 022608be1..f27a0fc1b 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 @@ -45,6 +45,7 @@ import org.meshtastic.core.repository.PacketHandler import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.RemoteShellHandler import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.StoreForwardPacketHandler @@ -84,6 +85,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 testDispatcher = StandardTestDispatcher() private val testScope = TestScope(testDispatcher) @@ -108,6 +110,7 @@ class MeshDataHandlerTest { storeForwardHandler = storeForwardHandler, telemetryHandler = telemetryHandler, adminPacketHandler = adminPacketHandler, + remoteShellHandler = remoteShellHandler, scope = testScope, ) 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 4e02ae2a7..e5424c659 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 @@ -64,6 +64,12 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl /** Support for ESP32 Unified OTA. Supported since firmware v2.7.18. */ val supportsEsp32Ota = atLeast(V2_7_18) + /** + * Support for the RemoteShell module (PTY-over-mesh, REMOTE_SHELL_APP portnum). Defined in protobufs HEAD + * (post-v2.7.21); gated to [UNRELEASED] until a firmware release ships it. + */ + val supportsRemoteShell = atLeast(UNRELEASED) + companion object { private val V2_6_8 = DeviceVersion("2.6.8") private val V2_6_9 = DeviceVersion("2.6.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 365a47c61..835376ac5 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 @@ -85,6 +85,12 @@ class CapabilitiesTest { assertTrue(caps("2.7.19").supportsTakConfig) } + @Test + fun supportsRemoteShell_is_currently_unreleased() { + assertFalse(caps("2.7.22").supportsRemoteShell) + assertFalse(caps("3.0.0").supportsRemoteShell) + } + @Test fun supportsEsp32Ota_requires_V2_7_18() { assertFalse(caps("2.7.17").supportsEsp32Ota) @@ -104,6 +110,7 @@ class CapabilitiesTest { assertFalse(c.supportsStatusMessage) assertFalse(c.supportsTrafficManagementConfig) assertFalse(c.supportsTakConfig) + assertFalse(c.supportsRemoteShell) assertFalse(c.supportsEsp32Ota) } @@ -115,5 +122,6 @@ class CapabilitiesTest { assertTrue(c.supportsStatusMessage) assertTrue(c.supportsTrafficManagementConfig) 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 7f43bf549..09fb82799 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 @@ -92,6 +92,8 @@ sealed interface NodeDetailRoute : Route { @Serializable data class PaxMetrics(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 000000000..f79630db0 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RemoteShellHandler.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025-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. The firmware-side + * implementation is currently unreleased (gated to [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 505d80821..64a73cf83 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -862,6 +862,8 @@ Modules unlocked Modules already unlocked Remote + Remote Shell + Phosphor colour (%1$d online / %2$d shown / %3$d total) React Disconnect diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt new file mode 100644 index 000000000..d8c220191 --- /dev/null +++ b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025-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 android.graphics.RenderEffect +import android.graphics.RuntimeShader +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asComposeRenderEffect +import androidx.compose.ui.graphics.graphicsLayer + +/** + * AGSL barrel-distortion shader that simulates the curved screen of a CRT monitor. + * + * The UV coordinates are shifted from [0,1] into [-0.5, 0.5], squared, scaled by [strength], then used to warp the + * sample position — producing the classic pincushion/barrel look. + * + * Only applied on Android 12+ (API 31+); older devices get the no-op pass-through. + */ +@Suppress("MagicNumber") +private val CRT_AGSL = + """ + uniform shader image; + uniform float2 resolution; + uniform float strength; + + half4 main(float2 fragCoord) { + float2 uv = fragCoord / resolution; + // Centre UV on (0,0) + float2 c = uv - 0.5; + // Barrel distortion — shift by c^2 * strength + float2 distorted = uv + c * dot(c, c) * strength; + // Clamp to avoid edge bleeding + distorted = clamp(distorted, float2(0.0, 0.0), float2(1.0, 1.0)); + return image.eval(distorted * resolution); + } + """ + .trimIndent() + +/** Multiplier applied to strength for a visible CRT curvature effect with the barrel-distortion shader. */ +private const val CRT_STRENGTH_SCALE = 4f + +@Composable +actual fun Modifier.crtCurvature(strength: Float): Modifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val shader = remember { RuntimeShader(CRT_AGSL) } + graphicsLayer { + val w = size.width + val h = size.height + if (w > 0f && h > 0f) { + shader.setFloatUniform("resolution", w, h) + shader.setFloatUniform("strength", strength * CRT_STRENGTH_SCALE) + renderEffect = RenderEffect.createRuntimeShaderEffect(shader, "image").asComposeRenderEffect() + } + } +} else { + this +} 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 23ef010e8..19bb4e71d 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 @@ -22,20 +22,24 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.resources.vectorResource import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.database.entity.asDeviceVersion import org.meshtastic.core.model.DeviceVersion import org.meshtastic.core.model.Node import org.meshtastic.core.model.service.ServiceAction +import org.meshtastic.core.navigation.NodeDetailRoute import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.administration 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.remote_admin +import org.meshtastic.core.resources.remote_shell import org.meshtastic.core.resources.request_metadata import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.icon.ForkLeft @@ -79,6 +83,17 @@ fun AdministrationSection( ) { onAction(NodeDetailAction.Navigate(SettingsRoute.Settings(node.num))) } + + if (node.capabilities.supportsRemoteShell) { + SectionDivider() + + ListItem( + text = stringResource(Res.string.remote_shell), + leadingIcon = vectorResource(Res.drawable.ic_terminal), + ) { + onAction(NodeDetailAction.Navigate(NodeDetailRoute.RemoteShell(node.num))) + } + } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt new file mode 100644 index 000000000..b6b5078da --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025-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.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Applies a barrel-distortion CRT curvature effect to the composable. + * + * On Android 12+ (API 31+) this uses [android.graphics.RenderEffect] with a [android.graphics.RuntimeShader] (AGSL) to + * simulate the curved screen of a classic cathode-ray tube. + * + * On all other platforms (desktop, iOS, older Android) this is a no-op. + * + * @param strength Barrel distortion factor in the range [0, 1]. 0 = flat, 1 = heavy curve. + */ +@Composable expect fun Modifier.crtCurvature(strength: Float = 0.08f): Modifier diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/FlickerEffect.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/FlickerEffect.kt new file mode 100644 index 000000000..9452f6fdf --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/FlickerEffect.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025-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.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import kotlinx.coroutines.delay +import kotlin.random.Random + +/** Minimum brightness during a flicker dip (fraction of full alpha). */ +@Suppress("MagicNumber") +private const val FLICKER_MIN = 0.88f + +/** Maximum brightness during a flicker peak. */ +private const val FLICKER_MAX = 1.00f + +/** Duration of a single flicker transition in milliseconds. */ +@Suppress("MagicNumber") +private const val FLICKER_TRANSITION_MS = 80 + +/** Delay between random flicker events in milliseconds. Keeps the effect subtle. */ +@Suppress("MagicNumber") +private const val FLICKER_INTERVAL_MS = 1_500L + +/** + * Produces a continuously animated phosphor brightness value suitable for driving CRT flicker. + * + * The returned [State] holds a float in [[FLICKER_MIN]..[FLICKER_MAX]] that dips and recovers at random intervals, + * simulating the subtle intensity variation of an analogue CRT. + * + * Usage: + * ``` + * val flickerAlpha by rememberFlickerAlpha() + * TerminalCanvas(lines, preset, flickerAlpha, showCursor) + * ScanlinesOverlay(preset, flickerAlpha) + * ``` + * + * @return A [State]<[Float]> updated by the running animation. + */ +@Composable +fun rememberFlickerAlpha(): State { + val animatable = remember { Animatable(FLICKER_MAX) } + + LaunchedEffect(Unit) { + while (true) { + // Wait a randomised interval before the next dip + delay(FLICKER_INTERVAL_MS + Random.nextLong(0L, FLICKER_INTERVAL_MS)) + val target = FLICKER_MIN + Random.nextFloat() * (FLICKER_MAX - FLICKER_MIN) + animatable.animateTo(target, animationSpec = tween(durationMillis = FLICKER_TRANSITION_MS)) + animatable.animateTo(FLICKER_MAX, animationSpec = tween(durationMillis = FLICKER_TRANSITION_MS * 2)) + } + } + + return animatable.asState() +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/PhosphorPreset.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/PhosphorPreset.kt new file mode 100644 index 000000000..6be8af332 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/PhosphorPreset.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025-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.ui.graphics.Color + +/** + * Phosphor colour presets for the RetroShell terminal. + * - [GREEN] — P1 phosphor, VT100 / IBM 3101 style (#33FF33 on near-black) + * - [AMBER] — P3 phosphor, IBM 3278 / Televideo 925 style (#FFB000 on near-black) + * - [WHITE] — P4 phosphor, DEC VT220 / paper-white style (#E0E0E0 on near-black) + */ +@Suppress("MagicNumber") +enum class PhosphorPreset( + /** The main text / glyph colour. */ + val fg: Color, + /** The terminal background — deliberately slightly off-black to avoid pure-black crush. */ + val bg: Color, + /** A dimmer variant used for scanline fill and unfocused UI elements. */ + val dim: Color, + /** Glow halo colour — same hue as [fg] but very transparent. */ + val glow: Color, +) { + GREEN(fg = Color(0xFF33FF33), bg = Color(0xFF0A0F0A), dim = Color(0xFF0D2B0D), glow = Color(0x4033FF33)), + AMBER(fg = Color(0xFFFFB000), bg = Color(0xFF100C00), dim = Color(0xFF2E1E00), glow = Color(0x40FFB000)), + WHITE(fg = Color(0xFFE0E0E0), bg = Color(0xFF0C0C0C), dim = Color(0xFF222222), glow = Color(0x40E0E0E0)), +} 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 000000000..17ec8d969 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellScreen.kt @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2025-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.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.ic_terminal +import org.meshtastic.core.resources.phosphor_colour +import org.meshtastic.core.resources.remote_shell +import org.meshtastic.core.ui.component.MainAppBar + +/** Cursor blink period in milliseconds. */ +@Suppress("MagicNumber") +private const val CURSOR_BLINK_MS = 530L + +/** + * Full retro-CRT terminal screen for the RemoteShell feature (portnum = 13). + * + * ### Input model + * Input is **raw / streaming** — there is no visible text field. A zero-size [BasicTextField] holds keyboard focus and + * is the sole entry point for both hardware key events and the Android soft keyboard. + * - Each printable character is routed to [RemoteShellViewModel.typeKey]. + * - Enter / newline is routed to [RemoteShellViewModel.typeEnter] (immediate flush). + * - Backspace is routed to [RemoteShellViewModel.typeBackspace]. + * - The ViewModel batches keystrokes and flushes over the mesh after a 50 ms debounce or when the 64-byte buffer fills. + * + * A small "tap to type" button at the bottom re-acquires focus when the soft keyboard is dismissed. + * + * ### Pending input rendering + * Unflushed characters from [RemoteShellViewModel.pendingInput] are passed to [TerminalCanvas] and drawn inline after + * the last confirmed output line using [PhosphorPreset.dim], giving immediate visual feedback without implying the + * bytes have been transmitted. + * + * @param viewModel [RemoteShellViewModel] for this destination node. + * @param onNavigateUp Callback invoked when the user presses the navigation-up button. + */ +@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +fun RemoteShellScreen(viewModel: RemoteShellViewModel, onNavigateUp: () -> Unit, modifier: Modifier = Modifier) { + val outputLines by viewModel.outputLines.collectAsStateWithLifecycle() + val pendingInput by viewModel.pendingInput.collectAsStateWithLifecycle() + val phosphor by viewModel.phosphor.collectAsStateWithLifecycle() + + // Cursor blink + var showCursor by remember { mutableStateOf(true) } + LaunchedEffect(Unit) { + while (true) { + delay(CURSOR_BLINK_MS) + showCursor = !showCursor + } + } + + // Open session on composition entry + LaunchedEffect(Unit) { viewModel.openSession() } + + // Flicker animation + val flickerAlpha by rememberFlickerAlpha() + + // Phosphor picker dropdown state + var dropdownExpanded by remember { mutableStateOf(false) } + + // Focus management for the hidden input sink + val focusRequester = remember { FocusRequester() } + + // Request focus when the screen first appears + LaunchedEffect(Unit) { + // Small delay to let the composition settle before requesting focus + delay(FOCUS_REQUEST_DELAY_MS) + focusRequester.requestFocus() + } + + Scaffold( + modifier = modifier, + topBar = { + MainAppBar( + title = viewModel.nodeLongName, + subtitle = stringResource(Res.string.remote_shell), + ourNode = null, + showNodeChip = false, + canNavigateUp = true, + onNavigateUp = onNavigateUp, + actions = { + Box { + IconButton(onClick = { dropdownExpanded = true }) { + Icon( + painter = painterResource(Res.drawable.ic_terminal), + contentDescription = stringResource(Res.string.phosphor_colour), + tint = phosphor.fg, + ) + } + DropdownMenu(expanded = dropdownExpanded, onDismissRequest = { dropdownExpanded = false }) { + PhosphorPreset.entries.forEach { preset -> + DropdownMenuItem( + text = { + Text(text = preset.name, color = preset.fg, fontFamily = FontFamily.Monospace) + }, + onClick = { + viewModel.setPhosphor(preset) + dropdownExpanded = false + }, + modifier = Modifier.background(preset.bg), + ) + } + } + } + }, + onClickChip = {}, + ) + }, + containerColor = phosphor.bg, + ) { paddingValues -> + Box(modifier = Modifier.fillMaxSize().padding(paddingValues).imePadding()) { + // --- Terminal surface --- + Box( + modifier = + Modifier.fillMaxSize() + .crtCurvature() + // Tapping anywhere on the terminal re-acquires keyboard focus + .clickable { focusRequester.requestFocus() }, + ) { + TerminalCanvas( + lines = outputLines, + pendingInput = pendingInput, + preset = phosphor, + flickerAlpha = flickerAlpha, + showCursor = showCursor, + modifier = Modifier.fillMaxSize(), + ) + ScanlinesOverlay(preset = phosphor, flickerAlpha = flickerAlpha, modifier = Modifier.fillMaxSize()) + } + + // --- Hidden keyboard sink --- + // Zero-size BasicTextField that holds focus so both hardware and soft keyboard input + // is captured. onKeyEvent handles hardware keys (including backspace and enter); + // onValueChange handles soft-keyboard input where key events may not fire. + BasicTextField( + value = "", + onValueChange = { newText -> + // Soft keyboard delivers text via onValueChange. Since we always reset to "" + // the entire newText string is fresh input. + newText.forEach { char -> + when { + char == '\n' || char == '\r' -> viewModel.typeEnter() + char == '\b' -> viewModel.typeBackspace() + char == '\t' -> viewModel.typeKey('\t') // tab: immediate flush in VM + char.isISOControl() -> Unit // ignore other control chars + else -> viewModel.typeKey(char) + } + } + }, + modifier = + Modifier.size(1.dp) // zero visible footprint + .align(Alignment.BottomStart) + .focusRequester(focusRequester) + .onKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onKeyEvent false + when (event.key) { + Key.Enter, + Key.NumPadEnter, + -> { + viewModel.typeEnter() + true + } + Key.Tab -> { + viewModel.typeKey('\t') + true + } + Key.Backspace -> { + viewModel.typeBackspace() + true + } + else -> false + } + }, + textStyle = androidx.compose.ui.text.TextStyle(color = Color.Transparent, fontSize = 1.sp), + cursorBrush = SolidColor(Color.Transparent), + ) + } + } +} + +/** Delay before the initial focus request, in milliseconds. */ +@Suppress("MagicNumber") +private const val FOCUS_REQUEST_DELAY_MS = 100L 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 000000000..dd7ee67f0 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellViewModel.kt @@ -0,0 +1,712 @@ +/* + * Copyright (c) 2025-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.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +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.Buffer +import okio.ByteString +import okio.ByteString.Companion.toByteString +import org.koin.core.annotation.InjectedParam +import org.koin.core.annotation.KoinViewModel +import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.RemoteShellHandler +import org.meshtastic.core.ui.viewmodel.safeLaunch +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.RemoteShell +import kotlin.concurrent.Volatile + +// --------------------------------------------------------------------------- +// Protocol constants (matched to dmshell_client.py / DMShell.cpp) +// --------------------------------------------------------------------------- + +/** Maximum number of output lines held in the ring buffer before the oldest are dropped. */ +private const val MAX_OUTPUT_LINES = 500 + +/** Default PTY column count sent in OPEN/RESIZE frames. */ +private const val DEFAULT_COLS = 80 + +/** Default PTY row count sent in OPEN/RESIZE frames. */ +private const val DEFAULT_ROWS = 24 + +/** + * Maximum payload bytes per INPUT frame. Matches the firmware constant (meshtastic_Constants_DATA_PAYLOAD_LEN is 237 + * but the POC client caps at 64 per batch for radio efficiency). + */ +private const val MAX_INPUT_CHUNK_BYTES = 64 + +/** + * Default debounce window in milliseconds. Matches the Python client's INPUT_BATCH_WINDOW_SEC = 0.5 s. Configurable via + * [RemoteShellViewModel.setFlushWindowMs]. + */ +private const val DEFAULT_FLUSH_WINDOW_MS = 500L + +/** Number of sent frames to keep in the retransmission history ring buffer. */ +private const val TX_HISTORY_MAX = 50 + +/** Idle period (ms) after which the first heartbeat PING is sent. */ +private const val HEARTBEAT_IDLE_DELAY_MS = 5_000L + +/** Interval (ms) between repeated heartbeat PINGs while the session is otherwise idle. */ +private const val HEARTBEAT_REPEAT_MS = 15_000L + +/** Poll interval (ms) for the heartbeat coroutine. */ +private const val HEARTBEAT_POLL_MS = 250L + +/** Minimum ms between re-requesting the same missing sequence number. */ +private const val MISSING_SEQ_RETRY_MS = 1_000L + +/** Maximum configurable flush window in milliseconds. */ +private const val MAX_FLUSH_WINDOW_MS = 5_000L + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Size in bytes of an encoded uint32 (big-endian). */ +private const val UINT32_BYTES = 4 + +private fun decodeUint32BE(bytes: ByteArray, offset: Int = 0): Int = + Buffer().apply { write(bytes, offset, UINT32_BYTES) }.readInt() + +// --------------------------------------------------------------------------- +// Sent-frame record for retransmission history +// --------------------------------------------------------------------------- + +private data class SentFrame( + val op: RemoteShell.OpCode, + val sessionId: Int, + val seq: Int, + val ackSeq: Int, + val payload: ByteString, + val cols: Int, + val rows: Int, + val flags: Int = 0, + val lastTxSeq: Int = 0, + val lastRxSeq: Int = 0, +) + +// --------------------------------------------------------------------------- +// ViewModel +// --------------------------------------------------------------------------- + +/** + * ViewModel for the RemoteShell terminal screen. + * + * ### Protocol fidelity + * Implements the same reliability layer as `dmshell_client.py`: + * - Every non-ACK outbound frame carries an incrementing [nextTxSeq] and a piggybacked [lastRxSeq] in the `ack_seq` + * field. + * - Out-of-order inbound frames are buffered; a gap triggers an `ACK` with a 4-byte `REPLAY_REQUEST` payload asking the + * sender to retransmit from the first missing seq. + * - The last [TX_HISTORY_MAX] sent frames are kept in [txHistory] for retransmission on request. + * - PING/PONG heartbeats carry an 8-byte status payload `(lastTxSeq, lastRxSeq)`. On PONG, if the peer is behind our tx + * cursor we replay; if the peer reports frames we haven't seen we request a replay. + * + * ### PKI + * Outbound [DataPacket]s use [DataPacket.PKC_CHANNEL_INDEX] so that `CommandSenderImpl` applies Curve25519 encryption + * before handing the frame to the radio. The firmware rejects DMShell packets that are not PKI-encrypted. + * + * ### Input model + * Raw streaming input with a configurable debounce flush window ([DEFAULT_FLUSH_WINDOW_MS] = 500 ms, matching the + * Python client). Flush is also triggered immediately when the buffer reaches [MAX_INPUT_CHUNK_BYTES] or when the user + * types a line terminator (`\n`, `\r`) or a tab (`\t`). + * + * ### Pending-input visibility + * Unflushed keystrokes are exposed via [pendingInput] and rendered dim in the UI until the batch is sent + * (snap-to-confirmed on flush). + */ +@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, +) : ViewModel() { + + // region --- Session state --- + + enum class SessionState { + IDLE, + OPENING, + OPEN, + CLOSING, + CLOSED, + ERROR, + } + + private val _sessionState = MutableStateFlow(SessionState.IDLE) + val sessionState: StateFlow = _sessionState.asStateFlow() + + companion object { + private val OPENABLE_STATES = setOf(SessionState.IDLE, SessionState.CLOSED, SessionState.ERROR) + } + + /** Session ID negotiated during OPEN / OPEN_OK exchange. */ + private val sessionId = MutableStateFlow(0) + + /** Remote PTY PID received in the OPEN_OK payload (0 if not provided). */ + private val _remotePid = MutableStateFlow(0) + val remotePid: StateFlow = _remotePid.asStateFlow() + + // endregion + + // region --- Outbound sequence / retransmission --- + + /** Next seq to assign to an outbound non-ACK frame. */ + @Volatile private var nextTxSeq: Int = 1 + + /** Ring buffer of the last [TX_HISTORY_MAX] sent frames for replay. */ + private val txHistory = ArrayDeque() + private val txMutex = Mutex() + + private suspend fun allocSeq(): Int = txMutex.withLock { nextTxSeq++ } + + private suspend fun rememberSent(frame: SentFrame) { + if (frame.seq == 0 || frame.op == RemoteShell.OpCode.ACK) return + txMutex.withLock { + txHistory.addLast(frame) + if (txHistory.size > TX_HISTORY_MAX) txHistory.removeFirst() + } + } + + private suspend fun pruneSentFrames(ackSeq: Int) { + if (ackSeq <= 0) return + txMutex.withLock { txHistory.removeAll { it.seq <= ackSeq } } + } + + private suspend fun replayFrom(startSeq: Int) { + val frame = txMutex.withLock { txHistory.firstOrNull { it.seq == startSeq } } + if (frame == null) { + Logger.w { "RemoteShell replay unavailable for seq=$startSeq" } + return + } + Logger.d { "RemoteShell replaying seq=${frame.seq} op=${frame.op}" } + sendFrame( + RemoteShell( + op = frame.op, + session_id = frame.sessionId, + seq = frame.seq, + ack_seq = currentAckSeq(), + payload = frame.payload, + cols = frame.cols, + rows = frame.rows, + flags = frame.flags, + last_tx_seq = frame.lastTxSeq, + last_rx_seq = frame.lastRxSeq, + ), + remember = false, + ) + } + + private suspend fun currentAckSeq(): Int = rxMutex.withLock { lastRxSeq } + + private suspend fun highestSentSeq(): Int = txMutex.withLock { nextTxSeq - 1 } + + // endregion + + // region --- Inbound sequence tracking --- + + private val rxMutex = Mutex() + + /** Highest in-order rx seq we have delivered to the output buffer. */ + @Volatile private var lastRxSeq: Int = 0 + + /** Next rx seq we expect to receive in order. */ + @Volatile private var nextExpectedRxSeq: Int = 1 + + /** Highest seq we have ever seen from the peer (may be ahead of [nextExpectedRxSeq]). */ + @Volatile private var highestSeenRxSeq: Int = 0 + + /** Out-of-order frames buffered while waiting for a gap to fill. */ + private val pendingRxFrames = mutableMapOf() + + /** Tracks when we last requested a specific missing seq, to rate-limit retries. */ + @Volatile private var lastRequestedMissingSeq: Int = 0 + + @Volatile private var lastMissingRequestTimeMs: Long = 0L + + private enum class RxAction { + PROCESS, + GAP, + DUPLICATE, + } + + private suspend fun noteReceivedSeq(seq: Int): RxAction = rxMutex.withLock { + if (seq == 0) return@withLock RxAction.PROCESS + when { + seq < nextExpectedRxSeq -> RxAction.DUPLICATE + seq > nextExpectedRxSeq -> { + if (seq > highestSeenRxSeq) highestSeenRxSeq = seq + RxAction.GAP + } + else -> { + lastRxSeq = seq + nextExpectedRxSeq = seq + 1 + if (seq > highestSeenRxSeq) highestSeenRxSeq = seq + if (lastRequestedMissingSeq != 0 && nextExpectedRxSeq > lastRequestedMissingSeq) { + lastRequestedMissingSeq = 0 + } + RxAction.PROCESS + } + } + } + + private suspend fun requestMissingSeqOnce(): Int? = rxMutex.withLock { + if (highestSeenRxSeq < nextExpectedRxSeq) return@withLock null + val now = nowMillis + if ( + lastRequestedMissingSeq == nextExpectedRxSeq && (now - lastMissingRequestTimeMs) < MISSING_SEQ_RETRY_MS + ) { + return@withLock null + } + lastRequestedMissingSeq = nextExpectedRxSeq + lastMissingRequestTimeMs = now + nextExpectedRxSeq + } + + // endregion + + // region --- Output buffer --- + + private val _outputLines = MutableStateFlow>(emptyList()) + val outputLines: StateFlow> = _outputLines.asStateFlow() + + // endregion + + // region --- Raw input / pending buffer --- + + private val inputBuffer = StringBuilder() + + private val _pendingInput = MutableStateFlow("") + val pendingInput: StateFlow = _pendingInput.asStateFlow() + + private var flushJob: Job? = null + + private val _flushWindowMs = MutableStateFlow(DEFAULT_FLUSH_WINDOW_MS) + val flushWindowMs: StateFlow = _flushWindowMs.asStateFlow() + + fun setFlushWindowMs(ms: Long) { + _flushWindowMs.value = ms.coerceIn(0L, MAX_FLUSH_WINDOW_MS) + } + + // endregion + + // region --- Terminal size --- + + private val _cols = MutableStateFlow(DEFAULT_COLS) + val cols: StateFlow = _cols.asStateFlow() + + private val _rows = MutableStateFlow(DEFAULT_ROWS) + val rows: StateFlow = _rows.asStateFlow() + + // endregion + + // region --- Phosphor colour pref --- + + private val _phosphor = MutableStateFlow(PhosphorPreset.GREEN) + val phosphor: StateFlow = _phosphor.asStateFlow() + + fun setPhosphor(preset: PhosphorPreset) { + _phosphor.value = preset + } + + // endregion + + // region --- Heartbeat timing --- + + @Volatile private var lastActivityMs: Long = nowMillis + + @Volatile private var lastHeartbeatSentMs: Long = 0L + + private fun noteActivity(isHeartbeat: Boolean = false) { + val now = nowMillis + if (isHeartbeat) lastHeartbeatSentMs = now else lastActivityMs = now + } + + private fun isHeartbeatDue(): Boolean { + val now = nowMillis + if ((now - lastActivityMs) < HEARTBEAT_IDLE_DELAY_MS) return false + return lastHeartbeatSentMs <= lastActivityMs || (now - lastHeartbeatSentMs) >= HEARTBEAT_REPEAT_MS + } + + // endregion + + // region --- Node info --- + + val nodeLongName: String + get() = nodeRepository.nodeDBbyNum.value[destNum]?.user?.long_name ?: destNum.toString() + + // endregion + + // region --- Session control --- + + fun openSession() { + if (_sessionState.value !in OPENABLE_STATES) return + _sessionState.update { SessionState.OPENING } + viewModelScope.launch { + val newSessionId = commandSender.generatePacketId() + sessionId.update { newSessionId } + rxMutex.withLock { + lastRxSeq = 0 + nextExpectedRxSeq = 1 + highestSeenRxSeq = 0 + pendingRxFrames.clear() + } + txMutex.withLock { nextTxSeq = 1 } + sendFrame( + RemoteShell( + op = RemoteShell.OpCode.OPEN, + session_id = newSessionId, + seq = allocSeq(), + cols = _cols.value, + rows = _rows.value, + ), + ) + Logger.d { "RemoteShell OPEN → destNum=$destNum sessionId=$newSessionId" } + startHeartbeatLoop() + } + } + + fun closeSession() { + if (_sessionState.value != SessionState.OPEN) return + _sessionState.update { SessionState.CLOSING } + viewModelScope.launch { + sendFrame( + RemoteShell( + op = RemoteShell.OpCode.CLOSE, + session_id = sessionId.value, + seq = allocSeq(), + ack_seq = currentAckSeq(), + ), + ) + } + } + + fun resize(cols: Int, rows: Int) { + _cols.value = cols + _rows.value = rows + if (_sessionState.value == SessionState.OPEN) { + viewModelScope.launch { + sendFrame( + RemoteShell( + op = RemoteShell.OpCode.RESIZE, + session_id = sessionId.value, + seq = allocSeq(), + ack_seq = currentAckSeq(), + cols = cols, + rows = rows, + ), + ) + } + } + } + + // endregion + + // region --- Raw input handling --- + + /** + * Appends [char] to the pending buffer and schedules a debounced flush. Flushes immediately on line-terminators and + * tab to match the Python client's early-flush heuristic, and when the buffer reaches [MAX_INPUT_CHUNK_BYTES]. + */ + fun typeKey(char: Char) { + inputBuffer.append(char) + _pendingInput.value = inputBuffer.toString() + when { + char == '\n' || char == '\r' || char == '\t' -> flushBuffer() + inputBuffer.length >= MAX_INPUT_CHUNK_BYTES -> flushBuffer() + else -> scheduleFlush() + } + } + + /** Appends `\r` and flushes immediately (Enter key on mobile). */ + fun typeEnter() { + inputBuffer.append('\r') + _pendingInput.value = inputBuffer.toString() + flushBuffer() + } + + /** Removes the last byte from the pending buffer; cancels or reschedules the flush job. */ + fun typeBackspace() { + if (inputBuffer.isEmpty()) return + inputBuffer.deleteAt(inputBuffer.lastIndex) + _pendingInput.value = inputBuffer.toString() + if (inputBuffer.isEmpty()) { + flushJob?.cancel() + flushJob = null + } else { + scheduleFlush() + } + } + + private fun scheduleFlush() { + flushJob?.cancel() + flushJob = + viewModelScope.launch { + delay(_flushWindowMs.value) + flushBuffer() + } + } + + private fun flushBuffer() { + flushJob?.cancel() + flushJob = null + if (inputBuffer.isEmpty()) return + + val text = inputBuffer.toString() + inputBuffer.clear() + _pendingInput.value = "" + + if (_sessionState.value != SessionState.OPEN) return + + // No local echo — unflushed keystrokes are shown via pendingInput (rendered dim) and the + // remote PTY will echo them back as OUTPUT frames once the INPUT is delivered. + + val payload = text.encodeToByteArray().toByteString() + viewModelScope.launch { + var offset = 0 + while (offset < payload.size) { + val end = (offset + MAX_INPUT_CHUNK_BYTES).coerceAtMost(payload.size) + sendFrame( + RemoteShell( + op = RemoteShell.OpCode.INPUT, + session_id = sessionId.value, + seq = allocSeq(), + ack_seq = currentAckSeq(), + payload = payload.substring(offset, end), + ), + ) + offset = end + } + } + } + + // endregion + + // region --- Heartbeat loop --- + + private var heartbeatJob: Job? = null + + @Suppress("LoopWithTooManyJumpStatements") + private fun startHeartbeatLoop() { + heartbeatJob?.cancel() + heartbeatJob = + safeLaunch(context = dispatchers.io, tag = "remoteShellHeartbeat") { + val terminalStates = setOf(SessionState.CLOSED, SessionState.ERROR, SessionState.CLOSING) + while (_sessionState.value !in terminalStates) { + delay(HEARTBEAT_POLL_MS) + if (_sessionState.value != SessionState.OPEN) continue + if (!isHeartbeatDue()) continue + sendFrame( + RemoteShell( + op = RemoteShell.OpCode.PING, + session_id = sessionId.value, + seq = allocSeq(), + ack_seq = currentAckSeq(), + last_tx_seq = highestSentSeq(), + last_rx_seq = currentAckSeq(), + ), + isHeartbeat = true, + ) + } + } + } + + // endregion + + // region --- Inbound frame processing --- + + init { + safeLaunch(context = dispatchers.io, tag = "remoteShellFrameCollector") { + remoteShellHandler.lastFrame.collect { (from, frame) -> + if (from != destNum) return@collect + val ourSession = sessionId.value + if (ourSession != 0 && frame.session_id != ourSession) return@collect + noteActivity() + processFrame(frame) + } + } + } + + @Suppress("CyclomaticComplexMethod") + private suspend fun processFrame(frame: RemoteShell) { + pruneSentFrames(frame.ack_seq) + + if (frame.op == RemoteShell.OpCode.ACK) { + if (frame.last_rx_seq > 0) { + replayFrom(frame.last_rx_seq + 1) + } + return + } + + when (noteReceivedSeq(frame.seq)) { + RxAction.DUPLICATE -> { + requestMissingSeqOnce()?.let { sendAck(replayFrom = it) } + } + RxAction.GAP -> { + rxMutex.withLock { pendingRxFrames[frame.seq] = frame } + requestMissingSeqOnce()?.let { sendAck(replayFrom = it) } + } + RxAction.PROCESS -> { + handleInOrderFrame(frame) + drainPendingRxFrames() + requestMissingSeqOnce()?.let { sendAck(replayFrom = it) } + } + } + } + + @Suppress("LoopWithTooManyJumpStatements") + private suspend fun drainPendingRxFrames() { + while (true) { + val next = rxMutex.withLock { pendingRxFrames.remove(nextExpectedRxSeq) } ?: break + if (noteReceivedSeq(next.seq) == RxAction.PROCESS) { + handleInOrderFrame(next) + } else { + rxMutex.withLock { pendingRxFrames[next.seq] = next } + break + } + } + } + + @Suppress("CyclomaticComplexMethod", "ReturnCount") + private suspend fun handleInOrderFrame(frame: RemoteShell) { + when (frame.op) { + RemoteShell.OpCode.OPEN_OK -> { + _sessionState.update { SessionState.OPEN } + val payload = frame.payload.toByteArray() + if (payload.size >= UINT32_BYTES) { + _remotePid.update { decodeUint32BE(payload) } + } + Logger.i { "RemoteShell OPEN_OK session=${frame.session_id} pid=${_remotePid.value}" } + } + RemoteShell.OpCode.OUTPUT -> { + val text = + frame.payload.utf8().ifEmpty { + return + } + text.lines().forEach { appendOutput(it) } + } + RemoteShell.OpCode.ERROR -> { + appendOutput("[error] ${frame.payload.utf8().ifEmpty { "unknown error" }}") + _sessionState.update { SessionState.ERROR } + } + RemoteShell.OpCode.CLOSED -> { + val msg = frame.payload.utf8() + appendOutput(if (msg.isNotEmpty()) "[session closed: $msg]" else "[session closed]") + _sessionState.update { SessionState.CLOSED } + } + RemoteShell.OpCode.PONG -> { + val peerLastTxSeq = frame.last_tx_seq + val peerLastRxSeq = frame.last_rx_seq + val ourHighestTx = highestSentSeq() + if (peerLastRxSeq in 1.. currentAckSeq()) { + rxMutex.withLock { if (peerLastTxSeq > highestSeenRxSeq) highestSeenRxSeq = peerLastTxSeq } + requestMissingSeqOnce()?.let { sendAck(replayFrom = it) } + } + } + else -> Logger.d { "RemoteShell unhandled in-order op=${frame.op}" } + } + } + + // endregion + + // region --- Frame dispatch --- + + private suspend fun sendAck(replayFrom: Int? = null) { + sendFrame( + RemoteShell( + op = RemoteShell.OpCode.ACK, + session_id = sessionId.value, + seq = 0, + ack_seq = currentAckSeq(), + last_rx_seq = replayFrom?.let { it - 1 } ?: 0, + ), + remember = false, + ) + } + + private suspend fun sendFrame(frame: RemoteShell, remember: Boolean = true, isHeartbeat: Boolean = false) { + if (remember) { + rememberSent( + SentFrame( + op = frame.op, + sessionId = frame.session_id, + seq = frame.seq, + ackSeq = frame.ack_seq, + payload = frame.payload, + cols = frame.cols, + rows = frame.rows, + flags = frame.flags, + lastTxSeq = frame.last_tx_seq, + lastRxSeq = frame.last_rx_seq, + ), + ) + } + noteActivity(isHeartbeat) + safeLaunch(context = dispatchers.io, tag = "remoteShellSend") { + val myNum = nodeRepository.myNodeInfo.value?.myNodeNum ?: 0 + val packet = + DataPacket( + to = DataPacket.nodeNumToDefaultId(destNum), + from = DataPacket.nodeNumToDefaultId(myNum), + bytes = RemoteShell.ADAPTER.encode(frame).toByteString(), + dataType = PortNum.REMOTE_SHELL_APP.value, + // PKC_CHANNEL_INDEX (8) triggers Curve25519 encryption in CommandSenderImpl. + // The firmware rejects DMShell packets that are not PKI-encrypted. + channel = DataPacket.PKC_CHANNEL_INDEX, + ) + commandSender.sendData(packet) + } + } + + // endregion + + // region --- Helpers --- + + private fun appendOutput(line: String) { + _outputLines.update { current -> (current + line).takeLast(MAX_OUTPUT_LINES) } + } + + // endregion + + override fun onCleared() { + super.onCleared() + heartbeatJob?.cancel() + if (_sessionState.value == SessionState.OPEN) closeSession() + Logger.d { "RemoteShellViewModel cleared for destNum=$destNum" } + } +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/ScanlinesOverlay.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/ScanlinesOverlay.kt new file mode 100644 index 000000000..6753c815d --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/ScanlinesOverlay.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025-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.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset + +/** Pitch between scanlines in pixels (one dark line every N px). */ +@Suppress("MagicNumber") +private const val SCANLINE_PITCH_PX = 2f + +/** Opacity of each dark scanline stripe. Higher = more pronounced CRT effect. */ +@Suppress("MagicNumber") +private const val SCANLINE_ALPHA = 0.18f + +/** + * Draws horizontal semi-transparent dark stripes over its entire bounds to simulate the inter-scan dark bands of a CRT + * phosphor screen. + * + * Render this as a transparent overlay on top of [TerminalCanvas]. The [flickerAlpha] modulates the stripe opacity so + * the scanlines breathe in sync with the phosphor flicker. + * + * @param preset Active [PhosphorPreset] (used for scanline tint colour). + * @param flickerAlpha Animated alpha from [FlickerEffect]; modulates stripe visibility. + */ +@Suppress("MagicNumber") +@Composable +fun ScanlinesOverlay(preset: PhosphorPreset, flickerAlpha: Float, modifier: Modifier = Modifier) { + Canvas(modifier = modifier.fillMaxSize()) { + val stripeColor = preset.bg.copy(alpha = SCANLINE_ALPHA * flickerAlpha) + var y = 0f + while (y < size.height) { + drawLine(color = stripeColor, start = Offset(0f, y), end = Offset(size.width, y), strokeWidth = 1f) + y += SCANLINE_PITCH_PX + } + } +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalCanvas.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalCanvas.kt new file mode 100644 index 000000000..8f006721e --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/TerminalCanvas.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2025-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.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.sp + +/** Extra vertical gap between lines to mimic CRT inter-scan spacing (px). */ +@Suppress("MagicNumber") +private const val LINE_SPACING_PX = 2f + +/** Terminal glyph size in SP. */ +@Suppress("MagicNumber") +private const val TERMINAL_FONT_SIZE_SP = 13 + +/** + * Draws [lines] of terminal output as a monospace character grid with a phosphor bloom effect. + * + * Each confirmed text row is rendered in two passes: + * 1. A glow pass (two translucent offset copies) using [PhosphorPreset.glow] to produce the phosphor halo. + * 2. A sharp pass with [PhosphorPreset.fg] for crisp readable glyphs on top. + * + * The last row additionally renders any [pendingInput] text as a dim suffix using [PhosphorPreset.dim]. Pending + * characters are keystrokes that have been typed but not yet flushed to the mesh — they appear immediately for + * responsiveness but at reduced brightness to signal their "in-flight" status. On flush [pendingInput] is `""` and the + * dim suffix disappears instantly. + * + * Only Compose/KMP APIs are used here — no `android.*` imports. + * + * @param lines Ordered confirmed output lines (oldest first). + * @param pendingInput Unflushed keystrokes to render as a dim suffix on the last line. + * @param preset Active [PhosphorPreset]. + * @param flickerAlpha Animated alpha from [FlickerEffect]. + * @param showCursor Whether to render the blinking block cursor after [pendingInput]. + */ +@Suppress("LongMethod", "MagicNumber") +@Composable +fun TerminalCanvas( + lines: List, + pendingInput: String, + preset: PhosphorPreset, + flickerAlpha: Float, + showCursor: Boolean, + modifier: Modifier = Modifier, +) { + val measurer = rememberTextMeasurer() + + val baseStyle = + remember(preset) { TextStyle(fontFamily = FontFamily.Monospace, fontSize = TERMINAL_FONT_SIZE_SP.sp) } + val fgStyle = remember(preset) { baseStyle.copy(color = preset.fg) } + val glowStyle = remember(preset) { baseStyle.copy(color = preset.glow) } + val dimStyle = remember(preset) { baseStyle.copy(color = preset.dim) } + + val charWidthPx = remember(preset) { measurer.measure("M", fgStyle).size.width.toFloat() } + + Canvas(modifier = modifier.fillMaxSize()) { + drawRect(color = preset.bg) + + val fontSizePx = TERMINAL_FONT_SIZE_SP.sp.toPx() + val lineHeightPx = fontSizePx + LINE_SPACING_PX + + val visibleLines = (size.height / lineHeightPx).toInt().coerceAtLeast(1) + val displayLines = lines.takeLast(visibleLines) + + displayLines.forEachIndexed { index, line -> + val y = index * lineHeightPx + val isLastLine = index == displayLines.lastIndex + + // --- Glow pass --- + drawText( + textMeasurer = measurer, + text = line, + style = glowStyle.copy(color = preset.glow.copy(alpha = flickerAlpha * 0.6f)), + topLeft = Offset(-1f, y - 1f), + ) + drawText( + textMeasurer = measurer, + text = line, + style = glowStyle.copy(color = preset.glow.copy(alpha = flickerAlpha * 0.4f)), + topLeft = Offset(1f, y + 1f), + ) + + // --- Sharp foreground pass --- + drawText( + textMeasurer = measurer, + text = line, + style = fgStyle.copy(color = preset.fg.copy(alpha = flickerAlpha)), + topLeft = Offset(0f, y), + ) + + // --- Pending input suffix (last line only) --- + if (isLastLine && pendingInput.isNotEmpty()) { + val confirmedWidthPx = measurer.measure(line, fgStyle).size.width.toFloat() + drawText( + textMeasurer = measurer, + text = pendingInput, + style = dimStyle.copy(color = preset.dim.copy(alpha = flickerAlpha)), + topLeft = Offset(confirmedWidthPx, y), + ) + } + } + + // If there are no confirmed lines yet but there is pending input, draw it on row 0. + if (displayLines.isEmpty() && pendingInput.isNotEmpty()) { + drawText( + textMeasurer = measurer, + text = pendingInput, + style = dimStyle.copy(color = preset.dim.copy(alpha = flickerAlpha)), + topLeft = Offset.Zero, + ) + } + + // --- Block cursor (positioned after pending input, or after the last confirmed line) --- + if (showCursor) { + val lastConfirmed = displayLines.lastOrNull() ?: "" + val cursorRow = displayLines.lastIndex.coerceAtLeast(0) + val confirmedWidth = + if (lastConfirmed.isNotEmpty()) { + measurer.measure(lastConfirmed, fgStyle).size.width.toFloat() + } else { + 0f + } + val pendingWidth = + if (pendingInput.isNotEmpty()) { + measurer.measure(pendingInput, dimStyle).size.width.toFloat() + } else { + 0f + } + val cursorX = confirmedWidth + pendingWidth + val cursorY = cursorRow * lineHeightPx + drawRect( + color = preset.fg.copy(alpha = flickerAlpha), + topLeft = Offset(cursorX, cursorY), + size = Size(charWidthPx, lineHeightPx - LINE_SPACING_PX), + ) + } + } +} 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 233942f00..1debb9f3f 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 @@ -46,10 +46,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 @@ -66,6 +68,8 @@ import org.meshtastic.feature.node.metrics.PositionLogScreen 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.terminal.RemoteShellScreen +import org.meshtastic.feature.node.metrics.terminal.RemoteShellViewModel import kotlin.reflect.KClass @OptIn(ExperimentalMaterial3AdaptiveApi::class) @@ -147,6 +151,15 @@ fun EntryProviderScope.nodeDetailGraph( tracerouteMapScreen(args.destNum, args.requestId, args.logUuid) { backStack.removeLastOrNull() } } + // RemoteShell uses its own ViewModel and is wired up separately from the MetricsViewModel-based screens. + entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args -> + val remoteShellViewModel = koinViewModel { parametersOf(args.destNum) } + RemoteShellScreen( + viewModel = remoteShellViewModel, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, + ) + } + NodeDetailScreen.entries.forEach { routeInfo -> when (routeInfo.routeClass) { NodeDetailRoute.DeviceMetrics::class -> @@ -165,6 +178,7 @@ fun EntryProviderScope.nodeDetailGraph( addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } NodeDetailRoute.NeighborInfoLog::class -> addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } + // NodeDetailRoute.RemoteShell is handled by the dedicated entry above. else -> Unit } } @@ -248,4 +262,12 @@ enum class NodeDetailScreen( Res.drawable.ic_group, { metricsVM, onNavigateUp -> PaxMetricsScreen(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/iosMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt b/feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt new file mode 100644 index 000000000..828dd1ad9 --- /dev/null +++ b/feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025-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.runtime.Composable +import androidx.compose.ui.Modifier + +/** iOS actual — CRT curvature is a no-op; AGSL is Android-only. */ +@Composable actual fun Modifier.crtCurvature(strength: Float): Modifier = this diff --git a/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt b/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt new file mode 100644 index 000000000..c31a9c44d --- /dev/null +++ b/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/metrics/terminal/CrtCurvatureModifier.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025-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.runtime.Composable +import androidx.compose.ui.Modifier + +/** JVM (desktop/test) actual — CRT curvature is a no-op; AGSL is Android-only. */ +@Composable actual fun Modifier.crtCurvature(strength: Float): Modifier = this