Add the Avalonia app and the xterm renderer, and fix two real bugs

The terminal works end to end. A new integration test drives a real sshd in
a container through a real PTY, the real pump, the real loopback WebSocket
with its token and origin checks, and a ClientWebSocket standing in for the
page: the login banner arrives, typed input round-trips, and `stty size`
reports the 100x30 the session asked for. The only untested link left is
xterm drawing bytes it was handed.

The WebView is de-risked on Windows, which was the plan's largest risk. Not
by assertion: with the app running there is an established TCP connection
from msedgewebview2 to the data plane port, so WebView2 launched, navigated
to the loopback page, executed terminal.js, and completed the WebSocket
handshake against the real token and origin checks. Linux remains unproven
and the package's own release notes now corroborate the concern -- Linux uses
a WPE backend, and it ships a NativeWebDialog described as useful where
embedded WebViews may be unavailable.

Two bugs found by building it, both of which would have shipped:

- ShellStream.Write buffers and needs an explicit Flush. Without one a
  keystroke is accepted, reported as written, and never reaches the remote:
  the terminal displays output perfectly and simply stops responding to
  input. SSH.NET's own WriteLine flushes, which is why the earlier spike
  never hit it. Found by isolating the pump against real SSH and reading
  BytesRead=51 -- banner and prompt through, nothing after.
- The Windows app manifest needs a supportedOS list, or Avalonia's native
  control host fails outright and the terminal never starts.

Also fixed a genuinely flaky test I happened to catch: SyncCursorTests
tampered with the *last* base64url character, whose low bits the decoder
ignores when the input length is not a multiple of three -- so a tampered
cursor sometimes decoded to identical bytes and verified. It failed roughly
one run in thirty, depending on a random key. Now tampers the penultimate
character, which is fully significant at every length; 40 consecutive runs
are clean.

xterm 6.0.0 plus the fit and webgl addons are vendored as UMD bundles rather
than built with npm, so a clean clone needs only the .NET SDK. Provenance
and licences are recorded next to them, along with the UMD global names
terminal.js depends on -- a bundle that switched to ES modules would load
without error and leave Terminal undefined.

The renderer acknowledges output from term.write's completion callback, not
on receipt. Acknowledging early would return flow-control credit for bytes
the screen has not caught up with, which is the one thing the credit window
exists to measure.

TerminalWorkspace moved into DodoSSH.Client.Terminal: it has no Avalonia
dependency, and having it there is what let the end-to-end test exist at all.

