summaryrefslogtreecommitdiffstats
path: root/html/src/components/terminal/xterm
diff options
context:
space:
mode:
Diffstat (limited to 'html/src/components/terminal/xterm')
-rw-r--r--html/src/components/terminal/xterm/addons/overlay.ts73
-rw-r--r--html/src/components/terminal/xterm/addons/zmodem.ts180
-rw-r--r--html/src/components/terminal/xterm/index.ts461
3 files changed, 714 insertions, 0 deletions
diff --git a/html/src/components/terminal/xterm/addons/overlay.ts b/html/src/components/terminal/xterm/addons/overlay.ts
new file mode 100644
index 0000000..6fa5a92
--- /dev/null
+++ b/html/src/components/terminal/xterm/addons/overlay.ts
@@ -0,0 +1,73 @@
+// ported from hterm.Terminal.prototype.showOverlay
+// https://chromium.googlesource.com/apps/libapps/+/master/hterm/js/hterm_terminal.js
+import { bind } from 'decko';
+import { ITerminalAddon, Terminal } from 'xterm';
+
+export class OverlayAddon implements ITerminalAddon {
+ private terminal: Terminal;
+ private overlayNode: HTMLElement;
+ private overlayTimeout?: number;
+
+ constructor() {
+ this.overlayNode = document.createElement('div');
+ this.overlayNode.style.cssText = `border-radius: 15px;
+font-size: xx-large;
+opacity: 0.75;
+padding: 0.2em 0.5em 0.2em 0.5em;
+position: absolute;
+-webkit-user-select: none;
+-webkit-transition: opacity 180ms ease-in;
+-moz-user-select: none;
+-moz-transition: opacity 180ms ease-in;`;
+
+ this.overlayNode.addEventListener(
+ 'mousedown',
+ e => {
+ e.preventDefault();
+ e.stopPropagation();
+ },
+ true
+ );
+ }
+
+ activate(terminal: Terminal): void {
+ this.terminal = terminal;
+ }
+
+ dispose(): void {}
+
+ @bind
+ showOverlay(msg: string, timeout?: number): void {
+ const { terminal, overlayNode } = this;
+ if (!terminal.element) return;
+
+ overlayNode.style.color = '#101010';
+ overlayNode.style.backgroundColor = '#f0f0f0';
+ overlayNode.textContent = msg;
+ overlayNode.style.opacity = '0.75';
+
+ if (!overlayNode.parentNode) {
+ terminal.element.appendChild(overlayNode);
+ }
+
+ const divSize = terminal.element.getBoundingClientRect();
+ const overlaySize = overlayNode.getBoundingClientRect();
+
+ overlayNode.style.top = (divSize.height - overlaySize.height) / 2 + 'px';
+ overlayNode.style.left = (divSize.width - overlaySize.width) / 2 + 'px';
+
+ if (this.overlayTimeout) clearTimeout(this.overlayTimeout);
+ if (!timeout) return;
+
+ this.overlayTimeout = window.setTimeout(() => {
+ overlayNode.style.opacity = '0';
+ this.overlayTimeout = window.setTimeout(() => {
+ if (overlayNode.parentNode) {
+ overlayNode.parentNode.removeChild(overlayNode);
+ }
+ this.overlayTimeout = undefined;
+ overlayNode.style.opacity = '0.75';
+ }, 200);
+ }, timeout || 1500);
+ }
+}
diff --git a/html/src/components/terminal/xterm/addons/zmodem.ts b/html/src/components/terminal/xterm/addons/zmodem.ts
new file mode 100644
index 0000000..8571f68
--- /dev/null
+++ b/html/src/components/terminal/xterm/addons/zmodem.ts
@@ -0,0 +1,180 @@
+import { bind } from 'decko';
+import { saveAs } from 'file-saver';
+import { IDisposable, ITerminalAddon, Terminal } from 'xterm';
+import * as Zmodem from 'zmodem.js/src/zmodem_browser';
+import { TrzszFilter } from 'trzsz';
+
+export interface ZmodeOptions {
+ zmodem: boolean;
+ trzsz: boolean;
+ windows: boolean;
+ onSend: () => void;
+ sender: (data: string | Uint8Array) => void;
+ writer: (data: string | Uint8Array) => void;
+}
+
+export class ZmodemAddon implements ITerminalAddon {
+ private disposables: IDisposable[] = [];
+ private terminal: Terminal;
+ private sentry: Zmodem.Sentry;
+ private session: Zmodem.Session;
+ private denier: () => void;
+ private trzszFilter: TrzszFilter;
+
+ constructor(private options: ZmodeOptions) {}
+
+ activate(terminal: Terminal) {
+ this.terminal = terminal;
+ if (this.options.zmodem) this.zmodemInit();
+ if (this.options.trzsz) this.trzszInit();
+ }
+
+ dispose() {
+ for (const d of this.disposables) {
+ d.dispose();
+ }
+ this.disposables.length = 0;
+ }
+
+ consume(data: ArrayBuffer) {
+ try {
+ if (this.options.trzsz) {
+ this.trzszFilter.processServerOutput(data);
+ } else {
+ this.sentry.consume(data);
+ }
+ } catch (e) {
+ console.error('[ttyd] zmodem consume: ', e);
+ this.reset();
+ }
+ }
+
+ @bind
+ private reset() {
+ this.terminal.options.disableStdin = false;
+ this.terminal.focus();
+ }
+
+ private addDisposableListener(target: EventTarget, type: string, listener: EventListener) {
+ target.addEventListener(type, listener);
+ this.disposables.push({ dispose: () => target.removeEventListener(type, listener) });
+ }
+
+ @bind
+ private trzszInit() {
+ const { terminal } = this;
+ const { sender, writer, zmodem } = this.options;
+ this.trzszFilter = new TrzszFilter({
+ writeToTerminal: data => {
+ if (!this.trzszFilter.isTransferringFiles() && zmodem) {
+ this.sentry.consume(data);
+ } else {
+ writer(typeof data === 'string' ? data : new Uint8Array(data as ArrayBuffer));
+ }
+ },
+ sendToServer: data => sender(data),
+ terminalColumns: terminal.cols,
+ isWindowsShell: this.options.windows,
+ });
+ const element = terminal.element as EventTarget;
+ this.addDisposableListener(element, 'dragover', event => event.preventDefault());
+ this.addDisposableListener(element, 'drop', event => {
+ event.preventDefault();
+ this.trzszFilter
+ .uploadFiles((event as DragEvent).dataTransfer?.items as DataTransferItemList)
+ .then(() => console.log('[ttyd] upload success'))
+ .catch(err => console.log('[ttyd] upload failed: ' + err));
+ });
+ this.disposables.push(terminal.onResize(size => this.trzszFilter.setTerminalColumns(size.cols)));
+ }
+
+ @bind
+ private zmodemInit() {
+ const { sender, writer } = this.options;
+ const { terminal, reset, zmodemDetect } = this;
+ this.session = null;
+ this.sentry = new Zmodem.Sentry({
+ to_terminal: octets => writer(new Uint8Array(octets)),
+ sender: octets => sender(new Uint8Array(octets)),
+ on_retract: () => reset(),
+ on_detect: detection => zmodemDetect(detection),
+ });
+ this.disposables.push(
+ terminal.onKey(e => {
+ const event = e.domEvent;
+ if (event.ctrlKey && event.key === 'c') {
+ if (this.denier) this.denier();
+ }
+ })
+ );
+ }
+
+ @bind
+ private zmodemDetect(detection: Zmodem.Detection): void {
+ const { terminal, receiveFile } = this;
+ terminal.options.disableStdin = true;
+
+ this.denier = () => detection.deny();
+ this.session = detection.confirm();
+ this.session.on('session_end', () => this.reset());
+
+ if (this.session.type === 'send') {
+ this.options.onSend();
+ } else {
+ receiveFile();
+ }
+ }
+
+ @bind
+ public sendFile(files: FileList) {
+ const { session, writeProgress } = this;
+ Zmodem.Browser.send_files(session, files, {
+ on_progress: (_, offer) => writeProgress(offer),
+ })
+ .then(() => session.close())
+ .catch(() => this.reset());
+ }
+
+ @bind
+ private receiveFile() {
+ const { session, writeProgress } = this;
+
+ session.on('offer', offer => {
+ offer.on('input', () => writeProgress(offer));
+ offer
+ .accept()
+ .then(payloads => {
+ const blob = new Blob(payloads, { type: 'application/octet-stream' });
+ saveAs(blob, offer.get_details().name);
+ })
+ .catch(() => this.reset());
+ });
+
+ session.start();
+ }
+
+ @bind
+ private writeProgress(offer: Zmodem.Offer) {
+ const { bytesHuman } = this;
+ const file = offer.get_details();
+ const name = file.name;
+ const size = file.size;
+ const offset = offer.get_offset();
+ const percent = ((100 * offset) / size).toFixed(2);
+
+ this.options.writer(`${name} ${percent}% ${bytesHuman(offset, 2)}/${bytesHuman(size, 2)}\r`);
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ private bytesHuman(bytes: any, precision: number): string {
+ if (!/^([-+])?|(\.\d+)(\d+(\.\d+)?|(\d+\.)|Infinity)$/.test(bytes)) {
+ return '-';
+ }
+ if (bytes === 0) return '0';
+ if (typeof precision === 'undefined') precision = 1;
+ const units = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
+ const num = Math.floor(Math.log(bytes) / Math.log(1024));
+ const value = (bytes / Math.pow(1024, Math.floor(num))).toFixed(precision);
+ return `${value} ${units[num]}`;
+ }
+}
diff --git a/html/src/components/terminal/xterm/index.ts b/html/src/components/terminal/xterm/index.ts
new file mode 100644
index 0000000..06d97ee
--- /dev/null
+++ b/html/src/components/terminal/xterm/index.ts
@@ -0,0 +1,461 @@
+import { bind } from 'decko';
+import { IDisposable, ITerminalOptions, Terminal } from 'xterm';
+import { CanvasAddon } from 'xterm-addon-canvas';
+import { WebglAddon } from 'xterm-addon-webgl';
+import { FitAddon } from 'xterm-addon-fit';
+import { WebLinksAddon } from 'xterm-addon-web-links';
+import { ImageAddon } from 'xterm-addon-image';
+import { OverlayAddon } from './addons/overlay';
+import { ZmodemAddon } from './addons/zmodem';
+
+import 'xterm/css/xterm.css';
+
+interface TtydTerminal extends Terminal {
+ fit(): void;
+}
+
+declare global {
+ interface Window {
+ term: TtydTerminal;
+ }
+}
+
+const enum Command {
+ // server side
+ OUTPUT = '0',
+ SET_WINDOW_TITLE = '1',
+ SET_PREFERENCES = '2',
+
+ // client side
+ INPUT = '0',
+ RESIZE_TERMINAL = '1',
+ PAUSE = '2',
+ RESUME = '3',
+}
+type Preferences = ITerminalOptions & ClientOptions;
+
+export type RendererType = 'dom' | 'canvas' | 'webgl';
+
+export interface ClientOptions {
+ rendererType: RendererType;
+ disableLeaveAlert: boolean;
+ disableResizeOverlay: boolean;
+ enableZmodem: boolean;
+ enableTrzsz: boolean;
+ enableSixel: boolean;
+ titleFixed?: string;
+ isWindows: boolean;
+}
+
+export interface FlowControl {
+ limit: number;
+ highWater: number;
+ lowWater: number;
+}
+
+export interface XtermOptions {
+ wsUrl: string;
+ tokenUrl: string;
+ flowControl: FlowControl;
+ clientOptions: ClientOptions;
+ termOptions: ITerminalOptions;
+}
+
+function toDisposable(f: () => void): IDisposable {
+ return { dispose: f };
+}
+
+function addEventListener(target: EventTarget, type: string, listener: EventListener): IDisposable {
+ target.addEventListener(type, listener);
+ return toDisposable(() => target.removeEventListener(type, listener));
+}
+
+export class Xterm {
+ private disposables: IDisposable[] = [];
+ private textEncoder = new TextEncoder();
+ private textDecoder = new TextDecoder();
+ private written = 0;
+ private pending = 0;
+
+ private terminal: Terminal;
+ private fitAddon = new FitAddon();
+ private overlayAddon = new OverlayAddon();
+ private webglAddon?: WebglAddon;
+ private canvasAddon?: CanvasAddon;
+ private zmodemAddon?: ZmodemAddon;
+
+ private socket?: WebSocket;
+ private token: string;
+ private opened = false;
+ private title?: string;
+ private titleFixed?: string;
+ private resizeOverlay = true;
+ private reconnect = true;
+ private doReconnect = true;
+
+ private writeFunc = (data: ArrayBuffer) => this.writeData(new Uint8Array(data));
+
+ constructor(
+ private options: XtermOptions,
+ private sendCb: () => void
+ ) {}
+
+ dispose() {
+ for (const d of this.disposables) {
+ d.dispose();
+ }
+ this.disposables.length = 0;
+ }
+
+ @bind
+ private register<T extends IDisposable>(d: T): T {
+ this.disposables.push(d);
+ return d;
+ }
+
+ @bind
+ public sendFile(files: FileList) {
+ this.zmodemAddon?.sendFile(files);
+ }
+
+ @bind
+ public async refreshToken() {
+ try {
+ const resp = await fetch(this.options.tokenUrl);
+ if (resp.ok) {
+ const json = await resp.json();
+ this.token = json.token;
+ }
+ } catch (e) {
+ console.error(`[ttyd] fetch ${this.options.tokenUrl}: `, e);
+ }
+ }
+
+ @bind
+ private onWindowUnload(event: BeforeUnloadEvent) {
+ event.preventDefault();
+ if (this.socket?.readyState === WebSocket.OPEN) {
+ const message = 'Close terminal? this will also terminate the command.';
+ event.returnValue = message;
+ return message;
+ }
+ return undefined;
+ }
+
+ @bind
+ public open(parent: HTMLElement) {
+ this.terminal = new Terminal(this.options.termOptions);
+ const { terminal, fitAddon, overlayAddon } = this;
+ window.term = terminal as TtydTerminal;
+ window.term.fit = () => {
+ this.fitAddon.fit();
+ };
+
+ terminal.loadAddon(fitAddon);
+ terminal.loadAddon(overlayAddon);
+ terminal.loadAddon(new WebLinksAddon());
+
+ terminal.open(parent);
+ fitAddon.fit();
+ }
+
+ @bind
+ private initListeners() {
+ const { terminal, fitAddon, overlayAddon, register, sendData } = this;
+ register(
+ terminal.onTitleChange(data => {
+ if (data && data !== '' && !this.titleFixed) {
+ document.title = data + ' | ' + this.title;
+ }
+ })
+ );
+ register(terminal.onData(data => sendData(data)));
+ register(terminal.onBinary(data => sendData(Uint8Array.from(data, v => v.charCodeAt(0)))));
+ register(
+ terminal.onResize(({ cols, rows }) => {
+ const msg = JSON.stringify({ columns: cols, rows: rows });
+ this.socket?.send(this.textEncoder.encode(Command.RESIZE_TERMINAL + msg));
+ if (this.resizeOverlay) overlayAddon.showOverlay(`${cols}x${rows}`, 300);
+ })
+ );
+ register(
+ terminal.onSelectionChange(() => {
+ if (this.terminal.getSelection() === '') return;
+ try {
+ document.execCommand('copy');
+ } catch (e) {
+ return;
+ }
+ this.overlayAddon?.showOverlay('\u2702', 200);
+ })
+ );
+ register(addEventListener(window, 'resize', () => fitAddon.fit()));
+ register(addEventListener(window, 'beforeunload', this.onWindowUnload));
+ }
+
+ @bind
+ public writeData(data: string | Uint8Array) {
+ const { terminal, textEncoder } = this;
+ const { limit, highWater, lowWater } = this.options.flowControl;
+
+ this.written += data.length;
+ if (this.written > limit) {
+ terminal.write(data, () => {
+ this.pending = Math.max(this.pending - 1, 0);
+ if (this.pending < lowWater) {
+ this.socket?.send(textEncoder.encode(Command.RESUME));
+ }
+ });
+ this.pending++;
+ this.written = 0;
+ if (this.pending > highWater) {
+ this.socket?.send(textEncoder.encode(Command.PAUSE));
+ }
+ } else {
+ terminal.write(data);
+ }
+ }
+
+ @bind
+ public sendData(data: string | Uint8Array) {
+ const { socket, textEncoder } = this;
+ if (socket?.readyState !== WebSocket.OPEN) return;
+
+ if (typeof data === 'string') {
+ const payload = new Uint8Array(data.length * 3 + 1);
+ payload[0] = Command.INPUT.charCodeAt(0);
+ const stats = textEncoder.encodeInto(data, payload.subarray(1));
+ socket.send(payload.subarray(0, (stats.written as number) + 1));
+ } else {
+ const payload = new Uint8Array(data.length + 1);
+ payload[0] = Command.INPUT.charCodeAt(0);
+ payload.set(data, 1);
+ socket.send(payload);
+ }
+ }
+
+ @bind
+ public connect() {
+ this.socket = new WebSocket(this.options.wsUrl, ['tty']);
+ const { socket, register } = this;
+
+ socket.binaryType = 'arraybuffer';
+ register(addEventListener(socket, 'open', this.onSocketOpen));
+ register(addEventListener(socket, 'message', this.onSocketData as EventListener));
+ register(addEventListener(socket, 'close', this.onSocketClose as EventListener));
+ register(addEventListener(socket, 'error', () => (this.doReconnect = false)));
+ }
+
+ @bind
+ private onSocketOpen() {
+ console.log('[ttyd] websocket connection opened');
+
+ const { textEncoder, terminal, overlayAddon } = this;
+ const msg = JSON.stringify({ AuthToken: this.token, columns: terminal.cols, rows: terminal.rows });
+ this.socket?.send(textEncoder.encode(msg));
+
+ if (this.opened) {
+ terminal.reset();
+ terminal.options.disableStdin = false;
+ overlayAddon.showOverlay('Reconnected', 300);
+ } else {
+ this.opened = true;
+ }
+
+ this.doReconnect = this.reconnect;
+ this.initListeners();
+ terminal.focus();
+ }
+
+ @bind
+ private onSocketClose(event: CloseEvent) {
+ console.log(`[ttyd] websocket connection closed with code: ${event.code}`);
+
+ const { refreshToken, connect, doReconnect, overlayAddon } = this;
+ overlayAddon.showOverlay('Connection Closed');
+ this.dispose();
+
+ // 1000: CLOSE_NORMAL
+ if (event.code !== 1000 && doReconnect) {
+ overlayAddon.showOverlay('Reconnecting...');
+ refreshToken().then(connect);
+ } else {
+ const { terminal } = this;
+ const keyDispose = terminal.onKey(e => {
+ const event = e.domEvent;
+ if (event.key === 'Enter') {
+ keyDispose.dispose();
+ overlayAddon.showOverlay('Reconnecting...');
+ refreshToken().then(connect);
+ }
+ });
+ overlayAddon.showOverlay('Press ⏎ to Reconnect');
+ }
+ }
+
+ @bind
+ private onSocketData(event: MessageEvent) {
+ const { textDecoder } = this;
+ const rawData = event.data as ArrayBuffer;
+ const cmd = String.fromCharCode(new Uint8Array(rawData)[0]);
+ const data = rawData.slice(1);
+
+ switch (cmd) {
+ case Command.OUTPUT:
+ this.writeFunc(data);
+ break;
+ case Command.SET_WINDOW_TITLE:
+ this.title = textDecoder.decode(data);
+ document.title = this.title;
+ break;
+ case Command.SET_PREFERENCES:
+ this.applyPreferences({
+ ...this.options.clientOptions,
+ ...JSON.parse(textDecoder.decode(data)),
+ } as Preferences);
+ break;
+ default:
+ console.warn(`[ttyd] unknown command: ${cmd}`);
+ break;
+ }
+ }
+
+ @bind
+ private applyPreferences(prefs: Preferences) {
+ const { terminal, fitAddon, register } = this;
+ if (prefs.enableZmodem || prefs.enableTrzsz) {
+ this.zmodemAddon = new ZmodemAddon({
+ zmodem: prefs.enableZmodem,
+ trzsz: prefs.enableTrzsz,
+ windows: prefs.isWindows,
+ onSend: this.sendCb,
+ sender: this.sendData,
+ writer: this.writeData,
+ });
+ this.writeFunc = data => this.zmodemAddon?.consume(data);
+ terminal.loadAddon(register(this.zmodemAddon));
+ }
+ Object.keys(prefs).forEach(key => {
+ const value = prefs[key];
+ switch (key) {
+ case 'rendererType':
+ this.setRendererType(value);
+ break;
+ case 'disableLeaveAlert':
+ if (value) {
+ window.removeEventListener('beforeunload', this.onWindowUnload);
+ console.log('[ttyd] Leave site alert disabled');
+ }
+ break;
+ case 'disableResizeOverlay':
+ if (value) {
+ console.log('[ttyd] Resize overlay disabled');
+ this.resizeOverlay = false;
+ }
+ break;
+ case 'disableReconnect':
+ if (value) {
+ console.log('[ttyd] Reconnect disabled');
+ this.reconnect = false;
+ this.doReconnect = false;
+ }
+ break;
+ case 'enableZmodem':
+ if (value) console.log('[ttyd] Zmodem enabled');
+ break;
+ case 'enableTrzsz':
+ if (value) console.log('[ttyd] trzsz enabled');
+ break;
+ case 'enableSixel':
+ if (value) {
+ terminal.loadAddon(register(new ImageAddon()));
+ console.log('[ttyd] Sixel enabled');
+ }
+ break;
+ case 'titleFixed':
+ if (!value || value === '') return;
+ console.log(`[ttyd] setting fixed title: ${value}`);
+ this.titleFixed = value;
+ document.title = value;
+ break;
+ case 'isWindows':
+ if (value) console.log('[ttyd] is windows');
+ break;
+ default:
+ console.log(`[ttyd] option: ${key}=${JSON.stringify(value)}`);
+ if (terminal.options[key] instanceof Object) {
+ terminal.options[key] = Object.assign({}, terminal.options[key], value);
+ } else {
+ terminal.options[key] = value;
+ }
+ if (key.indexOf('font') === 0) fitAddon.fit();
+ break;
+ }
+ });
+ }
+
+ @bind
+ private setRendererType(value: RendererType) {
+ const { terminal } = this;
+ const disposeCanvasRenderer = () => {
+ try {
+ this.canvasAddon?.dispose();
+ } catch {
+ // ignore
+ }
+ this.canvasAddon = undefined;
+ };
+ const disposeWebglRenderer = () => {
+ try {
+ this.webglAddon?.dispose();
+ } catch {
+ // ignore
+ }
+ this.webglAddon = undefined;
+ };
+ const enableCanvasRenderer = () => {
+ if (this.canvasAddon) return;
+ this.canvasAddon = new CanvasAddon();
+ disposeWebglRenderer();
+ try {
+ this.terminal.loadAddon(this.canvasAddon);
+ console.log('[ttyd] canvas renderer loaded');
+ } catch (e) {
+ console.log('[ttyd] canvas renderer could not be loaded, falling back to dom renderer', e);
+ disposeCanvasRenderer();
+ }
+ };
+ const enableWebglRenderer = () => {
+ if (this.webglAddon) return;
+ this.webglAddon = new WebglAddon();
+ disposeCanvasRenderer();
+ try {
+ this.webglAddon.onContextLoss(() => {
+ this.webglAddon?.dispose();
+ });
+ terminal.loadAddon(this.webglAddon);
+ console.log('[ttyd] WebGL renderer loaded');
+ } catch (e) {
+ console.log('[ttyd] WebGL renderer could not be loaded, falling back to canvas renderer', e);
+ disposeWebglRenderer();
+ enableCanvasRenderer();
+ }
+ };
+
+ switch (value) {
+ case 'canvas':
+ enableCanvasRenderer();
+ break;
+ case 'webgl':
+ enableWebglRenderer();
+ break;
+ case 'dom':
+ disposeWebglRenderer();
+ disposeCanvasRenderer();
+ console.log('[ttyd] dom renderer loaded');
+ break;
+ default:
+ break;
+ }
+ }
+}