mirror of
https://github.com/hanzoai/cli.git
synced 2026-10-30 15:00:27 +00:00
217 lines
8.5 KiB
JavaScript
217 lines
8.5 KiB
JavaScript
/* The Hanzo terminal client.
|
|
*
|
|
* ttyd's own page is a fine terminal and the wrong surface for this product: it
|
|
* cannot be themed to match the console that frames it, it has no touch
|
|
* affordances, and — because it is somebody else's document inside a
|
|
* cross-origin frame — it swallows every keystroke the workspace would like to
|
|
* hear. Owning the page answers all three at once. ttyd stays exactly as it is;
|
|
* only `--index` changes.
|
|
*
|
|
* THE PROTOCOL is ttyd's and is small. The socket carries a one-byte opcode:
|
|
* send '0'+utf8 input '1'+json{columns,rows} resize
|
|
* recv '0'+bytes output '1'+title '2'+json prefs
|
|
* The first frame the client sends is a JSON auth object; ttyd waits for it
|
|
* before spawning the command, which is why the shell does not exist until a
|
|
* browser actually connects.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
var TERM = new window.Terminal({
|
|
// Monochrome, matching hanzo.app rather than xterm's defaults. The terminal
|
|
// is the deepest surface in the product, so it is true black.
|
|
theme: {
|
|
background: '#000000',
|
|
foreground: '#e6e6e6',
|
|
cursor: '#e6e6e6',
|
|
cursorAccent: '#000000',
|
|
selectionBackground: 'rgba(255,255,255,0.18)',
|
|
black: '#000000', brightBlack: '#5c5c5c',
|
|
red: '#f87171', brightRed: '#fca5a5',
|
|
green: '#34d399', brightGreen: '#6ee7b7',
|
|
yellow: '#fbbf24', brightYellow: '#fcd34d',
|
|
blue: '#60a5fa', brightBlue: '#93c5fd',
|
|
magenta: '#c084fc', brightMagenta: '#d8b4fe',
|
|
cyan: '#22d3ee', brightCyan: '#67e8f9',
|
|
white: '#e6e6e6', brightWhite: '#ffffff',
|
|
},
|
|
fontFamily:
|
|
'"Geist Mono", "Zen Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
|
fontSize: 13,
|
|
lineHeight: 1.2,
|
|
cursorBlink: true,
|
|
cursorStyle: 'bar',
|
|
// A terminal you cannot scroll back through is a log you cannot read.
|
|
scrollback: 10000,
|
|
allowProposedApi: true,
|
|
macOptionIsMeta: true,
|
|
});
|
|
|
|
var FIT = new window.FitAddon.FitAddon();
|
|
TERM.loadAddon(FIT);
|
|
TERM.open(document.getElementById('term'));
|
|
|
|
// Tell whoever framed us that a REAL terminal is here.
|
|
//
|
|
// A parent cannot find this out for itself. Everything it can reach across the
|
|
// origin lies to it identically: a frame refused by `frame-ancestors` and a
|
|
// frame that loaded perfectly BOTH throw on contentDocument, because the
|
|
// browser swaps in its own error document for the refusal. So the workspace
|
|
// used to read every blocked terminal as healthy and paint an opaque black
|
|
// rectangle over the only way out of it.
|
|
//
|
|
// This is the one honest bit: it is sent by the page that owns the terminal,
|
|
// after xterm has actually opened. Absence means the terminal is not there —
|
|
// gate, dead tunnel, offline machine, all the same — and absence is what the
|
|
// workspace acts on. `'*'` because every machine publishes on its own tunnel
|
|
// host and this claims nothing worth targeting.
|
|
try {
|
|
if (window.parent !== window) {
|
|
window.parent.postMessage({ source: 'hanzo-term', ready: true }, '*');
|
|
}
|
|
} catch (e) {
|
|
/* no parent, or one that will not be spoken to: the terminal still works */
|
|
}
|
|
|
|
// ---- the socket ---------------------------------------------------------
|
|
var proto = location.protocol === 'https:' ? 'wss://' : 'ws://';
|
|
var url = proto + location.host + location.pathname.replace(/\/$/, '') + '/ws' + location.search;
|
|
var ws = new WebSocket(url, ['tty']);
|
|
ws.binaryType = 'arraybuffer';
|
|
|
|
var enc = new TextEncoder();
|
|
var dec = new TextDecoder();
|
|
|
|
function send(op, payload) {
|
|
if (ws.readyState !== WebSocket.OPEN) return;
|
|
var body = enc.encode(payload);
|
|
var frame = new Uint8Array(body.length + 1);
|
|
frame[0] = op.charCodeAt(0);
|
|
frame.set(body, 1);
|
|
ws.send(frame);
|
|
}
|
|
|
|
function fit() {
|
|
try {
|
|
FIT.fit();
|
|
send('1', JSON.stringify({ columns: TERM.cols, rows: TERM.rows }));
|
|
} catch (e) {
|
|
/* a zero-size container during layout; the next resize settles it */
|
|
}
|
|
}
|
|
|
|
ws.onopen = function () {
|
|
// ttyd spawns the command only after this frame.
|
|
ws.send(JSON.stringify({ AuthToken: '', columns: TERM.cols, rows: TERM.rows }));
|
|
fit();
|
|
TERM.focus();
|
|
};
|
|
|
|
ws.onmessage = function (e) {
|
|
var data = typeof e.data === 'string' ? enc.encode(e.data) : new Uint8Array(e.data);
|
|
if (!data.length) return;
|
|
var op = String.fromCharCode(data[0]);
|
|
var rest = data.subarray(1);
|
|
if (op === '0') TERM.write(rest);
|
|
else if (op === '1') document.title = dec.decode(rest);
|
|
};
|
|
|
|
ws.onclose = function () {
|
|
TERM.write('\r\n\x1b[2m— disconnected —\x1b[0m\r\n');
|
|
};
|
|
|
|
TERM.onData(function (d) { send('0', d); });
|
|
TERM.onBinary(function (d) { send('0', d); });
|
|
|
|
window.addEventListener('resize', fit);
|
|
// The pane is resized by the parent dragging a divider, which fires no window
|
|
// resize inside the frame. The container is what actually changes.
|
|
if (window.ResizeObserver) new ResizeObserver(fit).observe(document.getElementById('term'));
|
|
|
|
// ---- talking to the workspace that frames us ----------------------------
|
|
//
|
|
// A cross-origin frame receives every key and gives the parent none, so the
|
|
// workspace's shortcuts can only exist if this page forwards them. It forwards
|
|
// the ⌃⌥ set and NOTHING else: Ctrl belongs to the shell, Alt is readline's
|
|
// Meta, ⌘ is the browser's. `⌃⌥` is bound by nothing in bash, zsh, vim or tmux.
|
|
//
|
|
// AltGr reports as ctrl+alt on European layouts, so it is excluded explicitly —
|
|
// otherwise AltGr+E (€) would read as a workspace chord.
|
|
var PARENT = '*';
|
|
function post(msg) {
|
|
if (window.parent !== window) window.parent.postMessage(Object.assign({ v: 1 }, msg), PARENT);
|
|
}
|
|
|
|
TERM.attachCustomKeyEventHandler(function (e) {
|
|
if (e.type !== 'keydown') return true;
|
|
if (!e.ctrlKey || !e.altKey) return true;
|
|
if (e.getModifierState && e.getModifierState('AltGraph')) return true;
|
|
post({ t: 'chord', key: e.key, shift: e.shiftKey });
|
|
e.preventDefault();
|
|
return false; // consumed by the workspace, never sent to the pty
|
|
});
|
|
|
|
document.addEventListener('pointerdown', function () { post({ t: 'focus' }); }, true);
|
|
window.addEventListener('message', function (e) {
|
|
var d = e.data;
|
|
if (!d || d.v !== 1) return;
|
|
if (d.t === 'input' && typeof d.data === 'string') { send('0', d.data); TERM.focus(); }
|
|
if (d.t === 'focus') TERM.focus();
|
|
if (d.t === 'size' && typeof d.px === 'number') { TERM.options.fontSize = d.px; fit(); }
|
|
});
|
|
post({ t: 'hello' });
|
|
|
|
// ---- the key row --------------------------------------------------------
|
|
//
|
|
// A soft keyboard has no Esc, no Ctrl, no Tab and no arrows, so on a touch
|
|
// device a terminal without this row is a terminal you can read and not use.
|
|
// Every serious iOS client ships one; this is that row.
|
|
var TOUCH = window.matchMedia('(pointer: coarse)').matches;
|
|
if (TOUCH) {
|
|
var KEYS = [
|
|
['esc', '\x1b'], ['tab', '\t'], ['ctrl', null], ['/', '/'], ['|', '|'],
|
|
['-', '-'], ['~', '~'], ['↑', '\x1b[A'], ['↓', '\x1b[B'],
|
|
['←', '\x1b[D'], ['→', '\x1b[C'], ['^C', '\x03'], ['^D', '\x04'], ['^R', '\x12'],
|
|
];
|
|
var armed = false;
|
|
var row = document.getElementById('keys');
|
|
row.hidden = false;
|
|
KEYS.forEach(function (k) {
|
|
var b = document.createElement('button');
|
|
b.textContent = k[0];
|
|
b.addEventListener('pointerdown', function (ev) {
|
|
ev.preventDefault();
|
|
if (k[1] === null) {
|
|
// Sticky Ctrl: tap, then tap a letter. The only way to reach ^C on a
|
|
// soft keyboard, and how Blink and Termius both do it.
|
|
armed = !armed;
|
|
b.dataset.armed = armed ? '1' : '';
|
|
return;
|
|
}
|
|
if (armed && k[1].length === 1) {
|
|
var c = k[1].toUpperCase().charCodeAt(0);
|
|
if (c >= 64 && c < 128) send('0', String.fromCharCode(c & 31));
|
|
armed = false;
|
|
delete row.querySelector('[data-armed]')?.dataset.armed;
|
|
} else {
|
|
send('0', k[1]);
|
|
}
|
|
TERM.focus();
|
|
});
|
|
row.appendChild(b);
|
|
});
|
|
// Keep the row above the software keyboard rather than under it.
|
|
if (window.visualViewport) {
|
|
var vv = window.visualViewport;
|
|
var place = function () {
|
|
var inset = window.innerHeight - (vv.height + vv.offsetTop);
|
|
row.style.transform = 'translateY(' + -Math.max(0, inset) + 'px)';
|
|
fit();
|
|
};
|
|
vv.addEventListener('resize', place);
|
|
vv.addEventListener('scroll', place);
|
|
place();
|
|
}
|
|
}
|
|
})();
|