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 | |
parent | Initial commit. (diff) | |
download | ttyd-upstream/1.6.3.tar.xz ttyd-upstream/1.6.3.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')
-rw-r--r-- | html/src/components/app.tsx | 59 | ||||
-rw-r--r-- | html/src/components/modal/index.tsx | 27 | ||||
-rw-r--r-- | html/src/components/modal/modal.scss | 81 | ||||
-rw-r--r-- | html/src/components/terminal/index.tsx | 372 | ||||
-rw-r--r-- | html/src/components/terminal/overlay.ts | 75 | ||||
-rw-r--r-- | html/src/components/zmodem/index.tsx | 209 |
6 files changed, 823 insertions, 0 deletions
diff --git a/html/src/components/app.tsx b/html/src/components/app.tsx new file mode 100644 index 0000000..a899b7f --- /dev/null +++ b/html/src/components/app.tsx @@ -0,0 +1,59 @@ +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'); +} + +const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; +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, +} as ClientOptions; +const termOptions = { + fontSize: 13, + fontFamily: 'Menlo For Powerline,Consolas,Liberation Mono,Menlo,Courier,monospace', + theme: { + foreground: '#d2d2d2', + background: '#2b2b2b', + cursor: '#adadad', + black: '#000000', + red: '#d81e00', + green: '#5ea702', + yellow: '#cfae00', + blue: '#427ab3', + magenta: '#89658e', + cyan: '#00a7aa', + white: '#dbded8', + brightBlack: '#686a66', + brightRed: '#f54235', + brightGreen: '#99e343', + brightYellow: '#fdeb61', + brightBlue: '#84b0d8', + brightMagenta: '#bc94b7', + brightCyan: '#37e6e8', + brightWhite: '#f1f1f0', + } as ITheme, +} as ITerminalOptions; + +export class App extends Component { + render() { + return ( + <Xterm + id="terminal-container" + wsUrl={wsUrl} + tokenUrl={tokenUrl} + clientOptions={clientOptions} + termOptions={termOptions} + /> + ); + } +} diff --git a/html/src/components/modal/index.tsx b/html/src/components/modal/index.tsx new file mode 100644 index 0000000..558a218 --- /dev/null +++ b/html/src/components/modal/index.tsx @@ -0,0 +1,27 @@ +import { h, Component, ComponentChildren } from 'preact'; + +import './modal.scss'; + +interface Props { + show: boolean; + children: ComponentChildren; +} + +export class Modal extends Component<Props> { + constructor(props: Props) { + super(props); + } + + render({ show, children }: Props) { + return ( + show && ( + <div className="modal"> + <div className="modal-background" /> + <div className="modal-content"> + <div className="box">{children}</div> + </div> + </div> + ) + ); + } +} diff --git a/html/src/components/modal/modal.scss b/html/src/components/modal/modal.scss new file mode 100644 index 0000000..a99873b --- /dev/null +++ b/html/src/components/modal/modal.scss @@ -0,0 +1,81 @@ +.modal { + bottom: 0; + left: 0; + right: 0; + top: 0; + align-items: center; + display: flex; + overflow: hidden; + position: fixed; + z-index: 40; +} + +.modal-background { + bottom: 0; + left: 0; + position: absolute; + right: 0; + top: 0; + background-color: #4a4a4acc; +} + +.modal-content { + margin: 0 20px; + max-height: calc(100vh - 160px); + overflow: auto; + position: relative; + width: 100%; + + .box { + background-color: #fff; + color: #4a4a4a; + display: block; + padding: 1.25rem; + } + + header { + font-weight: bold; + text-align: center; + padding-bottom: 10px; + margin-bottom: 10px; + border-bottom: 1px solid #ddd; + } + + .file-input { + height: .01em; + left: 0; + outline: none; + position: absolute; + top: 0; + width: .01em; + } + + .file-cta { + cursor: pointer; + background-color: #f5f5f5; + color: #6200ee; + outline: none; + align-items: center; + box-shadow: none; + display: inline-flex; + height: 2.25em; + justify-content: flex-start; + line-height: 1.5; + position: relative; + vertical-align: top; + border-color: #dbdbdb; + border-radius: 3px; + font-size: 1em; + font-weight: 500; + padding: calc(.375em - 1px) 1em; + white-space: nowrap; + } +} + +@media print, screen and (min-width: 769px) { + .modal-content { + margin: 0 auto; + max-height: calc(100vh - 40px); + width: 640px; + } +} 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; + } +} diff --git a/html/src/components/zmodem/index.tsx b/html/src/components/zmodem/index.tsx new file mode 100644 index 0000000..9498863 --- /dev/null +++ b/html/src/components/zmodem/index.tsx @@ -0,0 +1,209 @@ +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<number>) => void; + control: FlowControl; +} + +interface State { + modal: boolean; +} + +export class ZmodemAddon extends Component<Props, State> 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 ( + <Modal show={modal}> + <label class="file-label"> + <input onChange={this.sendFile} class="file-input" type="file" multiple /> + <span class="file-cta">Choose files…</span> + </label> + </Modal> + ); + } + + 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<number>) => this.zmodemSend(octets), + on_retract: () => this.zmodemReset(), + on_detect: (detection: Zmodem.Detection) => this.zmodemDetect(detection), + }); + } + + @bind + private zmodemReset() { + this.terminal.setOption('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<number>): void { + this.props.sender(data); + } + + @bind + private zmodemDetect(detection: Zmodem.Detection): void { + const { terminal, receiveFile, zmodemReset } = this; + terminal.setOption('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]}`; + } +} |