summaryrefslogtreecommitdiffstats
path: root/html/src
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2023-01-23 08:44:51 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2023-01-23 08:45:08 +0000
commit60ef06ac51c8e5fab1c4d19f14d95f3f004a4333 (patch)
tree91b176a8d7d7730f9fd95413ea881095f9da9e23 /html/src
parentReleasing debian version 1.7.2-1. (diff)
downloadttyd-60ef06ac51c8e5fab1c4d19f14d95f3f004a4333.tar.xz
ttyd-60ef06ac51c8e5fab1c4d19f14d95f3f004a4333.zip
Merging upstream version 1.7.3.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'html/src')
-rw-r--r--html/src/components/app.tsx22
-rw-r--r--html/src/components/terminal/index.tsx432
-rw-r--r--html/src/components/terminal/xterm/addons/overlay.ts (renamed from html/src/components/terminal/overlay.ts)28
-rw-r--r--html/src/components/terminal/xterm/addons/zmodem.ts164
-rw-r--r--html/src/components/terminal/xterm/index.ts450
-rw-r--r--html/src/components/zmodem/index.tsx209
-rw-r--r--html/src/index.tsx3
7 files changed, 671 insertions, 637 deletions
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 (
- <Xterm
+ <Terminal
id="terminal-container"
wsUrl={wsUrl}
tokenUrl={tokenUrl}
clientOptions={clientOptions}
termOptions={termOptions}
+ flowControl={flowControl}
/>
);
}
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<Props> {
- private textEncoder: TextEncoder;
- private textDecoder: TextDecoder;
+export class Terminal extends Component<Props, State> {
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 (
- <div id={id} ref={c => (this.container = c)}>
- <ZmodemAddon ref={c => (this.zmodemAddon = c)} sender={this.sendData} control={control} />
+ <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
- 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.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/xterm/addons/overlay.ts
index 0438acf..6fa5a92 100644
--- a/html/src/components/terminal/overlay.ts
+++ b/html/src/components/terminal/xterm/addons/overlay.ts
@@ -1,11 +1,12 @@
// 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 | undefined;
- private overlayNode: HTMLElement | null;
- private overlayTimeout: number | null;
+ private terminal: Terminal;
+ private overlayNode: HTMLElement;
+ private overlayTimeout?: number;
constructor() {
this.overlayNode = document.createElement('div');
@@ -35,8 +36,10 @@ position: absolute;
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';
@@ -53,23 +56,18 @@ position: absolute;
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;
- }
+ if (this.overlayTimeout) clearTimeout(this.overlayTimeout);
+ if (!timeout) return;
- const self = this;
- self.overlayTimeout = setTimeout(() => {
+ this.overlayTimeout = window.setTimeout(() => {
overlayNode.style.opacity = '0';
- self.overlayTimeout = setTimeout(() => {
+ this.overlayTimeout = window.setTimeout(() => {
if (overlayNode.parentNode) {
overlayNode.parentNode.removeChild(overlayNode);
}
- self.overlayTimeout = null;
+ this.overlayTimeout = undefined;
overlayNode.style.opacity = '0.75';
- }, 200) as any;
- }, timeout || 1500) as any;
+ }, 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<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.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<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.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<number>): 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]}`;
- }
-}
diff --git a/html/src/index.tsx b/html/src/index.tsx
index 185872c..738fcd5 100644
--- a/html/src/index.tsx
+++ b/html/src/index.tsx
@@ -1,3 +1,6 @@
+if (process.env.NODE_ENV === 'development') {
+ require('preact/debug');
+}
import 'whatwg-fetch';
import { h, render } from 'preact';
import { App } from './components/app';