Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c90bdfbe42 | ||
|
|
27b6e647c1 | ||
|
|
0631e69ef0 | ||
|
|
f98c637cd9 | ||
|
|
554182d956 |
@@ -15,6 +15,7 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.Maestro">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<link rel="stylesheet" href="vendor/xterm.css" />
|
||||
<style>
|
||||
html, body, #terminal { height: 100%; margin: 0; padding: 0; background: #121419; overflow: hidden; }
|
||||
.xterm { padding: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="terminal"></div>
|
||||
<script src="vendor/xterm.js"></script>
|
||||
<script src="vendor/addon-fit.js"></script>
|
||||
<script>
|
||||
// Owns the `WS /api/attach/:id` connection directly, the same shape as
|
||||
// web/src/components/terminal/TerminalPane.tsx — PTY bytes in binary
|
||||
// frames both ways, `ClientControl` JSON in text frames. The native
|
||||
// Android side (TerminalWebView.kt) never touches the socket; it only
|
||||
// calls the maestro* bridge functions below for the extra key bar.
|
||||
var params = new URLSearchParams(location.search);
|
||||
var wsUrl = params.get("ws");
|
||||
|
||||
var term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
fontSize: 13,
|
||||
convertEol: false,
|
||||
scrollback: 2000,
|
||||
theme: {
|
||||
background: "#121419",
|
||||
foreground: "#e6e8ec",
|
||||
cursor: "#5fe3a1",
|
||||
cursorAccent: "#121419",
|
||||
selectionBackground: "#2b6b4f",
|
||||
},
|
||||
});
|
||||
var fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
var container = document.getElementById("terminal");
|
||||
term.open(container);
|
||||
fitAddon.fit();
|
||||
|
||||
var ws = null;
|
||||
var awaitingReplay = true;
|
||||
var armed = { ctrl: false, alt: false };
|
||||
|
||||
function sendControl(msg) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
function sendInput(data) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(new TextEncoder().encode(data));
|
||||
}
|
||||
|
||||
function connect() {
|
||||
awaitingReplay = true;
|
||||
ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.onopen = function () {
|
||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||
};
|
||||
|
||||
ws.onmessage = function (ev) {
|
||||
if (typeof ev.data === "string") return; // control frame, nothing to render
|
||||
var replay = awaitingReplay;
|
||||
awaitingReplay = false;
|
||||
term.write(new Uint8Array(ev.data), function () {
|
||||
if (replay) term.scrollToBottom();
|
||||
});
|
||||
};
|
||||
|
||||
ws.onclose = function (ev) {
|
||||
if (ev.code === 1000) return;
|
||||
term.write("\r\n\x1b[31m[disconnected]\x1b[0m\r\n");
|
||||
};
|
||||
}
|
||||
|
||||
// Ctrl maps to the C0 control range (A-Z, [, \, ], ^, _); Alt prefixes ESC
|
||||
// — the same encoding buildKeyBar/applyModsToChar use on the web client.
|
||||
function applyModsToChar(ch, ctrl, alt) {
|
||||
var out = ch;
|
||||
if (ctrl) {
|
||||
var code = ch.toUpperCase().charCodeAt(0);
|
||||
if (code >= 64 && code <= 95) out = String.fromCharCode(code - 64);
|
||||
}
|
||||
if (alt) out = "\x1b" + out;
|
||||
return out;
|
||||
}
|
||||
|
||||
// CSI cursor sequence with the modifier parameter xterm expects:
|
||||
// `1;<mod>` where 3=alt, 5=ctrl, 7=ctrl+alt.
|
||||
function cursorSeq(final, ctrl, alt) {
|
||||
if (!ctrl && !alt) return "\x1b[" + final;
|
||||
var mod = 1 + (alt ? 2 : 0) + (ctrl ? 4 : 0);
|
||||
return "\x1b[1;" + mod + final;
|
||||
}
|
||||
|
||||
term.onData(function (data) {
|
||||
if ((armed.ctrl || armed.alt) && data.length === 1) {
|
||||
var hadCtrl = armed.ctrl;
|
||||
var hadAlt = armed.alt;
|
||||
sendInput(applyModsToChar(data, hadCtrl, hadAlt));
|
||||
armed.ctrl = false;
|
||||
armed.alt = false;
|
||||
if (window.AndroidBridge) {
|
||||
if (hadCtrl) AndroidBridge.onModConsumed("ctrl");
|
||||
if (hadAlt) AndroidBridge.onModConsumed("alt");
|
||||
}
|
||||
} else {
|
||||
sendInput(data);
|
||||
}
|
||||
});
|
||||
|
||||
term.onResize(function (size) {
|
||||
sendControl({ type: "Resize", cols: size.cols, rows: size.rows });
|
||||
});
|
||||
|
||||
container.addEventListener("click", function () {
|
||||
term.focus();
|
||||
});
|
||||
|
||||
var ro = new ResizeObserver(function () {
|
||||
try {
|
||||
fitAddon.fit();
|
||||
} catch (e) {
|
||||
// container not measurable yet (e.g. mid-teardown) — next observation retries
|
||||
}
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
// Bridge called from TerminalWebViewController via evaluateJavascript —
|
||||
// the native extra key bar's Tab/Esc/arrow buttons and Ctrl/Alt toggles.
|
||||
window.maestroKey = function (key) {
|
||||
var ctrl = armed.ctrl;
|
||||
var alt = armed.alt;
|
||||
var seq;
|
||||
switch (key) {
|
||||
case "Tab": seq = "\t"; break;
|
||||
case "Esc": seq = "\x1b"; break;
|
||||
case "Up": seq = cursorSeq("A", ctrl, alt); break;
|
||||
case "Down": seq = cursorSeq("B", ctrl, alt); break;
|
||||
case "Right": seq = cursorSeq("C", ctrl, alt); break;
|
||||
case "Left": seq = cursorSeq("D", ctrl, alt); break;
|
||||
default: return;
|
||||
}
|
||||
sendInput(seq);
|
||||
armed.ctrl = false;
|
||||
armed.alt = false;
|
||||
};
|
||||
|
||||
window.maestroToggleMod = function (name) {
|
||||
if (name === "ctrl" || name === "alt") armed[name] = !armed[name];
|
||||
};
|
||||
|
||||
window.maestroFocus = function () {
|
||||
term.focus();
|
||||
};
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,2 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=addon-fit.js.map
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility:not(.debug),
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree {
|
||||
user-select: text;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||
* explicitly in the generated class and reset to 1 here */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xterm-underline-1 { text-decoration: underline; }
|
||||
.xterm-underline-2 { text-decoration: double underline; }
|
||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||
|
||||
.xterm-overline {
|
||||
text-decoration: overline;
|
||||
}
|
||||
|
||||
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -22,7 +22,10 @@ import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -32,7 +35,10 @@ import kotlinx.coroutines.launch
|
||||
import pt.naps.maestro.connection.DaemonLink
|
||||
import pt.naps.maestro.connection.DaemonPrefs
|
||||
import pt.naps.maestro.connection.LinkState
|
||||
import pt.naps.maestro.data.Session
|
||||
import pt.naps.maestro.ui.sidebar.SidebarContent
|
||||
import pt.naps.maestro.ui.sidebar.sessionName
|
||||
import pt.naps.maestro.ui.terminal.TerminalScreen
|
||||
import pt.naps.maestro.ui.theme.MaestroTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
@@ -50,34 +56,49 @@ class MainActivity : ComponentActivity() {
|
||||
setContent {
|
||||
MaestroTheme {
|
||||
val linkState by daemonLink.state.collectAsState()
|
||||
val baseUrl by prefs.baseUrl.collectAsState()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
var openSession by remember { mutableStateOf<Session?>(null) }
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
|
||||
SidebarContent(
|
||||
catalogStore = daemonLink.catalog,
|
||||
onOpenSession = { scope.launch { drawerState.close() } },
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Maestro") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
Text("☰")
|
||||
}
|
||||
},
|
||||
)
|
||||
val session = openSession
|
||||
if (session != null) {
|
||||
TerminalScreen(
|
||||
daemonBaseUrl = baseUrl,
|
||||
sessionId = session.id,
|
||||
sessionTitle = sessionName(session),
|
||||
onBack = { openSession = null },
|
||||
)
|
||||
} else {
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
|
||||
SidebarContent(
|
||||
catalogStore = daemonLink.catalog,
|
||||
onOpenSession = { s ->
|
||||
openSession = s
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
PlaceholderScreen(modifier = Modifier.padding(innerPadding), linkState = linkState)
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Maestro") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
Text("☰")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
PlaceholderScreen(modifier = Modifier.padding(innerPadding), linkState = linkState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package pt.naps.maestro.ui.terminal
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** One button on the [TerminalKeyBar]. A [Modifier] toggles Ctrl/Alt state
|
||||
* in the terminal page's own JS instead of sending a byte sequence. */
|
||||
sealed class TerminalKeyAction(val label: String) {
|
||||
data class Modifier(val name: String, val display: String) : TerminalKeyAction(display)
|
||||
data class Send(val display: String, val key: String) : TerminalKeyAction(display)
|
||||
}
|
||||
|
||||
/**
|
||||
* Termux-style extra key row for the on-screen keyboard (issue #266): Tab,
|
||||
* Esc, Ctrl/Alt, and the four cursor arrows. Ctrl/Alt are one-shot modifiers
|
||||
* — armed here, then folded into the very next key (tapped on this bar or
|
||||
* typed on the soft keyboard) by `assets/terminal/index.html`'s own JS.
|
||||
*/
|
||||
val TERMINAL_KEY_ACTIONS: List<TerminalKeyAction> = listOf(
|
||||
TerminalKeyAction.Send("Esc", "Esc"),
|
||||
TerminalKeyAction.Send("Tab", "Tab"),
|
||||
TerminalKeyAction.Modifier("ctrl", "Ctrl"),
|
||||
TerminalKeyAction.Modifier("alt", "Alt"),
|
||||
TerminalKeyAction.Send("↑", "Up"),
|
||||
TerminalKeyAction.Send("↓", "Down"),
|
||||
TerminalKeyAction.Send("←", "Left"),
|
||||
TerminalKeyAction.Send("→", "Right"),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TerminalKeyBar(
|
||||
armed: Set<String>,
|
||||
onSend: (String) -> Unit,
|
||||
onToggleModifier: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
) {
|
||||
for (action in TERMINAL_KEY_ACTIONS) {
|
||||
when (action) {
|
||||
is TerminalKeyAction.Modifier -> {
|
||||
val isArmed = action.name in armed
|
||||
TextButton(onClick = { onToggleModifier(action.name) }) {
|
||||
Text(
|
||||
text = action.display,
|
||||
color = if (isArmed) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is TerminalKeyAction.Send -> {
|
||||
TextButton(onClick = { onSend(action.key) }) {
|
||||
Text(text = action.display)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package pt.naps.maestro.ui.terminal
|
||||
|
||||
import java.net.URLEncoder
|
||||
import pt.naps.maestro.api.Routes
|
||||
import pt.naps.maestro.data.Uuid
|
||||
|
||||
/**
|
||||
* The `file:///android_asset/terminal/index.html` URL a [TerminalWebView]
|
||||
* loads for one session. The embedded xterm.js page reads its `ws` query
|
||||
* param and opens that as its own `WS /api/attach/:id` connection directly
|
||||
* — this object only computes the URL, it never touches the socket.
|
||||
*/
|
||||
object TerminalPageUrl {
|
||||
private const val ASSET_URL = "file:///android_asset/terminal/index.html"
|
||||
|
||||
fun build(daemonBaseUrl: String, sessionId: Uuid): String {
|
||||
val wsUrl = Routes.attachWsUrl(daemonBaseUrl, sessionId)
|
||||
val encoded = URLEncoder.encode(wsUrl, "UTF-8")
|
||||
return "$ASSET_URL?ws=$encoded"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package pt.naps.maestro.ui.terminal
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.isImeVisible
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import pt.naps.maestro.data.Uuid
|
||||
|
||||
/**
|
||||
* Full-screen terminal pane for one session (issue #266): a [TerminalWebView]
|
||||
* showing xterm.js bound to `WS /api/attach/:id`, plus the native
|
||||
* [TerminalKeyBar] overlaid above the soft keyboard while it's open.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun TerminalScreen(
|
||||
daemonBaseUrl: String,
|
||||
sessionId: Uuid,
|
||||
sessionTitle: String,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val pageUrl = remember(daemonBaseUrl, sessionId) { TerminalPageUrl.build(daemonBaseUrl, sessionId) }
|
||||
var controller by remember { mutableStateOf<TerminalWebViewController?>(null) }
|
||||
var armed by remember { mutableStateOf(emptySet<String>()) }
|
||||
val keyboardOpen = WindowInsets.isImeVisible
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(sessionTitle) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Text(text = "←", style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
TerminalWebView(
|
||||
pageUrl = pageUrl,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxSize(),
|
||||
onReady = { controller = it },
|
||||
onModifierConsumed = { name -> armed = armed - name },
|
||||
)
|
||||
if (keyboardOpen) {
|
||||
TerminalKeyBar(
|
||||
armed = armed,
|
||||
onSend = { key ->
|
||||
controller?.sendKey(key)
|
||||
armed = emptySet()
|
||||
},
|
||||
onToggleModifier = { name ->
|
||||
armed = if (name in armed) armed - name else armed + name
|
||||
controller?.toggleModifier(name)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package pt.naps.maestro.ui.terminal
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Matches `#121419` in `assets/terminal/index.html` and the web SPA's own
|
||||
* xterm theme, so the WebView doesn't flash white while the page loads. */
|
||||
private const val TERMINAL_BACKGROUND_ARGB = 0xFF121419.toInt()
|
||||
|
||||
/**
|
||||
* Drives the terminal page loaded into a [TerminalWebView] from native code
|
||||
* — the extra key bar's Tab/Esc/arrow buttons and Ctrl/Alt toggles. The page
|
||||
* itself owns the `WS /api/attach/:id` socket and the xterm.js instance;
|
||||
* this controller only calls the `maestro*` bridge functions it exposes.
|
||||
*/
|
||||
class TerminalWebViewController internal constructor(private val webView: WebView) {
|
||||
fun sendKey(key: String) {
|
||||
val arg = JSONObject.quote(key)
|
||||
webView.evaluateJavascript("window.maestroKey && window.maestroKey($arg)", null)
|
||||
}
|
||||
|
||||
fun toggleModifier(name: String) {
|
||||
val arg = JSONObject.quote(name)
|
||||
webView.evaluateJavascript("window.maestroToggleMod && window.maestroToggleMod($arg)", null)
|
||||
}
|
||||
|
||||
fun focus() {
|
||||
webView.requestFocus()
|
||||
webView.evaluateJavascript("window.maestroFocus && window.maestroFocus()", null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal pane composable for issue #266: a [WebView] loading the bundled
|
||||
* xterm.js page (`assets/terminal/index.html`), which connects straight to
|
||||
* the daemon's `WS /api/attach/:id` for this session. [onModifierConsumed]
|
||||
* fires when the page's own JS folds an armed Ctrl/Alt into a key typed on
|
||||
* the soft keyboard, so the native key bar can clear its toggle to match.
|
||||
*/
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
fun TerminalWebView(
|
||||
pageUrl: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onReady: (TerminalWebViewController) -> Unit = {},
|
||||
onModifierConsumed: (String) -> Unit = {},
|
||||
) {
|
||||
val mainHandler = remember { Handler(Looper.getMainLooper()) }
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
setBackgroundColor(TERMINAL_BACKGROUND_ARGB)
|
||||
addJavascriptInterface(
|
||||
object {
|
||||
@JavascriptInterface
|
||||
fun onModConsumed(name: String) {
|
||||
mainHandler.post { onModifierConsumed(name) }
|
||||
}
|
||||
},
|
||||
"AndroidBridge",
|
||||
)
|
||||
onReady(TerminalWebViewController(this))
|
||||
loadUrl(pageUrl)
|
||||
}
|
||||
},
|
||||
update = { webView ->
|
||||
if (webView.url != pageUrl) webView.loadUrl(pageUrl)
|
||||
},
|
||||
onRelease = { it.destroy() },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package pt.naps.maestro.ui.terminal
|
||||
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TerminalKeyBarActionsTest {
|
||||
@Test
|
||||
fun `contains tab esc and all four arrows`() {
|
||||
val labels = TERMINAL_KEY_ACTIONS.map { it.label }
|
||||
assertTrue(labels.containsAll(listOf("Tab", "Esc", "↑", "↓", "←", "→")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ctrl and alt are modifiers rather than sends`() {
|
||||
val modifierNames = TERMINAL_KEY_ACTIONS.filterIsInstance<TerminalKeyAction.Modifier>().map { it.name }
|
||||
assertTrue(modifierNames.containsAll(listOf("ctrl", "alt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `arrow keys carry the maestroKey names the terminal page expects`() {
|
||||
val sendKeys = TERMINAL_KEY_ACTIONS.filterIsInstance<TerminalKeyAction.Send>().map { it.key }
|
||||
assertTrue(sendKeys.containsAll(listOf("Up", "Down", "Left", "Right", "Tab", "Esc")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package pt.naps.maestro.ui.terminal
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TerminalPageUrlTest {
|
||||
@Test
|
||||
fun `builds asset url with the encoded attach ws url`() {
|
||||
val url = TerminalPageUrl.build("http://192.168.1.5:7070", "abc-123")
|
||||
assertEquals(
|
||||
"file:///android_asset/terminal/index.html" +
|
||||
"?ws=ws%3A%2F%2F192.168.1.5%3A7070%2Fapi%2Fattach%2Fabc-123",
|
||||
url,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `swaps an https base to wss`() {
|
||||
val url = TerminalPageUrl.build("https://daemon.example.com", "s1")
|
||||
assertTrue(url.contains("wss%3A%2F%2Fdaemon.example.com"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `strips a trailing slash from the daemon base url`() {
|
||||
val url = TerminalPageUrl.build("http://host:7070/", "s1")
|
||||
assertTrue(url.contains("ws%3A%2F%2Fhost%3A7070%2Fapi%2Fattach%2Fs1"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user