From 60ef06ac51c8e5fab1c4d19f14d95f3f004a4333 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Mon, 23 Jan 2023 09:44:51 +0100 Subject: Merging upstream version 1.7.3. Signed-off-by: Daniel Baumann --- html/src/components/app.tsx | 22 +- html/src/components/terminal/index.tsx | 432 ++------------------ html/src/components/terminal/overlay.ts | 75 ---- .../components/terminal/xterm/addons/overlay.ts | 73 ++++ .../src/components/terminal/xterm/addons/zmodem.ts | 164 ++++++++ html/src/components/terminal/xterm/index.ts | 450 +++++++++++++++++++++ html/src/components/zmodem/index.tsx | 209 ---------- 7 files changed, 728 insertions(+), 697 deletions(-) delete mode 100644 html/src/components/terminal/overlay.ts create mode 100644 html/src/components/terminal/xterm/addons/overlay.ts create mode 100644 html/src/components/terminal/xterm/addons/zmodem.ts create mode 100644 html/src/components/terminal/xterm/index.ts delete mode 100644 html/src/components/zmodem/index.tsx (limited to 'html/src/components') diff --git a/html/src/components/app.tsx b/html/src/components/app.tsx index 15815ab..71a2d89 100644 --- a/html/src/components/app.tsx +++ b/html/src/components/app.tsx @@ -1,22 +1,20 @@ import { h, Component } from 'preact'; import { ITerminalOptions, ITheme } from 'xterm'; -import { ClientOptions, Xterm } from './terminal'; - -if ((module as any).hot) { - // tslint:disable-next-line:no-var-requires - require('preact/debug'); -} +import { ClientOptions, FlowControl } from './terminal/xterm'; +import { Terminal } from './terminal'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; -const path = window.location.pathname.replace(/[\/]+$/, ''); +const path = window.location.pathname.replace(/[/]+$/, ''); const wsUrl = [protocol, '//', window.location.host, path, '/ws', window.location.search].join(''); const tokenUrl = [window.location.protocol, '//', window.location.host, path, '/token'].join(''); const clientOptions = { rendererType: 'webgl', disableLeaveAlert: false, disableResizeOverlay: false, - titleFixed: null, + enableZmodem: false, + enableTrzsz: false, + enableSixel: false, } as ClientOptions; const termOptions = { fontSize: 13, @@ -44,16 +42,22 @@ const termOptions = { } as ITheme, allowProposedApi: true, } as ITerminalOptions; +const flowControl = { + limit: 100000, + highWater: 10, + lowWater: 4, +} as FlowControl; export class App extends Component { render() { return ( - ); } diff --git a/html/src/components/terminal/index.tsx b/html/src/components/terminal/index.tsx index af1ab36..a7349fd 100644 --- a/html/src/components/terminal/index.tsx +++ b/html/src/components/terminal/index.tsx @@ -1,435 +1,59 @@ import { bind } from 'decko'; import { Component, h } from 'preact'; -import { 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 './overlay'; -import { ZmodemAddon, FlowControl } from '../zmodem'; +import { Xterm, XtermOptions } from './xterm'; import 'xterm/css/xterm.css'; -import worker from 'xterm-addon-image/lib/xterm-addon-image-worker'; +import { Modal } from '../modal'; -const imageWorkerUrl = window.URL.createObjectURL(new Blob([worker], { type: 'text/javascript' })); - -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', -} - -export type RendererType = 'dom' | 'canvas' | 'webgl'; - -export interface ClientOptions { - rendererType: RendererType; - disableLeaveAlert: boolean; - disableResizeOverlay: boolean; - titleFixed: string; +interface Props extends XtermOptions { + id: string; } -interface Props { - id: string; - wsUrl: string; - tokenUrl: string; - clientOptions: ClientOptions; - termOptions: ITerminalOptions; +interface State { + modal: boolean; } -export class Xterm extends Component { - private textEncoder: TextEncoder; - private textDecoder: TextDecoder; +export class Terminal extends Component { private container: HTMLElement; - private terminal: Terminal; - - private fitAddon: FitAddon; - private overlayAddon: OverlayAddon; - private zmodemAddon: ZmodemAddon; - private webglAddon: WebglAddon; - private canvasAddon: CanvasAddon; - - private socket: WebSocket; - private token: string; - private opened = false; - private title: string; - private titleFixed: string; - private resizeTimeout: number; - private resizeOverlay = true; - private reconnect = true; - private doReconnect = true; + private xterm: Xterm; constructor(props: Props) { - super(props); - - this.textEncoder = new TextEncoder(); - this.textDecoder = new TextDecoder(); - this.fitAddon = new FitAddon(); - this.overlayAddon = new OverlayAddon(); + super(); + this.xterm = new Xterm(props, this.showModal); } async componentDidMount() { - await this.refreshToken(); - this.openTerminal(); - this.connect(); - - window.addEventListener('resize', this.onWindowResize); - window.addEventListener('beforeunload', this.onWindowUnload); + await this.xterm.refreshToken(); + this.xterm.open(this.container); + this.xterm.connect(); } componentWillUnmount() { - this.socket.close(); - this.terminal.dispose(); - - window.removeEventListener('resize', this.onWindowResize); - window.removeEventListener('beforeunload', this.onWindowUnload); + this.xterm.dispose(); } - render({ id }: Props) { - const control = { - limit: 100000, - highWater: 10, - lowWater: 4, - pause: () => this.pause(), - resume: () => this.resume(), - } as FlowControl; - + render({ id }: Props, { modal }: State) { return ( -
(this.container = c)}> - (this.zmodemAddon = c)} sender={this.sendData} control={control} /> +
(this.container = c as HTMLElement)}> + + +
); } @bind - private pause() { - const { textEncoder, socket } = this; - socket.send(textEncoder.encode(Command.PAUSE)); - } - - @bind - private resume() { - const { textEncoder, socket } = this; - socket.send(textEncoder.encode(Command.RESUME)); - } - - @bind - private sendData(data: ArrayLike) { - const { socket } = this; - const payload = new Uint8Array(data.length + 1); - payload[0] = Command.INPUT.charCodeAt(0); - payload.set(data, 1); - socket.send(payload); - } - - @bind - private async refreshToken() { - try { - const resp = await fetch(this.props.tokenUrl); - if (resp.ok) { - const json = await resp.json(); - this.token = json.token; - } - } catch (e) { - console.error(`[ttyd] fetch ${this.props.tokenUrl}: `, e); - } - } - - @bind - private onWindowResize() { - const { fitAddon } = this; - clearTimeout(this.resizeTimeout); - this.resizeTimeout = setTimeout(() => fitAddon.fit(), 250) as any; - } - - @bind - private onWindowUnload(event: BeforeUnloadEvent): any { - const { socket } = this; - if (socket && socket.readyState === WebSocket.OPEN) { - const message = 'Close terminal? this will also terminate the command.'; - event.returnValue = message; - return message; - } - event.preventDefault(); - } - - @bind - private openTerminal() { - this.terminal = new Terminal(this.props.termOptions); - const { terminal, container, 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.loadAddon(this.zmodemAddon); - terminal.loadAddon(new ImageAddon(imageWorkerUrl)); - - terminal.onTitleChange(data => { - if (data && data !== '' && !this.titleFixed) { - document.title = data + ' | ' + this.title; - } - }); - terminal.onData(this.onTerminalData); - terminal.onResize(this.onTerminalResize); - if (document.queryCommandSupported && document.queryCommandSupported('copy')) { - terminal.onSelectionChange(() => { - if (terminal.getSelection() === '') return; - overlayAddon.showOverlay('\u2702', 200); - document.execCommand('copy'); - }); - } - terminal.open(container); - fitAddon.fit(); - } - - @bind - private connect() { - this.socket = new WebSocket(this.props.wsUrl, ['tty']); - const { socket } = this; - - socket.binaryType = 'arraybuffer'; - socket.onopen = this.onSocketOpen; - socket.onmessage = this.onSocketData; - socket.onclose = this.onSocketClose; - socket.onerror = this.onSocketError; - } - - @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': - default: - break; - } - } - - @bind - private applyOptions(options: ITerminalOptions) { - const { terminal, fitAddon } = this; - - Object.keys(options).forEach(key => { - const value = options[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 '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 onSocketOpen() { - console.log('[ttyd] websocket connection opened'); - - const { socket, textEncoder, terminal, fitAddon, overlayAddon } = this; - const dims = fitAddon.proposeDimensions(); - socket.send( - textEncoder.encode( - JSON.stringify({ - AuthToken: this.token, - columns: dims.cols, - rows: dims.rows, - }) - ) - ); - - if (this.opened) { - terminal.reset(); - terminal.resize(dims.cols, dims.rows); - overlayAddon.showOverlay('Reconnected', 300); - } else { - this.opened = true; - } - - this.doReconnect = this.reconnect; - - 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', null); - - // 1000: CLOSE_NORMAL - if (event.code !== 1000 && doReconnect) { - overlayAddon.showOverlay('Reconnecting...', null); - 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...', null); - refreshToken().then(connect); - } - }); - overlayAddon.showOverlay('Press ⏎ to Reconnect', null); - } - } - - @bind - private onSocketError(event: Event) { - console.error('[ttyd] websocket connection error: ', event); - this.doReconnect = false; - } - - @bind - private onSocketData(event: MessageEvent) { - const { textDecoder, zmodemAddon } = 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: - zmodemAddon.consume(data); - break; - case Command.SET_WINDOW_TITLE: - this.title = textDecoder.decode(data); - document.title = this.title; - break; - case Command.SET_PREFERENCES: - const prefs = JSON.parse(textDecoder.decode(data)); - const options = Object.assign({}, this.props.clientOptions, prefs) as ITerminalOptions; - this.applyOptions(options); - break; - default: - console.warn(`[ttyd] unknown command: ${cmd}`); - break; - } - } - - @bind - private onTerminalResize(size: { cols: number; rows: number }) { - const { overlayAddon, socket, textEncoder, resizeOverlay } = this; - if (!socket || socket.readyState !== WebSocket.OPEN) return; - - const msg = JSON.stringify({ columns: size.cols, rows: size.rows }); - socket.send(textEncoder.encode(Command.RESIZE_TERMINAL + msg)); - - if (resizeOverlay) { - setTimeout(() => { - overlayAddon.showOverlay(`${size.cols}x${size.rows}`); - }, 500); - } + showModal() { + this.setState({ modal: true }); } @bind - private onTerminalData(data: string) { - const { socket, textEncoder } = this; - if (socket.readyState === WebSocket.OPEN) { - socket.send(textEncoder.encode(Command.INPUT + data)); - } + sendFile(event: Event) { + this.setState({ modal: false }); + const files = (event.target as HTMLInputElement).files; + if (files) this.xterm.sendFile(files); } } diff --git a/html/src/components/terminal/overlay.ts b/html/src/components/terminal/overlay.ts deleted file mode 100644 index 0438acf..0000000 --- a/html/src/components/terminal/overlay.ts +++ /dev/null @@ -1,75 +0,0 @@ -// ported from hterm.Terminal.prototype.showOverlay -// https://chromium.googlesource.com/apps/libapps/+/master/hterm/js/hterm_terminal.js -import { ITerminalAddon, Terminal } from 'xterm'; - -export class OverlayAddon implements ITerminalAddon { - private terminal: Terminal | undefined; - private overlayNode: HTMLElement | null; - private overlayTimeout: number | null; - - 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 {} - - showOverlay(msg: string, timeout?: number): void { - const { terminal, overlayNode } = this; - - 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 === null) { - return; - } - - const self = this; - self.overlayTimeout = setTimeout(() => { - overlayNode.style.opacity = '0'; - self.overlayTimeout = setTimeout(() => { - if (overlayNode.parentNode) { - overlayNode.parentNode.removeChild(overlayNode); - } - self.overlayTimeout = null; - overlayNode.style.opacity = '0.75'; - }, 200) as any; - }, timeout || 1500) as any; - } -} 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..3d38c5c --- /dev/null +++ b/html/src/components/terminal/xterm/addons/zmodem.ts @@ -0,0 +1,164 @@ +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; + 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(); + } + + @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, + }); + 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..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(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; + } + } +} diff --git a/html/src/components/zmodem/index.tsx b/html/src/components/zmodem/index.tsx deleted file mode 100644 index f0e9e87..0000000 --- a/html/src/components/zmodem/index.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import { bind } from 'decko'; -import { h, Component } from 'preact'; -import { saveAs } from 'file-saver'; -import { IDisposable, ITerminalAddon, Terminal } from 'xterm'; -import * as Zmodem from 'zmodem.js/src/zmodem_browser'; - -import { Modal } from '../modal'; - -export interface FlowControl { - limit: number; - highWater: number; - lowWater: number; - - pause: () => void; - resume: () => void; -} - -interface Props { - sender: (data: ArrayLike) => void; - control: FlowControl; -} - -interface State { - modal: boolean; -} - -export class ZmodemAddon extends Component implements ITerminalAddon { - private terminal: Terminal | undefined; - private keyDispose: IDisposable | undefined; - private sentry: Zmodem.Sentry; - private session: Zmodem.Session; - - private written = 0; - private pending = 0; - - constructor(props: Props) { - super(props); - - this.zmodemInit(); - } - - render(_, { modal }: State) { - return ( - - - - ); - } - - activate(terminal: Terminal): void { - this.terminal = terminal; - } - - dispose(): void {} - - consume(data: ArrayBuffer) { - const { sentry, handleError } = this; - try { - sentry.consume(data); - } catch (e) { - handleError(e, 'consume'); - } - } - - @bind - private handleError(e: Error, reason: string) { - console.error(`[ttyd] zmodem ${reason}: `, e); - this.zmodemReset(); - } - - @bind - private zmodemInit() { - this.session = null; - this.sentry = new Zmodem.Sentry({ - to_terminal: (octets: ArrayBuffer) => this.zmodemWrite(octets), - sender: (octets: ArrayLike) => this.zmodemSend(octets), - on_retract: () => this.zmodemReset(), - on_detect: (detection: Zmodem.Detection) => this.zmodemDetect(detection), - }); - } - - @bind - private zmodemReset() { - this.terminal.options.disableStdin = false; - - if (this.keyDispose) { - this.keyDispose.dispose(); - this.keyDispose = null; - } - this.zmodemInit(); - - this.terminal.focus(); - } - - @bind - private zmodemWrite(data: ArrayBuffer): void { - const { limit, highWater, lowWater, pause, resume } = this.props.control; - const { terminal } = this; - const rawData = new Uint8Array(data); - - this.written += rawData.length; - if (this.written > limit) { - terminal.write(rawData, () => { - this.pending = Math.max(this.pending - 1, 0); - if (this.pending < lowWater) { - resume(); - } - }); - this.pending++; - this.written = 0; - if (this.pending > highWater) { - pause(); - } - } else { - terminal.write(rawData); - } - } - - @bind - private zmodemSend(data: ArrayLike): void { - this.props.sender(data); - } - - @bind - private zmodemDetect(detection: Zmodem.Detection): void { - const { terminal, receiveFile, zmodemReset } = this; - terminal.options.disableStdin = true; - - this.keyDispose = terminal.onKey(e => { - const event = e.domEvent; - if (event.ctrlKey && event.key === 'c') { - detection.deny(); - } - }); - - this.session = detection.confirm(); - this.session.on('session_end', zmodemReset); - - if (this.session.type === 'send') { - this.setState({ modal: true }); - } else { - receiveFile(); - } - } - - @bind - private sendFile(event: Event) { - this.setState({ modal: false }); - - const { session, writeProgress, handleError } = this; - const files: FileList = (event.target as HTMLInputElement).files; - - Zmodem.Browser.send_files(session, files, { - on_progress: (_, offer: Zmodem.Offer) => writeProgress(offer), - }) - .then(() => session.close()) - .catch(e => handleError(e, 'send')); - } - - @bind - private receiveFile() { - const { session, writeProgress, handleError } = this; - - session.on('offer', (offer: Zmodem.Offer) => { - const fileBuffer = []; - offer.on('input', payload => { - writeProgress(offer); - fileBuffer.push(new Uint8Array(payload)); - }); - offer - .accept() - .then(() => { - const blob = new Blob(fileBuffer, { type: 'application/octet-stream' }); - saveAs(blob, offer.get_details().name); - }) - .catch(e => handleError(e, 'receive')); - }); - - session.start(); - } - - @bind - private writeProgress(offer: Zmodem.Offer) { - const { terminal, 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); - - terminal.write(`${name} ${percent}% ${bytesHuman(offset, 2)}/${bytesHuman(size, 2)}\r`); - } - - 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]}`; - } -} -- cgit v1.2.3