summaryrefslogtreecommitdiffstats
path: root/html/src
diff options
context:
space:
mode:
Diffstat (limited to 'html/src')
-rw-r--r--html/src/components/app.tsx65
-rw-r--r--html/src/components/modal/index.tsx27
-rw-r--r--html/src/components/modal/modal.scss81
-rw-r--r--html/src/components/terminal/index.tsx59
-rw-r--r--html/src/components/terminal/xterm/addons/overlay.ts73
-rw-r--r--html/src/components/terminal/xterm/addons/zmodem.ts180
-rw-r--r--html/src/components/terminal/xterm/index.ts461
-rw-r--r--html/src/favicon.pngbin0 -> 1657 bytes
-rw-r--r--html/src/index.tsx9
-rw-r--r--html/src/style/index.scss18
-rw-r--r--html/src/template.html17
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
new file mode 100644
index 0000000..36b7596
--- /dev/null
+++ b/html/src/favicon.png
Binary files differ
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>