404 tests pass, zero warnings on a clean rebuild, format clean.
This commit is contained in:
2026-07-28 22:30:42 +02:00
parent eb354bcdd9
commit 5fccd53824
30 changed files with 2087 additions and 21 deletions
@@ -0,0 +1,69 @@
/*
The page is the terminal surface and nothing else. No chrome, no scrollbars of its own: the
window, tabs and splits are Avalonia's job, and duplicating any of it here would mean two
layout systems disagreeing about the same pixels.
*/
:root {
--dodo-background: #10131a;
--dodo-foreground: #d5d8de;
--dodo-muted: #7b8394;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
background: var(--dodo-background);
color: var(--dodo-foreground);
font-family: ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, monospace;
}
#root {
position: absolute;
inset: 0;
}
/*
Every session gets a pane, and all but the active one are hidden rather than destroyed. On a
dropped connection the DOM and its scrollback must survive: rebuilding the terminal would
discard everything the user was reading, which is the one thing they cannot get back.
*/
.pane {
position: absolute;
inset: 0;
display: none;
/* A little breathing room, and it keeps the WebGL canvas off the window edge. */
padding: 4px 2px 2px 6px;
}
.pane[data-active="true"] {
display: block;
}
.pane > .xterm {
height: 100%;
}
#status {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 6px 10px;
font-size: 12px;
color: var(--dodo-muted);
background: color-mix(in srgb, var(--dodo-background) 88%, white);
border-top: 1px solid color-mix(in srgb, var(--dodo-background) 70%, white);
}
/* Once a session is running the banner is noise, so it only shows when it has something to say. */
#status:empty {
display: none;
}
@@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>DodoSSH terminal</title>
<!--
A restrictive CSP. The page is served over loopback from our own process, but it hosts a
terminal — arbitrary remote output is written into it — so the cheapest insurance against a
future mistake is forbidding anything this page does not already need. No remote origins,
no inline script, no eval. 'self' covers the loopback origin the host serves from, and the
WebSocket connects to that same origin.
-->
<meta http-equiv="Content-Security-Policy"
content="default-src 'none';
script-src 'self';
style-src 'self';
font-src 'self';
img-src 'self' data:;
connect-src ws://127.0.0.1:*;
base-uri 'none';
form-action 'none'">
<link rel="stylesheet" href="/vendor/xterm.css">
<link rel="stylesheet" href="/terminal.css">
</head>
<body>
<!--
The token and socket URL are substituted by the host when it serves this file, so neither is
ever written to disk and neither appears in a URL.
-->
<div id="root" data-token="__DODOSSH_TOKEN__" data-socket="__DODOSSH_SOCKET__"></div>
<div id="status" role="status" aria-live="polite">Connecting…</div>
<script src="/vendor/xterm.js"></script>
<script src="/vendor/addon-fit.js"></script>
<script src="/vendor/addon-webgl.js"></script>
<script src="/terminal.js"></script>
</body>
</html>
@@ -0,0 +1,225 @@
'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 CLIENT_INPUT = 1;
const CLIENT_ACKNOWLEDGE = 2;
const CLIENT_RESIZE = 3;
const HEADER_LENGTH = 5;
const SCROLLBACK_LINES = 5000;
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);
}
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: 13,
scrollback: SCROLLBACK_LINES,
// Matches terminal.css, so the canvas and the page agree on the background.
theme: { background: '#10131a', foreground: '#d5d8de' },
});
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.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();
}
}
function resize(session, sessionId) {
// 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_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();
+28
View File
@@ -0,0 +1,28 @@
# Vendored third-party assets
Committed rather than fetched at build time, so a clean clone builds with the .NET SDK alone — no
node, no npm, no esbuild step. The trade-off is that upgrades are a manual re-download, which is
exactly why the provenance is written down here.
All of the following are MIT licensed, the same licence as this repository.
| File | Package | Version | Source |
| --- | --- | --- | --- |
| `xterm.js` | `@xterm/xterm` | 6.0.0 | `https://unpkg.com/@xterm/xterm@6.0.0/lib/xterm.js` |
| `xterm.css` | `@xterm/xterm` | 6.0.0 | `https://unpkg.com/@xterm/xterm@6.0.0/css/xterm.css` |
| `addon-fit.js` | `@xterm/addon-fit` | 0.11.0 | `https://unpkg.com/@xterm/addon-fit@0.11.0/lib/addon-fit.js` |
| `addon-webgl.js` | `@xterm/addon-webgl` | 0.19.0 | `https://unpkg.com/@xterm/addon-webgl@0.19.0/lib/addon-webgl.js` |
Copyright (c) 2017 The xterm.js authors. See <https://github.com/xtermjs/xterm.js> for the full
licence text.
These are the UMD builds, so they attach to `globalThis`: `Terminal`, `FitAddon.FitAddon` and
`WebglAddon.WebglAddon`. `terminal.js` depends on those exact names, so check them after any upgrade
— a bundle that switched to ES modules would load without error and leave `Terminal` undefined.
## Upgrading
1. Download the four files at the new versions and update the table above.
2. Confirm the UMD global names are unchanged.
3. Run the terminal suite, then start the app and open a session: nothing here is covered by the
.NET tests, because the renderer they exercise is a `ClientWebSocket` standing in for this page.
+2
View File
@@ -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()}(globalThis,(()=>(()=>{"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._renderService.dimensions;if(0===e.css.cell.width||0===e.css.cell.height)return;const t=0===this._terminal.options.scrollback?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),i=parseInt(r.getPropertyValue("height")),o=Math.max(0,parseInt(r.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),n=i-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),l=o-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-t;return{cols:Math.max(2,Math.floor(l/e.css.cell.width)),rows:Math.max(1,Math.floor(n/e.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=addon-fit.js.map
File diff suppressed because one or more lines are too long
+285
View File
@@ -0,0 +1,285 @@
/**
* 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-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 {
font-family: monospace;
user-select: text;
white-space: pre;
}
.xterm .xterm-accessibility-tree > div {
transform-origin: left;
width: fit-content;
}
.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;
}
/* Derived from vs/base/browser/ui/scrollbar/media/scrollbar.css */
/* xterm.js customization: Override xterm's cursor style */
.xterm .xterm-scrollable-element > .scrollbar {
cursor: default;
}
/* Arrows */
.xterm .xterm-scrollable-element > .scrollbar > .scra {
cursor: pointer;
font-size: 11px !important;
}
.xterm .xterm-scrollable-element > .visible {
opacity: 1;
/* Background rule added for IE9 - to allow clicks on dom node */
background:rgba(0,0,0,0);
transition: opacity 100ms linear;
/* In front of peek view */
z-index: 11;
}
.xterm .xterm-scrollable-element > .invisible {
opacity: 0;
pointer-events: none;
}
.xterm .xterm-scrollable-element > .invisible.fade {
transition: opacity 800ms linear;
}
/* Scrollable Content Inset Shadow */
.xterm .xterm-scrollable-element > .shadow {
position: absolute;
display: none;
}
.xterm .xterm-scrollable-element > .shadow.top {
display: block;
top: 0;
left: 3px;
height: 3px;
width: 100%;
box-shadow: var(--vscode-scrollbar-shadow, #000) 0 6px 6px -6px inset;
}
.xterm .xterm-scrollable-element > .shadow.left {
display: block;
top: 3px;
left: 0;
height: 100%;
width: 3px;
box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
}
.xterm .xterm-scrollable-element > .shadow.top-left-corner {
display: block;
top: 0;
left: 0;
height: 3px;
width: 3px;
}
.xterm .xterm-scrollable-element > .shadow.top.left {
box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
}
File diff suppressed because one or more lines are too long