summaryrefslogtreecommitdiffstats
path: root/html/src/components/terminal/xterm/index.ts
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2023-01-23 08:44:51 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2023-01-23 08:45:08 +0000
commit60ef06ac51c8e5fab1c4d19f14d95f3f004a4333 (patch)
tree91b176a8d7d7730f9fd95413ea881095f9da9e23 /html/src/components/terminal/xterm/index.ts
parentReleasing debian version 1.7.2-1. (diff)
downloadttyd-60ef06ac51c8e5fab1c4d19f14d95f3f004a4333.tar.xz
ttyd-60ef06ac51c8e5fab1c4d19f14d95f3f004a4333.zip
Merging upstream version 1.7.3.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'html/src/components/terminal/xterm/index.ts')
-rw-r--r--html/src/components/terminal/xterm/index.ts450
1 files changed, 450 insertions, 0 deletions
diff --git a/html/src/components/terminal/xterm/index.ts b/html/src/components/terminal/xterm/index.ts
new file mode 100644
index 0000000..e6809e4
--- /dev/null
+++ b/html/src/components/terminal/xterm/index.ts
@@ -0,0 +1,450 @@
+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;
+}
+
+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.PAUSE));
+ }
+ });
+ this.pending++;
+ this.written = 0;
+ if (this.pending > highWater) {
+ this.socket?.send(textEncoder.encode(Command.RESUME));
+ }
+ } else {
+ terminal.write(data);
+ }
+ }
+
+ @bind
+ public sendData(data: string | Uint8Array) {
+ const { socket, textEncoder } = this;
+ if (socket?.readyState !== WebSocket.OPEN) return;
+
+ if (typeof data === 'string') {
+ socket.send(textEncoder.encode(Command.INPUT + data));
+ } 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,
+ 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;
+ 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;
+ }
+ }
+}