Files
DodoSSH/src/DodoSSH.Client.Shell/WebAssets/terminal.js
T
jaap-janandClaude Opus 5 5593f337b6 Give the phone the second design, and both heads the palette it arrives with
The Android v2 design is what this head draws now: four destinations in a bottom bar — Hosts,
Terminal, Keychain, More — with snippets, SFTP, S3, logs and preferences one tap deeper behind the
last. The first design's four had nothing behind them, which is what made a hub worth building.

The palette moved from green-black to blue-black, and it moved in the shared project because that is
where it lives and the desktop v2 specifies the same seventeen tokens. One colour changed meaning
rather than value, and it is the only semantic change in the file. Green used to *be* the accent, so
Ellipse.dot.live filled with Accent and "the thing to press" and "a shell is open on this host" were
the same colour by construction. v2 makes the accent blue and keeps a green for status alone, which
finally separates them: Live is that green and nothing merely interactive may use it. The accent is
also two colours now — Accent fills, AccentText writes — because a row of chips in the fill colour is
a row of things that all look like the primary action.

A palette is not one file, which is the part worth knowing before the next one. Nine hex literals
lived outside it: the nav bar's own label colours, the accessory keys and their Ctrl-latched state,
two scrims, the window background Android paints before Avalonia has a frame, and the launcher
vector. The two C# sites now resolve from the dictionary by name rather than restating it. The
renderer's page cannot — it is served to a WebView over a loopback socket — so terminal.css and
terminal.js keep hand-copied values and say so at both sites.

ShellScreen gained More and Buckets, appended rather than slotted in. SFTP and S3 are one screen over
one TransfersViewModel differing only in which picker they offer, and the kind is set by the button
that navigates rather than on arrival — doing it in OnScreenChanged made every arrival at Transfers
force the picker back to hosts, including the desktop's own rail arriving at a screen with a bucket
already open. It refuses to change kind while a session is live, because there is one session behind
both destinations and switching under it would title a screen S3 while it listed an SFTP host.

