diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 02:54:31 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 02:54:31 +0000 |
commit | 3b4936b6f2b870f9f2444ebb6ce1ca2de3f49d9f (patch) | |
tree | c91ab0ac9447b25f91c05e901212c2142fb95953 /html/src | |
parent | Initial commit. (diff) | |
download | ttyd-3b4936b6f2b870f9f2444ebb6ce1ca2de3f49d9f.tar.xz ttyd-3b4936b6f2b870f9f2444ebb6ce1ca2de3f49d9f.zip |
Adding upstream version 1.7.4.upstream/1.7.4
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'html/src')
-rw-r--r-- | html/src/components/app.tsx | 65 | ||||
-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 | 59 | ||||
-rw-r--r-- | html/src/components/terminal/xterm/addons/overlay.ts | 73 | ||||
-rw-r--r-- | html/src/components/terminal/xterm/addons/zmodem.ts | 180 | ||||
-rw-r--r-- | html/src/components/terminal/xterm/index.ts | 461 | ||||
-rw-r--r-- | html/src/favicon.png | bin | 0 -> 1657 bytes | |||
-rw-r--r-- | html/src/index.tsx | 9 | ||||
-rw-r--r-- | html/src/style/index.scss | 18 | ||||
-rw-r--r-- | html/src/template.html | 17 |
11 files changed, 990 insertions, 0 deletions
diff --git a/html/src/components/app.tsx b/html/src/components/app.tsx new file mode 100644 index 0000000..1ad5fd3 --- /dev/null +++ b/html/src/components/app.tsx @@ -0,0 +1,65 @@ +import { h, Component } from 'preact'; + +import { ITerminalOptions, ITheme } from 'xterm'; +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 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, + enableZmodem: false, + enableTrzsz: false, + enableSixel: false, + isWindows: false, +} as ClientOptions; +const termOptions = { + fontSize: 13, + fontFamily: '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, + allowProposedApi: true, +} as ITerminalOptions; +const flowControl = { + limit: 100000, + highWater: 10, + lowWater: 4, +} as FlowControl; + +export class App extends Component { + render() { + return ( + <Terminal + id="terminal-container" + wsUrl={wsUrl} + tokenUrl={tokenUrl} + clientOptions={clientOptions} + termOptions={termOptions} + flowControl={flowControl} + /> + ); + } +} 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..a7349fd --- /dev/null +++ b/html/src/components/terminal/index.tsx @@ -0,0 +1,59 @@ +import { bind } from 'decko'; +import { Component, h } from 'preact'; +import { Xterm, XtermOptions } from './xterm'; + +import 'xterm/css/xterm.css'; +import { Modal } from '../modal'; + +interface Props extends XtermOptions { + id: string; +} + +interface State { + modal: boolean; +} + +export class Terminal extends Component<Props, State> { + private container: HTMLElement; + private xterm: Xterm; + + constructor(props: Props) { + super(); + this.xterm = new Xterm(props, this.showModal); + } + + async componentDidMount() { + await this.xterm.refreshToken(); + this.xterm.open(this.container); + this.xterm.connect(); + } + + componentWillUnmount() { + this.xterm.dispose(); + } + + render({ id }: Props, { modal }: State) { + return ( + <div id={id} ref={c => (this.container = c as HTMLElement)}> + <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> + </div> + ); + } + + @bind + showModal() { + this.setState({ modal: true }); + } + + @bind + 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/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..8571f68 --- /dev/null +++ b/html/src/components/terminal/xterm/addons/zmodem.ts @@ -0,0 +1,180 @@ +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; + windows: 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(); + } + + private addDisposableListener(target: EventTarget, type: string, listener: EventListener) { + target.addEventListener(type, listener); + this.disposables.push({ dispose: () => target.removeEventListener(type, listener) }); + } + + @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, + isWindowsShell: this.options.windows, + }); + const element = terminal.element as EventTarget; + this.addDisposableListener(element, 'dragover', event => event.preventDefault()); + this.addDisposableListener(element, 'drop', event => { + event.preventDefault(); + this.trzszFilter + .uploadFiles((event as DragEvent).dataTransfer?.items as DataTransferItemList) + .then(() => console.log('[ttyd] upload success')) + .catch(err => console.log('[ttyd] upload failed: ' + err)); + }); + 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..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<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.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; + } + } +} diff --git a/html/src/favicon.png b/html/src/favicon.png Binary files differnew file mode 100644 index 0000000..36b7596 --- /dev/null +++ b/html/src/favicon.png diff --git a/html/src/index.tsx b/html/src/index.tsx new file mode 100644 index 0000000..738fcd5 --- /dev/null +++ b/html/src/index.tsx @@ -0,0 +1,9 @@ +if (process.env.NODE_ENV === 'development') { + require('preact/debug'); +} +import 'whatwg-fetch'; +import { h, render } from 'preact'; +import { App } from './components/app'; +import './style/index.scss'; + +render(<App />, document.body); diff --git a/html/src/style/index.scss b/html/src/style/index.scss new file mode 100644 index 0000000..0f9244b --- /dev/null +++ b/html/src/style/index.scss @@ -0,0 +1,18 @@ +html, +body { + height: 100%; + min-height: 100%; + margin: 0; + overflow: hidden; +} + +#terminal-container { + width: auto; + height: 100%; + margin: 0 auto; + padding: 0; + .terminal { + padding: 5px; + height: calc(100% - 10px); + } +} diff --git a/html/src/template.html b/html/src/template.html new file mode 100644 index 0000000..ffe2ad3 --- /dev/null +++ b/html/src/template.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> + <title><%= htmlWebpackPlugin.options.title %></title> + <link inline rel="icon" type="image/png" href="favicon.png"> + <% for (const css in htmlWebpackPlugin.files.css) { %> + <link inline rel="stylesheet" type="text/css" href="<%= htmlWebpackPlugin.files.css[css] %>"> + <% } %> +</head> +<body> +<% for (const js in htmlWebpackPlugin.files.js) { %> +<script inline type="text/javascript" src="<%= htmlWebpackPlugin.files.js[js] %>"></script> +<% } %> +</body> +</html> |