Public Access
973 lines
42 KiB
JavaScript
973 lines
42 KiB
JavaScript
'use strict';
|
|
|
|
/*
|
|
The renderer half of the terminal data plane.
|
|
|
|
Three 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.
|
|
|
|
3. The socket reconnects itself, forever, with backoff — and on being looked at again, which is not
|
|
the same thing and is the half a timer cannot cover. This page's WebView is routinely killed
|
|
and reloaded by Android under memory pressure or simply for being backgrounded, so "the
|
|
socket closed" is an ordinary event here, not the end of the terminal's life — see connect().
|
|
A reloaded page starts with an empty session map, so createSession is idempotent (a session
|
|
that already has a pane is left alone) and a SESSION_OPENED frame carries a flag telling this
|
|
page whether it is a replay: nothing to do for a pane that is still here, and a short banner
|
|
for one that is not, because that pane's scrollback genuinely did not survive.
|
|
*/
|
|
|
|
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 SERVER_CLIPBOARD = 8;
|
|
const SERVER_OUTPUT_DROPPED = 9;
|
|
|
|
const CLIENT_INPUT = 1;
|
|
const CLIENT_ACKNOWLEDGE = 2;
|
|
const CLIENT_RESIZE = 3;
|
|
const CLIENT_FONT_SIZE_STEP = 4;
|
|
const CLIENT_CLIPBOARD_COPY = 5;
|
|
const CLIENT_CLIPBOARD_PASTE = 6;
|
|
|
|
const HEADER_LENGTH = 5;
|
|
const SCROLLBACK_LINES = 5000;
|
|
|
|
/*
|
|
How long to wait before trying the socket again, and how that wait grows. Starting quick matters
|
|
because the ordinary case is a page that just finished loading after its WebView came back — the
|
|
host's listener has been sitting there the whole time — and capping it matters because there is no
|
|
point spacing attempts further apart than a person notices. Forever rather than giving up, because
|
|
giving up would need a way to try again and there is none better than the one already here: the page
|
|
dies with the app.
|
|
*/
|
|
const RECONNECT_INITIAL_DELAY_MS = 1000;
|
|
const RECONNECT_MAX_DELAY_MS = 5000;
|
|
|
|
/*
|
|
How long a socket is given to finish its handshake before it is treated as a failure.
|
|
|
|
A socket that cannot connect normally says so and says it quickly — 'error' then 'close', within a
|
|
millisecond or two of a loopback refusal. The case this covers is the one that says nothing: an attempt
|
|
parked in CONNECTING with no event ever coming, which is what a WebSocket opened by a renderer that is
|
|
then suspended, or one whose handshake the host never answers, leaves this page holding.
|
|
|
|
Without the watchdog that state is terminal, and quietly so. Every retry in this file is scheduled by a
|
|
close or an error, so an attempt that produces neither schedules nothing: the banner says the view is
|
|
reconnecting for the rest of the page's life while nothing whatever is reconnecting. Five seconds is
|
|
generous against a handshake that ordinarily takes about a millisecond, and short against somebody
|
|
waiting on their terminal to come back.
|
|
*/
|
|
const HANDSHAKE_TIMEOUT_MS = 5000;
|
|
|
|
/*
|
|
Styled like the SESSION_CLOSED banner (matching \x1b[38;5;244, the same dim grey), but written by
|
|
createSession's caller rather than by createSession itself: only a *replay* landing on a pane that
|
|
does not exist yet means the page reloaded and lost it, and createSession has no way to know which
|
|
of its callers that is.
|
|
*/
|
|
const REPLAY_BANNER = '\x1b[38;5;244m── the view reconnected; earlier output stayed on the host ──\x1b[0m\r\n';
|
|
|
|
/*
|
|
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, notice: string}>} */
|
|
const sessions = new Map();
|
|
|
|
/** @type {WebSocket | null} */
|
|
let socket = null;
|
|
|
|
/** Whose pane is showing, or null before there is one — see activate(). */
|
|
let activeSessionId = null;
|
|
|
|
/*
|
|
── THE BANNER BELONGS TO ONE PANE AT A TIME ─────────────────────────────────────────────────────────
|
|
There is one #status element for the whole page, because there is one page for every terminal: the
|
|
panes are stacked in the same box and all but the active one are hidden. What goes in it comes from
|
|
two sources that are not the same size, and the difference is the whole of this.
|
|
|
|
The socket's troubles are the page's. There is a single socket behind every pane, so "the view is
|
|
reconnecting" is true of whatever is on screen and true of the panes behind it.
|
|
|
|
A session's last words are not. "The remote closed the session." is a fact about one terminal and says
|
|
nothing whatever about the others — so it is held on the session and drawn only while that session's
|
|
pane is the one showing. Written straight into the shared element, which is what this used to do, it
|
|
outlived the tab it described: switching to a live terminal left the dead one's epitaph sitting under
|
|
it, and opening or closing any other tab wiped the message whether or not it belonged to that tab.
|
|
|
|
The socket's half wins when both have something to say: a page whose socket is down is not showing
|
|
live output on any pane, which makes what became of one session the less urgent of the two.
|
|
*/
|
|
let transportStatus = statusBanner.textContent ?? '';
|
|
|
|
function renderStatus() {
|
|
const notice = activeSessionId === null ? '' : sessions.get(activeSessionId)?.notice ?? '';
|
|
|
|
statusBanner.textContent = transportStatus || notice;
|
|
}
|
|
|
|
/** Says something about the socket, which every pane shares. */
|
|
function setTransportStatus(text) {
|
|
transportStatus = text ?? '';
|
|
renderStatus();
|
|
}
|
|
|
|
/** Records what became of one session, to be drawn only while that session's pane is showing. */
|
|
function setSessionNotice(sessionId, text) {
|
|
const session = sessions.get(sessionId);
|
|
|
|
if (!session) {
|
|
return;
|
|
}
|
|
|
|
session.notice = text ?? '';
|
|
renderStatus();
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/*
|
|
── THE RIGHT-CLICK IS A COPY OR A PASTE, AND NEVER A MENU ───────────────────────────────────────────
|
|
|
|
What a terminal's right button has meant since the Windows console got QuickEdit and since PuTTY
|
|
shipped: with something selected it copies and drops the selection, with nothing selected it pastes.
|
|
No menu, no second click. This page used to answer with the WebView's own context menu — xterm stuffs
|
|
its helper textarea with the selection on contextmenu precisely so that the browser's Copy entry
|
|
works — which is correct behaviour for a web page and wrong for a terminal.
|
|
|
|
◆ THE CLIPBOARD IS THE HOST'S, IN BOTH DIRECTIONS, AND THE READING IS WHY.
|
|
|
|
navigator.clipboard.readText() is gated behind the clipboard-read permission in every Chromium, and an
|
|
embedded WebView with no PermissionRequested handler answers a permission request by putting a prompt
|
|
on the screen. Trading a context menu for a permission dialog is not this change. So the page asks and
|
|
the host answers, exactly as the font-size chords do — see requestFontSizeStep. Writing would have
|
|
worked from here on Windows, and does not go that way either: a clipboard that is the host's in one
|
|
direction and the page's in the other is two mechanisms to keep working across WebView2, WebKitGTK and
|
|
Android's WebView.
|
|
|
|
◆ CAPTURED AND STOPPED, RATHER THAN MERELY PREVENTED.
|
|
|
|
preventDefault alone would suppress the menu and still let xterm's own contextmenu handler run, which
|
|
moves the helper textarea under the pointer at z-index 1000 and leaves the selection sitting in it,
|
|
selected. Both of those exist to serve the native menu; with no menu they are an invisible textarea
|
|
floating over the grid and a DOM selection nothing will clear. Taking the event in the capture phase
|
|
and stopping it there is what keeps the gesture wholly ours.
|
|
|
|
rightClickSelectsWord is off for the same reason from the other side — see createSession.
|
|
*/
|
|
function handleRightClick(event, sessionId) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
const session = sessions.get(sessionId);
|
|
|
|
if (!session) {
|
|
return;
|
|
}
|
|
|
|
const selection = session.term.getSelection();
|
|
|
|
if (selection.length > 0) {
|
|
send(CLIENT_CLIPBOARD_COPY, sessionId, new TextEncoder().encode(selection));
|
|
|
|
/*
|
|
And the selection goes, which is the console's behaviour and is load-bearing rather than cosmetic.
|
|
It is the only acknowledgement there is that the copy happened — nothing else on screen moves — and
|
|
it is what makes the *next* right-click a paste. Left standing, a selection somebody had forgotten
|
|
about would turn every later right-click into a copy of it, and the paste would look broken.
|
|
*/
|
|
session.term.clearSelection();
|
|
} else {
|
|
send(CLIENT_CLIPBOARD_PASTE, sessionId);
|
|
}
|
|
|
|
// Right-clicking a pane is a way of arriving at it. The caret follows, for the reason the paste frame's
|
|
// own focus() call gives.
|
|
session.term.focus();
|
|
}
|
|
|
|
/**
|
|
* Builds a pane for a session, or returns the one already there.
|
|
*
|
|
* Idempotent because a replay can land on a page that never lost its pane — the socket dropped and
|
|
* came back, but this page's own process survived — and asking for a session that already has a pane
|
|
* must not build a second one on top of it, orphaning the first one's WebGL context and scrollback.
|
|
*/
|
|
function createSession(sessionId) {
|
|
const existing = sessions.get(sessionId);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
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,
|
|
|
|
/*
|
|
Off, and on macOS that is a change from the default rather than a restatement of it: xterm defaults
|
|
this to true there, so a right-click would select the word under the pointer. With the gesture
|
|
meaning copy-or-paste, a right-click that quietly makes a selection is a right-click that copies a
|
|
word instead of pasting — and only on one platform, which is the worst way for it to be wrong.
|
|
|
|
Belt and braces today, because handleRightClick takes the event in the capture phase and xterm never
|
|
sees it. It is here for the upgrade that moves xterm's handler to mousedown, which is where it
|
|
already is on the Firefox path.
|
|
*/
|
|
rightClickSelectsWord: false,
|
|
|
|
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);
|
|
|
|
/*
|
|
── CHARACTER WIDTHS FROM UNICODE 11, NOT 6 ────────────────────────────────────────────────────────
|
|
|
|
How many columns a character takes is not something the terminal and the remote can each decide for
|
|
themselves: the shell's line editor counts columns to know where the cursor is, and the terminal
|
|
counts them to know where to draw. When the two disagree, every character after the one they
|
|
disagree about lands a column away from where the remote believes it is — a prompt that redraws over
|
|
itself, a cursor one cell off, a line that wraps early.
|
|
|
|
xterm.js ships with Unicode 6's table, which predates emoji as a wide class. So 😀 was one column
|
|
here and two on any current remote (glibc, musl and every modern wcwidth follow Unicode 9 and later),
|
|
and a line with an emoji in it drifted from the first emoji on. Measured against xterm.js 6.0
|
|
headless: 😀 took one cell as shipped and two with this addon, and CJK, combining marks and every
|
|
escape sequence behaved the same either way.
|
|
|
|
Before open() so the first frame is measured with the right table. activeVersion is a proposed API,
|
|
which is what allowProposedApi above is already on for.
|
|
*/
|
|
term.loadAddon(new Unicode11Addon.Unicode11Addon());
|
|
term.unicode.activeVersion = '11';
|
|
|
|
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.
|
|
//
|
|
// ◆ THE CONTEXT-LOSS HANDLER IS THE HALF THAT WAS MISSING, AND ON A PHONE IT IS THE WHOLE THING.
|
|
//
|
|
// The addon does not recover from a lost GPU context by itself, and it does not fail loudly either:
|
|
// it stays loaded over a dead context and draws nothing at all. What that looks like from outside is
|
|
// a terminal that is connected, still accepting keystrokes, still acknowledging output — and blank.
|
|
// xterm's own guidance is to dispose the addon and let the DOM renderer take over, which is what this
|
|
// does; the addon is not reloaded afterwards, because a pane that lost the context once is on a
|
|
// surface that will do it again and thrashing between renderers is worse than being slow.
|
|
//
|
|
// Losing it is ordinary on Android and nearly unheard of on Windows, which is why this went unnoticed
|
|
// for so long. Collapsing the renderer sets the native view to GONE — see
|
|
// AndroidNativeControlHostImpl.HideWithSize — and a WebView with no surface has no GL context. The
|
|
// shell collapses it every time a tab starts connecting, every time the connect sheet opens and every
|
|
// time the app is backgrounded, so on a phone the first loss arrives within seconds of the first
|
|
// session. WebView2 hides a child HWND instead and keeps rendering throughout; see
|
|
// docs/platform-flags.md.
|
|
try {
|
|
const webgl = new WebglAddon.WebglAddon();
|
|
|
|
// Subscribed before loadAddon, because loadAddon is what activates the addon and a context that is
|
|
// already gone can be reported from inside that call.
|
|
webgl.onContextLoss(() => webgl.dispose());
|
|
|
|
term.loadAddon(webgl);
|
|
} catch (error) {
|
|
console.warn('WebGL renderer unavailable; falling back to canvas.', error);
|
|
}
|
|
|
|
term.attachCustomKeyEventHandler(handleKey);
|
|
|
|
// On the pane rather than on xterm's own element, and in the capture phase, which is what puts this
|
|
// ahead of xterm's handler on the element inside it. See handleRightClick.
|
|
pane.addEventListener('contextmenu', (event) => handleRightClick(event, sessionId), true);
|
|
|
|
term.onData((data) => {
|
|
send(CLIENT_INPUT, sessionId, new TextEncoder().encode(data));
|
|
});
|
|
|
|
term.onResize(() => sendResize(sessionId, term, pane));
|
|
|
|
const session = { term, fit, pane, notice: '' };
|
|
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);
|
|
}
|
|
|
|
// The banner follows the pane. Whatever this session has to say for itself replaces whatever the
|
|
// session that was showing had to say for its own, which is the point of holding it per session.
|
|
activeSessionId = sessionId;
|
|
renderStatus();
|
|
|
|
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 on the desktop, which an earlier version of this
|
|
// comment claimed. Collapsing WebView2 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.
|
|
//
|
|
// On the phone it *is* load-bearing, and that is the one place the two heads differ here. Android hides a
|
|
// native child by setting it GONE, and a GONE view is skipped by its parent's layout — so collapsing the
|
|
// renderer really does take this page's viewport to nothing, the observer really does fire, and without
|
|
// the guard every lock, every connect sheet and every trip to the background would reflow the remote pty
|
|
// to 2x1 and mangle the scrollback it wrapped.
|
|
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: {
|
|
// Checked before createSession, which would otherwise erase the answer by creating the pane
|
|
// this check is asking about.
|
|
const hadPaneAlready = sessions.has(sessionId);
|
|
const session = createSession(sessionId);
|
|
|
|
// Byte 1 means the host is replaying a session that existed before this socket attached — see
|
|
// TerminalWorkspace.ReplayAfterAttachAsync. A replay landing on a pane that is still here has
|
|
// nothing left to do beyond the idempotent create above; one landing on a pane that is not means
|
|
// this page reloaded and that pane's scrollback went with it, which is worth a line saying so.
|
|
if (payload.length > 0 && payload[0] === 1 && !hadPaneAlready) {
|
|
session.term.write(REPLAY_BANNER);
|
|
}
|
|
|
|
// This session's own line, and only this one's: a session that is open has nothing to say about
|
|
// how it ended. The page's own "Connecting…" is cleared by the socket opening, which happens
|
|
// before any frame can arrive.
|
|
setSessionNotice(sessionId, '');
|
|
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_OUTPUT_DROPPED: {
|
|
const session = sessions.get(sessionId) ?? createSession(sessionId);
|
|
|
|
/*
|
|
The host could not keep the whole of a flood, and this is the line saying where the gap is. It
|
|
goes in exactly here, between the bytes either side of it — the socket keeps order within a
|
|
session, so nothing has to be done to place it.
|
|
|
|
Written without an acknowledgement, deliberately. Credit describes what the remote sent and what
|
|
this page has drawn of it; these bytes came from the host itself, and acknowledging them would
|
|
hand back credit for output that was never reserved against the window in the first place. The
|
|
payload arrives already styled and already terminated by a reset — see the host's own remark for
|
|
why the reset comes first as well.
|
|
*/
|
|
session.term.write(payload);
|
|
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);
|
|
|
|
// The notice went with the session record it was held on, but the page can still be pointing at
|
|
// the pane that is now gone. Cleared rather than left dangling, so the banner stops describing a
|
|
// closed tab while the host decides which pane to show next.
|
|
if (activeSessionId === sessionId) {
|
|
activeSessionId = null;
|
|
}
|
|
|
|
renderStatus();
|
|
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.
|
|
|
|
◆ ONE LINE IS TYPED INSTEAD, and this is not an optimisation. Bracketed paste is what readline
|
|
uses to decide it has been pasted into, and bash marks the result as an active region: the
|
|
inserted command sits at the prompt in reverse video, looking selected, until the next
|
|
keystroke clears it. That is right for a paste somebody made with the clipboard and wrong for a
|
|
snippet they picked off the sidebar, which should read as though they had typed it.
|
|
|
|
The markers are only load-bearing for text carrying a newline — that is the whole of what the
|
|
paragraph above protects against — so a single-line snippet does not need them and is written
|
|
as keystrokes. Multi-line still pastes, highlight and all, because "runs three commands
|
|
unasked" is the worse of the two.
|
|
*/
|
|
if (text.includes('\n') || text.includes('\r')) {
|
|
session.term.paste(text);
|
|
} else {
|
|
session.term.input(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');
|
|
}
|
|
|
|
/*
|
|
The caret goes back where the text landed. Half of it, anyway: this reaches
|
|
document.activeElement and nothing further, so it is what makes the pane the page's own focused
|
|
element and what stops a hidden textarea from keeping the caret. The other half is Win32
|
|
focus — the sidebar row that sent this frame took it — and only the host can give that back;
|
|
see MainWindowViewModel.TerminalFocusRequested and MainWindow's own FocusTerminalWhenLaidOut.
|
|
*/
|
|
session.term.focus();
|
|
|
|
break;
|
|
}
|
|
|
|
case SERVER_CLIPBOARD: {
|
|
const session = sessions.get(sessionId);
|
|
|
|
if (!session || payload.length < 1) {
|
|
break;
|
|
}
|
|
|
|
/*
|
|
paste() unconditionally, and that is the whole difference from SERVER_PASTE above. This text came
|
|
off the system clipboard, which is the case bracketed paste is *for*: readline uses the markers to
|
|
decide it has been pasted into, and a shell that gets a multi-line command inside them treats every
|
|
newline as text rather than as "run this". The reasoning that makes a single-line *snippet* worth
|
|
typing instead — bash marking a bracketed paste as an active region, so the command sits at the
|
|
prompt looking selected — is the reasoning for a snippet reading as though it had been typed. A
|
|
clipboard paste is not pretending to be typing, and every other terminal highlights it too.
|
|
|
|
No execute flag, either: a paste inserts and stops. Pressing Enter for somebody is what the
|
|
sidebar's RUN does, and it is a thing they asked for by name.
|
|
*/
|
|
session.term.paste(new TextDecoder().decode(payload));
|
|
|
|
// The caret goes where the text landed, for the reason SERVER_PASTE's own focus() call gives — half
|
|
// of it, anyway. Here the other half is already right: the gesture that asked for this was a click
|
|
// on the pane, so the host's window never took Win32 focus away.
|
|
session.term.focus();
|
|
|
|
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) {
|
|
// No pane, so there is nothing this page can honestly hang the reason on. It used to go into
|
|
// the banner anyway, which printed one session's ending underneath whichever pane happened to
|
|
// be showing at the time.
|
|
break;
|
|
}
|
|
|
|
// 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;
|
|
|
|
setSessionNotice(sessionId, reason);
|
|
break;
|
|
}
|
|
|
|
default:
|
|
// A newer host than this page. Ignored rather than fatal.
|
|
break;
|
|
}
|
|
}
|
|
|
|
/*
|
|
── THE SOCKET COMES BACK BY ITSELF, INCLUDING FOR A PAGE NOBODY HAS LOOKED AT IN AN HOUR ────────────
|
|
|
|
Retrying on a timer is the easy half and was the whole of this. The three rules below are what make the
|
|
retry actually reach a page that has been sitting collapsed — each of them a way this page was found
|
|
showing "Reconnecting the terminal view…" over a socket that nothing was reconnecting.
|
|
|
|
1. ONLY THE CURRENT ATTEMPT'S EVENTS COUNT. connect() abandons whatever socket was attached before it,
|
|
and an abandoned socket still reports its end: the host aborts it the moment the newcomer upgrades,
|
|
which is TerminalDataPlane.UpgradeAsync's takeover doing exactly what it is meant to. Counting that
|
|
close as a fresh failure schedules a retry against a socket that has just succeeded, and the
|
|
takeover then aborts *that* one, whose close schedules the next — a loop with no fixed point, in
|
|
which the terminal reconnects every second or so forever and the banner is up for most of it. Every
|
|
handler below asks whether it is still the page's socket before it does anything.
|
|
|
|
2. AN ATTEMPT THAT NEVER FINISHES IS A FAILURE TOO. See HANDSHAKE_TIMEOUT_MS.
|
|
|
|
3. COMING BACK IS A REASON TO TRY, NOT ONLY THE CLOCK. The retry is a setTimeout chain, and a chain is
|
|
precisely what a WebView is entitled to stop running. Chromium throttles timers in a page nobody is
|
|
looking at — down to once a minute once it has been hidden five minutes — and a renderer that is
|
|
frozen, or reclaimed and not yet reloaded, runs none of them at all. So the socket drops while nobody
|
|
is watching, the banner goes up, the retry is scheduled, and the retry is then the one thing not
|
|
running: coming back shows a terminal that says it is reconnecting and, for as long as that lasts,
|
|
is not.
|
|
|
|
The phone is where this is easiest to reach, because leaving the terminal for another screen
|
|
collapses its WebView to GONE — see createSession's note on the GPU context — and a WebView with no
|
|
surface is a page the platform may treat as hidden. Which of the two mechanisms actually bit is not
|
|
established here, and does not need to be: both end with a timer that will not run, and the fix is
|
|
not to guess at either but to stop depending on the timer alone.
|
|
|
|
Hence the wake-ups at the bottom of this file. What they add is not a faster retry; it is a retry
|
|
driven by the one thing that cannot be throttled, which is the user arriving.
|
|
*/
|
|
|
|
/** @type {number | null} */
|
|
let reconnectTimer = null;
|
|
|
|
/** @type {number | null} */
|
|
let handshakeTimer = null;
|
|
|
|
let reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
|
|
|
|
/**
|
|
* Tries the socket again after a wait, unless a try is already pending.
|
|
*
|
|
* The guard is what keeps 'close' and 'error' from stacking two timers for one failure — a socket
|
|
* that fails to open typically fires both, and each would otherwise schedule its own reconnect.
|
|
*/
|
|
function scheduleReconnect() {
|
|
if (reconnectTimer !== null) {
|
|
return;
|
|
}
|
|
|
|
setTransportStatus('Reconnecting the terminal view…');
|
|
|
|
reconnectTimer = setTimeout(() => {
|
|
reconnectTimer = null;
|
|
connect();
|
|
}, reconnectDelay);
|
|
|
|
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_DELAY_MS);
|
|
}
|
|
|
|
/**
|
|
* Tries the socket again now, if it is down.
|
|
*
|
|
* The entry point for "somebody is looking at this page again", and it has to be safe to call as often
|
|
* as that happens — which on the desktop is every time the window is clicked. A socket that is up makes
|
|
* this nothing at all.
|
|
*
|
|
* An attempt still in CONNECTING is left alone rather than restarted: it may be about to succeed, and
|
|
* the one that is not is already the handshake watchdog's to give up on.
|
|
*/
|
|
function reconnectIfDown() {
|
|
if (socket !== null
|
|
&& (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
|
return;
|
|
}
|
|
|
|
// Whatever the timer was going to do, this is doing now. Left pending it would land on top of the
|
|
// socket this call is about to open, and the takeover that followed is the loop rule 1 describes.
|
|
if (reconnectTimer !== null) {
|
|
clearTimeout(reconnectTimer);
|
|
reconnectTimer = null;
|
|
}
|
|
|
|
// Back to the quick attempt. The wait grew to space out retries during an outage nobody was watching,
|
|
// and being called at all means somebody is watching now.
|
|
reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
|
|
|
|
connect();
|
|
}
|
|
|
|
function connect() {
|
|
const token = root.dataset.token;
|
|
const url = root.dataset.socket;
|
|
|
|
/*
|
|
Whatever was attached is abandoned here, and closed rather than dropped: the host takes the socket
|
|
over regardless, but one left open is a connection it has to abort and an event this page then has to
|
|
ignore. Cleared out of `socket` before the close, so that every handler — including that close,
|
|
whenever it lands — can already tell the attempt is no longer the page's.
|
|
*/
|
|
const abandoned = socket;
|
|
socket = null;
|
|
abandoned?.close();
|
|
|
|
// The token travels as a subprotocol rather than a query parameter, which keeps it out of
|
|
// anything that logs URLs.
|
|
const pending = new WebSocket(url, ['dodossh.terminal.v1', `token.${token}`]);
|
|
pending.binaryType = 'arraybuffer';
|
|
socket = pending;
|
|
|
|
/** Whether this attempt is still the page's, rather than one a later connect() has replaced. */
|
|
const isCurrent = () => socket === pending;
|
|
|
|
const forgetHandshakeTimer = () => {
|
|
if (handshakeTimer !== null) {
|
|
clearTimeout(handshakeTimer);
|
|
handshakeTimer = null;
|
|
}
|
|
};
|
|
|
|
forgetHandshakeTimer();
|
|
|
|
handshakeTimer = setTimeout(() => {
|
|
handshakeTimer = null;
|
|
|
|
// Still CONNECTING with nothing on its way. Only the retry is arranged here; abandoning the attempt
|
|
// is left to the connect() that retry runs, which is the one place a socket is replaced.
|
|
if (isCurrent() && pending.readyState === WebSocket.CONNECTING) {
|
|
scheduleReconnect();
|
|
}
|
|
}, HANDSHAKE_TIMEOUT_MS);
|
|
|
|
pending.addEventListener('open', () => {
|
|
// A replaced attempt cannot reach here — connect() closes what it abandons, and a socket closed
|
|
// while connecting never opens — so this is a guard against the ordering rather than a live case.
|
|
if (!isCurrent()) {
|
|
return;
|
|
}
|
|
|
|
forgetHandshakeTimer();
|
|
setTransportStatus('');
|
|
|
|
// Back to the quick attempt for whatever the next failure turns out to be. Kept slow between
|
|
// attempts within one outage, reset once the outage is actually over.
|
|
reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
|
|
});
|
|
|
|
pending.addEventListener('message', (event) => {
|
|
if (isCurrent()) {
|
|
handleFrame(event.data);
|
|
}
|
|
});
|
|
|
|
// Both close and error retry. They are not the same event on every failure — a socket that never
|
|
// opens can fire only 'error', one that opens and later drops fires only 'close' — and the host
|
|
// side of this same problem (TerminalDataPlane.UpgradeAsync's takeover) is exactly why retrying is
|
|
// safe: whichever attempt eventually reaches the host, a fresh valid upgrade always wins the socket.
|
|
const retry = () => {
|
|
if (isCurrent()) {
|
|
forgetHandshakeTimer();
|
|
scheduleReconnect();
|
|
}
|
|
};
|
|
|
|
pending.addEventListener('close', retry);
|
|
pending.addEventListener('error', retry);
|
|
}
|
|
|
|
// 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());
|
|
|
|
/*
|
|
The ways this page finds out somebody is looking at it again — rule 3 at the top of the transport
|
|
section. All three end in the same check, and that check is what makes them safe to be as noisy as they
|
|
are: with a healthy socket every one of them does nothing.
|
|
|
|
'visibilitychange' is the phone's. A WebView collapsed to GONE is a hidden page, so coming back to the
|
|
terminal screen is the event that says so, and it is the same event Chromium lifts its own throttling
|
|
on — this page simply does not wait to be asked twice.
|
|
|
|
'focus' is the desktop's, where the page is *not* marked hidden while the WebView is collapsed
|
|
(measured; see docs/platform-flags.md) but a window left in the background for hours has had its timers
|
|
throttled all the same. It fires when the terminal takes the keyboard back, which is the same gesture.
|
|
|
|
'online' is neither head's ordinary case, because the socket is loopback and has nothing to do with the
|
|
network — but the event does follow a machine coming back from sleep, and a page with a dead socket has
|
|
no reason to ignore any hint that the world has moved.
|
|
*/
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (!document.hidden) {
|
|
reconnectIfDown();
|
|
}
|
|
});
|
|
|
|
window.addEventListener('focus', reconnectIfDown);
|
|
window.addEventListener('online', reconnectIfDown);
|
|
|
|
/*
|
|
And nowhere on this page has a context menu, which the panes' own handler nearly finishes on its own: they
|
|
are positioned at inset 0, so every right-click over a terminal lands on one and is stopped there before it
|
|
reaches here.
|
|
|
|
What is left is the status banner, which is drawn over the bottom of the pane while it has something to say
|
|
— so a right-click on "Reconnecting the terminal view…" would have been the one place in a terminal window
|
|
that still opened a browser menu. Prevented and not stopped: there is nothing below this to stop it from.
|
|
*/
|
|
document.addEventListener('contextmenu', (event) => event.preventDefault());
|
|
|
|
connect();
|