What the design draws and this does not, on the usual grounds. The FORWARDING screen: nothing here
forwards anything, so every toggle would be a control with no effect — it is a paragraph on the hub
naming the absence, for the reason the desktop keeps TEAMS in its rail. The terminal's `23 ms · fwd
5432`. An ED25519 badge and a SHA256 line on keychain cards, which need an algorithm field and a
fingerprint the item type does not have. An `agent` chip, for an agent that does not exist. Snippet
run history and exit codes. The Logs FOLLOW pill, which claims a live tail over records that are
written once at close and read when the screen opens, and the severity filter, which has nothing to
count — that chip row is spent on the real choice, which of the two logs. S3 bucket totals and
lifecycle. And the + on HOSTS, which would open a host editor this head has not got.

SFTP is browse, open and delete. Both transfer commands work, and what they work against is the local
pane: QueueDownloads writes to Path.Combine(LocalPath, name), and LocalPath starts at
SpecialFolder.UserProfile, which on Android is the application's own private directory. A download
would have reported success and left the file where the person who asked for it cannot open it, which
is worse than not offering it — a refusal is visible and a file in /data/user/0/ is not. The queue is
not drawn either, since nothing here can put anything in it. Both return with the document picker.
The foreground service still counts zero transfers, and the reason moved rather than went away.

Four defects worth naming, because three of them are the kind that compile. A Button as a ListBox
ItemTemplate swallows the pointer press before the list sees it, so the files listing selected
nothing and every command reading the selection did nothing — the row is a Border now and the
phone-only single-tap-to-open is a Tapped handler, which also keeps a desktop single click from
walking into directories. Avalonia type selectors are exact, so TextBlock.fingerprint never matched
SelectableTextBlock and every fingerprint on this head rendered proportional and unwrapped: that was
breaking the never-truncated rule on the host-key sheet already. The new two-level hierarchy had no
handler for the system back gesture, so back left the application from a log screen. And the tab's
close cross had shrunk to a 30x32 target flush against the select target, which is the one control
here that ends a shell with no confirmation and no undo.

Fingerprint unlock is raised on arriving at the lock screen rather than waiting for its button, which
is still there. Only at launch: a lock the user asked for is not answered with an immediate request
to unlock, which makes LOCK look inert and trains the reflex of authenticating at a prompt nobody
asked for. And once, because a declined gesture leaves the passphrase box exactly where it was and a
prompt that came back after being dismissed would be a modal you cannot get out of to type into it.

Two fixes fall on the desktop. Its file listing coloured directories with Info and executables with
Accent, which was blue against green and is now two steps of one blue; an executable is Live now.
And a bucket's folders were drawn with a 0001-01-01 timestamp, because a prefix has no modification
time — blank now, for the reason a directory's size is blank.

Verified by the whole suite: 1309 tests over nineteen projects, none failing, including the layout
suite that stands up real Avalonia and parses every desktop screen. Both heads build. Not verified on
a device — nothing in this head ever has been; see docs/android-port.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2
2026-08-02 18:23:53 +02:00

449 lines
15 KiB
JavaScript

'use strict';
/*
The renderer half of the terminal data plane.
Two things here are load-bearing and easy to get wrong:
1. Output is acknowledged from term.write's completion callback, never on receipt. The
acknowledgement returns flow-control credit to the host, so acknowledging early would tell
the host the screen has caught up when it has not — and the whole point of the credit
window is that it reflects what has actually been rendered. Acknowledge on receipt and a
remote running `yes` grows this page's memory until the tab dies.
2. Output is written as a Uint8Array, not a string. xterm decodes UTF-8 itself and carries
partial sequences across writes. Decoding here would corrupt any multi-byte character that
happened to straddle a frame boundary, which shows up as occasional mojibake in exactly the
conditions that are hardest to reproduce.
*/
const SERVER_OUTPUT = 1;
const SERVER_SESSION_OPENED = 2;
const SERVER_SESSION_CLOSED = 3;
const SERVER_SESSION_ACTIVATED = 4;
const SERVER_SESSION_REMOVED = 5;
const SERVER_PASTE = 6;
const SERVER_FONT_SIZE = 7;
const CLIENT_INPUT = 1;
const CLIENT_ACKNOWLEDGE = 2;
const CLIENT_RESIZE = 3;
const CLIENT_FONT_SIZE_STEP = 4;
const HEADER_LENGTH = 5;
const SCROLLBACK_LINES = 5000;
/*
The size panes are created at, until the host says otherwise — which it does as soon as it has read
the stored preference, usually before the first session exists. Kept here as well so a pane opened
before that frame arrives is not created at some other size and then jumped.
*/
const DEFAULT_FONT_SIZE = 13;
let fontSize = DEFAULT_FONT_SIZE;
/*
The way out of the terminal, for someone using only a keyboard.
It has to be handled here rather than by the host: once this page's window owns Win32 focus, the
host's Avalonia window receives no key events at all, so nothing on that side could hear a shortcut.
Ctrl+Shift+F6 rather than Escape. F6 is the Windows convention for moving to the next pane, but a
bare F6 is a real terminal key that TUIs bind — as is Escape, which vim alone rules out. Ctrl+Shift
is the range terminal emulators conventionally keep for themselves and never forward to the remote,
so qualifying F6 with it keeps the convention without taking a key away from the remote shell.
*/
const RELEASE_FOCUS_MESSAGE = 'dodossh.release-focus';
const root = document.getElementById('root');
const statusBanner = document.getElementById('status');
/** @type {Map<number, {term: object, fit: object, pane: HTMLElement}>} */
const sessions = new Map();
/** @type {WebSocket | null} */
let socket = null;
function setStatus(text) {
statusBanner.textContent = text ?? '';
}
/** Builds a frame: opcode, big-endian session id, then payload. */
function frame(opcode, sessionId, payload) {
const body = payload ?? new Uint8Array(0);
const buffer = new ArrayBuffer(HEADER_LENGTH + body.length);
const view = new DataView(buffer);
view.setUint8(0, opcode);
view.setUint32(1, sessionId);
new Uint8Array(buffer, HEADER_LENGTH).set(body);
return buffer;
}
function send(opcode, sessionId, payload) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(frame(opcode, sessionId, payload));
}
}
function sendAcknowledgement(sessionId, byteCount) {
const payload = new Uint8Array(4);
new DataView(payload.buffer).setUint32(0, byteCount);
send(CLIENT_ACKNOWLEDGE, sessionId, payload);
}
function sendResize(sessionId, term, pane) {
const payload = new Uint8Array(8);
const view = new DataView(payload.buffer);
// Columns before rows, matching the SSH request. Swapping them produces a terminal that is 24
// columns by 80 rows, which reads as a rendering bug rather than a protocol one.
view.setUint16(0, term.cols);
view.setUint16(2, term.rows);
view.setUint16(4, pane.clientWidth);
view.setUint16(6, pane.clientHeight);
send(CLIENT_RESIZE, sessionId, payload);
}
/**
* Asks the host to take keyboard focus back.
*
* Optional by design: the bridge only exists under a real embedded WebView, and this page is also
* openable in a plain browser for debugging, where there is no host to ask.
*/
function releaseFocusToHost() {
window.chrome?.webview?.postMessage(RELEASE_FOCUS_MESSAGE);
}
/**
* Asks the host to move the font size, or to put it back (step 0).
*
* A request rather than a change made here: the host owns the size, because the host is what remembers
* it between launches and what draws the buttons the phone uses. The answer arrives as a
* SERVER_FONT_SIZE frame, so this route and that one end in the same place.
*/
function requestFontSizeStep(step) {
const payload = new Uint8Array(1);
new DataView(payload.buffer).setInt8(0, step);
// Session zero: the size is not a property of any one terminal.
send(CLIENT_FONT_SIZE_STEP, 0, payload);
}
/**
* Applies a size to every pane, and to panes opened after this.
*
* Refitting is not optional. The cell size has changed, so the column and row counts have too, and a
* pane left unfitted draws a grid the remote is not wrapping to. fit() sends the resize frame that
* tells the far end.
*/
function applyFontSize(size) {
fontSize = size;
for (const [sessionId, session] of sessions) {
session.term.options.fontSize = size;
resize(session, sessionId);
}
}
/**
* Swallows the shortcuts that belong to the terminal application rather than to the remote.
*
* Returning false stops xterm processing the event, which is what keeps a chord from being encoded
* and written to the pty.
*/
function handleKey(event) {
if (event.type !== 'keydown') {
return true;
}
if (event.ctrlKey && event.shiftKey && event.key === 'F6') {
releaseFocusToHost();
return false;
}
/*
Ctrl with plus, minus and zero — what every terminal emulator and every browser uses for text size,
and it has to be caught here for the reason the release-focus chord does: while a terminal has focus
the host's window receives no key events at all, so nothing on that side could hear it.
Both spellings of plus, because the key that is drawn as + on the keycap reports as '+' when Shift
is read and as '=' when it is not, and which one arrives is not something the person pressing it
should have to know. Same for minus and underscore.
*/
if (event.ctrlKey && !event.altKey) {
if (event.key === '+' || event.key === '=') {
requestFontSizeStep(1);
return false;
}
if (event.key === '-' || event.key === '_') {
requestFontSizeStep(-1);
return false;
}
if (event.key === '0') {
requestFontSizeStep(0);
return false;
}
}
return true;
}
function createSession(sessionId) {
const pane = document.createElement('div');
pane.className = 'pane';
pane.dataset.sessionId = String(sessionId);
root.appendChild(pane);
const term = new Terminal({
allowProposedApi: true,
convertEol: false,
cursorBlink: true,
fontSize,
scrollback: SCROLLBACK_LINES,
// Matches terminal.css, so the canvas and the page agree on the background. Both are the v2
// design's terminal surface — `TerminalSurface` in Palette.axaml. See the note in terminal.css.
theme: { background: '#171a26', foreground: '#d8dcea' },
});
const fit = new FitAddon.FitAddon();
term.loadAddon(fit);
term.open(pane);
// WebGL where it is available. Falling back rather than failing matters because a software
// renderer is slow but usable, whereas a blank pane is not — and remote desktops and VMs
// routinely have no usable GPU context.
try {
term.loadAddon(new WebglAddon.WebglAddon());
} catch (error) {
console.warn('WebGL renderer unavailable; falling back to canvas.', error);
}
term.attachCustomKeyEventHandler(handleKey);
term.onData((data) => {
send(CLIENT_INPUT, sessionId, new TextEncoder().encode(data));
});
term.onResize(() => sendResize(sessionId, term, pane));
const session = { term, fit, pane };
sessions.set(sessionId, session);
activate(sessionId);
resize(session, sessionId);
return session;
}
function activate(sessionId) {
for (const [id, session] of sessions) {
session.pane.dataset.active = String(id === sessionId);
}
const active = sessions.get(sessionId);
if (active) {
active.term.focus();
}
}
// Below this, a pane is not being looked at — it is minimised or dragged to nothing. Fitting anyway would
// be actively harmful rather than merely useless: the fit addon floors its proposal at 2 columns by 1 row,
// so a degenerate viewport reflows the *remote* pty to 2x1 through window-change, and the wrapped
// scrollback that produces cannot be recovered when the pane comes back. A guard rather than a fix for one
// caller, because more than one path reaches here: a minimised window, and a splitter dragged to the edge
// once splits land.
//
// It is *not* what protects the vault's lock screen, which an earlier version of this comment claimed.
// Collapsing the host's WebView hides a native child window without resizing it, so this page's viewport
// does not change, no observer fires and this function is never called — measured with a live shell, and
// confirmed by removing the guard and finding the lock cycle equally clean. See docs/platform-flags.md.
const MINIMUM_FITTABLE_PIXELS = 40;
function resize(session, sessionId) {
const pane = session.pane;
if (pane.clientWidth < MINIMUM_FITTABLE_PIXELS || pane.clientHeight < MINIMUM_FITTABLE_PIXELS) {
return;
}
// fit() throws if the pane has no layout yet, which happens on the very first frame.
try {
session.fit.fit();
sendResize(sessionId, session.term, session.pane);
} catch (error) {
console.warn('Could not fit the terminal yet.', error);
}
}
function handleFrame(buffer) {
if (buffer.byteLength < HEADER_LENGTH) {
return;
}
const view = new DataView(buffer);
const opcode = view.getUint8(0);
const sessionId = view.getUint32(1);
const payload = new Uint8Array(buffer, HEADER_LENGTH);
switch (opcode) {
case SERVER_SESSION_OPENED:
createSession(sessionId);
setStatus('');
break;
case SERVER_OUTPUT: {
const session = sessions.get(sessionId) ?? createSession(sessionId);
// The callback is the acknowledgement, and payload.length is the byte count the host
// reserved credit for. Both must be the raw byte length, not a decoded character count.
session.term.write(payload, () => sendAcknowledgement(sessionId, payload.length));
break;
}
case SERVER_SESSION_ACTIVATED: {
const session = sessions.get(sessionId);
// Ignored for a pane that does not exist. The host sends this when a tab is selected, and a tab
// whose session ended still has its pane — but one the host knows about and this page has not
// created yet cannot be shown, and inventing an empty terminal for it would be worse than waiting
// for the SessionOpened frame that is already on its way.
if (!session) {
break;
}
activate(sessionId);
// Refitted on activation, not only on resize. A hidden pane has no layout, so every resize while
// it was hidden was skipped by the guard in resize() — meaning it comes back holding whatever
// geometry it had when it was last visible, and the remote pty is still sized to match.
resize(session, sessionId);
break;
}
case SERVER_SESSION_REMOVED: {
const session = sessions.get(sessionId);
if (!session) {
break;
}
/*
The tab is gone, so the pane goes with it — and this is the only place that is true. A shell that
ended on its own keeps its pane, because the last thing the remote said is usually why it ended;
a tab the user closed has nothing left to read.
term.dispose() is what actually matters. It releases the WebGL context, and a browser hands out
about sixteen of those: without this, a day of opening and closing terminals ends with panes that
cannot get a renderer, and nothing outside this page would ever say why.
*/
session.term.dispose();
session.pane.remove();
sessions.delete(sessionId);
setStatus('');
break;
}
case SERVER_PASTE: {
const session = sessions.get(sessionId);
if (!session || payload.length < 1) {
break;
}
const execute = payload[0] !== 0;
const text = new TextDecoder().decode(payload.subarray(1));
/*
term.paste rather than term.input, and that is the whole reason this frame exists rather than
the host writing the bytes into the pump. paste() wraps the text in bracketed-paste markers
when the remote has turned that mode on — xterm tracks \e[?2004h from the output stream, which
is something only this page sees — and a shell that receives a multi-line command inside those
markers treats every newline as text. Without them it treats each one as "run this", so a
three-line snippet runs three commands the moment it is inserted.
*/
session.term.paste(text);
/*
And the Enter goes through input(), deliberately outside that wrapper. A '\r' appended to the
pasted text would be bracketed along with it and arrive at the shell as a literal carriage
return, so nothing would run — which is the failure that looks like the feature working right
up until somebody wonders why RUN does not.
*/
if (execute) {
session.term.input('\r');
}
break;
}
case SERVER_FONT_SIZE: {
if (payload.length < 1) {
break;
}
// Applied even with no sessions open, which is the common case at startup: the host sends the
// stored size as soon as this page attaches, and the first pane is then created at it rather
// than being created small and resized in front of the user.
applyFontSize(payload[0]);
break;
}
case SERVER_SESSION_CLOSED: {
const session = sessions.get(sessionId);
const reason = new TextDecoder().decode(payload);
if (session) {
// The pane and its scrollback stay. The user was probably reading the last thing the
// remote said, and that is usually why the session ended.
session.term.write(`\r\n\x1b[38;5;244m── ${reason} ──\x1b[0m\r\n`);
session.term.options.cursorBlink = false;
}
setStatus(reason);
break;
}
default:
// A newer host than this page. Ignored rather than fatal.
break;
}
}
function connect() {
const token = root.dataset.token;
const url = root.dataset.socket;
// The token travels as a subprotocol rather than a query parameter, which keeps it out of
// anything that logs URLs.
socket = new WebSocket(url, ['dodossh.terminal.v1', `token.${token}`]);
socket.binaryType = 'arraybuffer';
socket.addEventListener('open', () => setStatus(''));
socket.addEventListener('message', (event) => handleFrame(event.data));
socket.addEventListener('close', () => {
setStatus('Disconnected from DodoSSH.');
});
socket.addEventListener('error', () => {
setStatus('The terminal connection failed.');
});
}
// One observer for the whole root rather than one per pane: resizes arrive in bursts while a
// window is being dragged, and a single callback coalesces them for free.
new ResizeObserver(() => {
for (const [sessionId, session] of sessions) {
resize(session, sessionId);
}
}).observe(root);
window.addEventListener('beforeunload', () => socket?.close());
connect();