From 3b4936b6f2b870f9f2444ebb6ce1ca2de3f49d9f Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 04:54:31 +0200 Subject: Adding upstream version 1.7.4. Signed-off-by: Daniel Baumann --- html/src/components/terminal/xterm/index.ts | 461 ++++++++++++++++++++++++++++ 1 file changed, 461 insertions(+) create mode 100644 html/src/components/terminal/xterm/index.ts (limited to 'html/src/components/terminal/xterm/index.ts') 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(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; + } + } +} -- cgit v1.2.3