diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2021-02-08 13:16:47 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2021-02-08 13:16:47 +0000 |
commit | 4f5226cb7a97f86421a94fcc75c59fe6d709ae02 (patch) | |
tree | 1a2cab09cbbc1040650fe21c0a9cef15d2ccb6ee /html/src/components/terminal | |
parent | Initial commit. (diff) | |
download | ttyd-4f5226cb7a97f86421a94fcc75c59fe6d709ae02.tar.xz ttyd-4f5226cb7a97f86421a94fcc75c59fe6d709ae02.zip |
Adding upstream version 1.6.3.upstream/1.6.3
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'html/src/components/terminal')
-rw-r--r-- | html/src/components/terminal/index.tsx | 372 | ||||
-rw-r--r-- | html/src/components/terminal/overlay.ts | 75 |
2 files changed, 447 insertions, 0 deletions
diff --git a/html/src/components/terminal/index.tsx b/html/src/components/terminal/index.tsx new file mode 100644 index 0000000..b344287 --- /dev/null +++ b/html/src/components/terminal/index.tsx @@ -0,0 +1,372 @@ +import { bind } from 'decko'; +import * as backoff from 'backoff'; +import { Component, h } from 'preact'; +import { ITerminalOptions, Terminal } from 'xterm'; +import { FitAddon } from 'xterm-addon-fit'; +import { WebglAddon } from 'xterm-addon-webgl'; +import { WebLinksAddon } from 'xterm-addon-web-links'; + +import { OverlayAddon } from './overlay'; +import { ZmodemAddon, FlowControl } from '../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', +} + +export interface ClientOptions { + rendererType: 'dom' | 'canvas' | 'webgl'; + disableLeaveAlert: boolean; + disableResizeOverlay: boolean; + titleFixed: string; +} + +interface Props { + id: string; + wsUrl: string; + tokenUrl: string; + clientOptions: ClientOptions; + termOptions: ITerminalOptions; +} + +export class Xterm extends Component<Props> { + private textEncoder: TextEncoder; + private textDecoder: TextDecoder; + private container: HTMLElement; + private terminal: Terminal; + + private fitAddon: FitAddon; + private overlayAddon: OverlayAddon; + private zmodemAddon: ZmodemAddon; + + private socket: WebSocket; + private token: string; + private title: string; + private titleFixed: string; + private resizeTimeout: number; + private resizeOverlay = true; + + private backoff: backoff.Backoff; + private backoffLock = false; + private doBackoff = true; + private reconnect = false; + + constructor(props: Props) { + super(props); + + this.textEncoder = new TextEncoder(); + this.textDecoder = new TextDecoder(); + this.fitAddon = new FitAddon(); + this.overlayAddon = new OverlayAddon(); + this.backoff = backoff.exponential({ + initialDelay: 100, + maxDelay: 10000, + }); + this.backoff.failAfter(15); + this.backoff.on('ready', () => { + this.backoffLock = false; + this.refreshToken().then(this.connect); + }); + this.backoff.on('backoff', (_, delay: number) => { + console.log(`[ttyd] will attempt to reconnect websocket in ${delay}ms`); + this.backoffLock = true; + }); + this.backoff.on('fail', () => { + this.backoffLock = true; // break backoff + }); + } + + async componentDidMount() { + await this.refreshToken(); + this.openTerminal(); + this.connect(); + + window.addEventListener('resize', this.onWindowResize); + window.addEventListener('beforeunload', this.onWindowUnload); + } + + componentWillUnmount() { + this.socket.close(); + this.terminal.dispose(); + + window.removeEventListener('resize', this.onWindowResize); + window.removeEventListener('beforeunload', this.onWindowUnload); + } + + render({ id }: Props) { + const control = { + limit: 100000, + highWater: 10, + lowWater: 4, + pause: () => this.pause(), + resume: () => this.resume(), + } as FlowControl; + + return ( + <div id={id} ref={c => (this.container = c)}> + <ZmodemAddon ref={c => (this.zmodemAddon = c)} sender={this.sendData} control={control} /> + </div> + ); + } + + @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<number>) { + 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.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); + } + + @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 applyOptions(options: any) { + const { terminal, fitAddon } = this; + const isWebGL2Available = () => { + try { + const canvas = document.createElement('canvas'); + return !!(window.WebGL2RenderingContext && canvas.getContext('webgl2')); + } catch (e) { + return false; + } + }; + + Object.keys(options).forEach(key => { + const value = options[key]; + switch (key) { + case 'rendererType': + if (value === 'webgl' && isWebGL2Available()) { + terminal.loadAddon(new WebglAddon()); + console.log(`[ttyd] WebGL renderer enabled`); + } + 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.doBackoff = 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}=${value}`); + terminal.setOption(key, value); + if (key.indexOf('font') === 0) fitAddon.fit(); + break; + } + }); + } + + @bind + private onSocketOpen() { + console.log('[ttyd] websocket connection opened'); + this.backoff.reset(); + + const { socket, textEncoder, terminal, fitAddon, overlayAddon } = this; + socket.send(textEncoder.encode(JSON.stringify({ AuthToken: this.token }))); + + if (this.reconnect) { + const dims = fitAddon.proposeDimensions(); + terminal.reset(); + terminal.resize(dims.cols, dims.rows); + this.onTerminalResize(dims); // may not be triggered by terminal.resize + overlayAddon.showOverlay('Reconnected', 300); + } else { + this.reconnect = true; + fitAddon.fit(); + } + + this.applyOptions(this.props.clientOptions); + + terminal.focus(); + } + + @bind + private onSocketClose(event: CloseEvent) { + console.log(`[ttyd] websocket connection closed with code: ${event.code}`); + + const { backoff, doBackoff, backoffLock, overlayAddon } = this; + overlayAddon.showOverlay('Connection Closed', null); + + // 1000: CLOSE_NORMAL + if (event.code !== 1000 && doBackoff && !backoffLock) { + backoff.backoff(); + } + } + + @bind + private onSocketError(event: Event) { + console.error('[ttyd] websocket connection error: ', event); + const { backoff, doBackoff, backoffLock } = this; + if (doBackoff && !backoffLock) { + backoff.backoff(); + } + } + + @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: + this.applyOptions(JSON.parse(textDecoder.decode(data))); + 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.readyState === WebSocket.OPEN) { + 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); + } + } + + @bind + private onTerminalData(data: string) { + const { socket, textEncoder } = this; + if (socket.readyState === WebSocket.OPEN) { + socket.send(textEncoder.encode(Command.INPUT + data)); + } + } +} diff --git a/html/src/components/terminal/overlay.ts b/html/src/components/terminal/overlay.ts new file mode 100644 index 0000000..0438acf --- /dev/null +++ b/html/src/components/terminal/overlay.ts @@ -0,0 +1,75 @@ +// 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; + } +} |