html: refactor terminal component

This commit is contained in:
Shuanglei Tao
2022-11-02 13:39:05 +08:00
parent 4cab29d470
commit b370b2c991
6 changed files with 10196 additions and 10224 deletions

View File

@@ -1,7 +1,8 @@
import { h, Component } from 'preact';
import { ITerminalOptions, ITheme } from 'xterm';
import { ClientOptions, FlowControl, Xterm } from './terminal';
import { ClientOptions, FlowControl } from './terminal/xterm';
import { Terminal } from './terminal';
if ((module as any).hot) {
require('preact/debug');
@@ -18,7 +19,6 @@ const clientOptions = {
enableZmodem: false,
enableTrzsz: false,
enableSixel: false,
titleFixed: null,
} as ClientOptions;
const termOptions = {
fontSize: 13,
@@ -55,7 +55,7 @@ const flowControl = {
export class App extends Component {
render() {
return (
<Xterm
<Terminal
id="terminal-container"
wsUrl={wsUrl}
tokenUrl={tokenUrl}

View File

@@ -1,505 +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 } 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';
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;
enableZmodem: boolean;
enableTrzsz: boolean;
enableSixel: boolean;
titleFixed: string | null;
}
type Options = ITerminalOptions & ClientOptions;
export interface FlowControl {
limit: number;
highWater: number;
lowWater: number;
}
interface Props {
interface Props extends XtermOptions {
id: string;
wsUrl: string;
tokenUrl: string;
clientOptions: ClientOptions;
termOptions: ITerminalOptions;
flowControl: FlowControl;
}
interface State {
zmodem: boolean;
trzsz: boolean;
modal: boolean;
}
export class Xterm extends Component<Props, State> {
private textEncoder = new TextEncoder();
private textDecoder = new TextDecoder();
export class Terminal extends Component<Props, State> {
private container: HTMLElement;
private terminal: Terminal;
private written = 0;
private pending = 0;
private fitAddon = new FitAddon();
private overlayAddon = new OverlayAddon();
private webglAddon?: WebglAddon;
private canvasAddon?: CanvasAddon;
private socket: WebSocket;
private writeFunc = (data: ArrayBuffer) => this.writeData(new Uint8Array(data));
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);
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, { zmodem, trzsz }: State) {
render({ id }: Props, { modal }: State) {
return (
<div id={id} ref={c => (this.container = c as HTMLElement)}>
{(zmodem || trzsz) && (
<ZmodemAddon
zmodem={zmodem}
trzsz={trzsz}
callback={this.zmodemCb}
sender={this.sendData}
writer={this.writeData}
/>
)}
<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));
showModal() {
this.setState({ modal: true });
}
@bind
private resume() {
const { textEncoder, socket } = this;
socket.send(textEncoder.encode(Command.RESUME));
}
@bind
private zmodemCb(addon: ZmodemAddon) {
this.terminal.loadAddon(addon);
this.writeFunc = data => addon.consume(data);
}
@bind
private sendData(data: string | Uint8Array) {
const { socket, textEncoder } = this;
if (!socket || 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
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.onTitleChange(data => {
if (data && data !== '' && !this.titleFixed) {
document.title = data + ' | ' + this.title;
}
});
terminal.onData(this.onTerminalData);
terminal.onBinary(this.onTerminalBinary);
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 writeData(data: string | Uint8Array) {
const { terminal, pause, resume } = this;
const { limit, highWater, lowWater } = this.props.flowControl;
this.written += data.length;
if (this.written > limit) {
terminal.write(data, () => {
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(data);
}
}
@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: Options) {
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 'enableZmodem':
if (value) {
this.setState({ zmodem: true });
console.log('[ttyd] Zmodem enabled');
}
break;
case 'enableTrzsz':
if (value) {
this.setState({ trzsz: true });
console.log('[ttyd] trzsz enabled');
}
break;
case 'enableSixel':
if (value) {
const imageWorkerUrl = window.URL.createObjectURL(
new Blob([worker], { type: 'text/javascript' })
);
terminal.loadAddon(new ImageAddon(imageWorkerUrl));
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 onSocketOpen() {
console.log('[ttyd] websocket connection opened');
const { socket, textEncoder, terminal, overlayAddon } = this;
socket.send(
textEncoder.encode(
JSON.stringify({
AuthToken: this.token,
columns: terminal.cols,
rows: terminal.rows,
})
)
);
if (this.opened) {
terminal.reset();
terminal.options.disableStdin = false;
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');
this.setState({ zmodem: false, trzsz: false });
// 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 onSocketError(event: Event) {
console.error('[ttyd] websocket connection error: ', event);
this.doReconnect = false;
}
@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.applyOptions({
...this.props.clientOptions,
...JSON.parse(textDecoder.decode(data)),
} as 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}`, 300);
}, 500);
}
}
@bind
private onTerminalData(data: string) {
this.sendData(data);
}
@bind
private onTerminalBinary(data: string) {
this.sendData(Uint8Array.from(data, v => v.charCodeAt(0)));
sendFile(event: Event) {
this.setState({ modal: false });
const files = (event.target as HTMLInputElement).files;
if (files) this.xterm.sendFile(files);
}
}

View File

@@ -1,59 +1,31 @@
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 { TrzszFilter } from 'trzsz';
import { Modal } from '../modal';
interface Props {
export interface ZmodeOptions {
zmodem: boolean;
trzsz: boolean;
callback: (addon: ZmodemAddon) => void;
onSend: () => void;
sender: (data: string | Uint8Array) => void;
writer: (data: string | Uint8Array) => void;
}
interface State {
modal: boolean;
}
export class ZmodemAddon extends Component<Props, State> implements ITerminalAddon {
private terminal: Terminal;
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(props: Props) {
super(props);
}
render(_: Props, { 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>
);
}
componentDidMount() {
this.props.callback(this);
}
componentWillUnmount() {
this.dispose();
}
constructor(private options: ZmodeOptions) {}
activate(terminal: Terminal) {
this.terminal = terminal;
if (this.props.zmodem) this.zmodemInit();
if (this.props.trzsz) this.trzszInit();
if (this.options.zmodem) this.zmodemInit();
if (this.options.trzsz) this.trzszInit();
}
dispose() {
@@ -65,7 +37,7 @@ export class ZmodemAddon extends Component<Props, State> implements ITerminalAdd
consume(data: ArrayBuffer) {
try {
if (this.props.trzsz) {
if (this.options.trzsz) {
this.trzszFilter.processServerOutput(data);
} else {
this.sentry.consume(data);
@@ -75,6 +47,7 @@ export class ZmodemAddon extends Component<Props, State> implements ITerminalAdd
}
}
@bind
private reset() {
this.terminal.options.disableStdin = false;
this.terminal.focus();
@@ -88,8 +61,8 @@ export class ZmodemAddon extends Component<Props, State> implements ITerminalAdd
@bind
private trzszInit() {
const { writer, sender, zmodem } = this.props;
const { terminal } = this;
const { sender, writer, zmodem } = this.options;
this.trzszFilter = new TrzszFilter({
writeToTerminal: data => {
if (!this.trzszFilter.isTransferringFiles() && zmodem) {
@@ -106,7 +79,7 @@ export class ZmodemAddon extends Component<Props, State> implements ITerminalAdd
@bind
private zmodemInit() {
const { writer, sender } = this.props;
const { sender, writer } = this.options;
const { terminal, reset, zmodemDetect } = this;
this.session = null;
this.sentry = new Zmodem.Sentry({
@@ -135,19 +108,15 @@ export class ZmodemAddon extends Component<Props, State> implements ITerminalAdd
this.session.on('session_end', () => reset());
if (this.session.type === 'send') {
this.setState({ modal: true });
this.options.onSend();
} else {
receiveFile();
}
}
@bind
private sendFile(event: Event) {
this.setState({ modal: false });
public sendFile(files: FileList) {
const { session, writeProgress, handleError } = this;
const files = (event.target as HTMLInputElement).files;
Zmodem.Browser.send_files(session, files, {
on_progress: (_, offer) => writeProgress(offer),
})
@@ -182,7 +151,7 @@ export class ZmodemAddon extends Component<Props, State> implements ITerminalAdd
const offset = offer.get_offset();
const percent = ((100 * offset) / size).toFixed(2);
this.props.writer(`${name} ${percent}% ${bytesHuman(offset, 2)}/${bytesHuman(size, 2)}\r`);
this.options.writer(`${name} ${percent}% ${bytesHuman(offset, 2)}/${bytesHuman(size, 2)}\r`);
}
private bytesHuman(bytes: any, precision: number): string {

View File

@@ -0,0 +1,447 @@
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';
import worker from 'xterm-addon-image/lib/xterm-addon-image-worker';
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): 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
public open(parent: HTMLElement) {
this.terminal = this.register(new Terminal(this.options.termOptions));
const { terminal, fitAddon, overlayAddon, register, sendData } = this;
window.term = terminal as TtydTerminal;
window.term.fit = () => {
this.fitAddon.fit();
};
terminal.loadAddon(fitAddon);
terminal.loadAddon(overlayAddon);
terminal.loadAddon(new WebLinksAddon());
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));
terminal.open(parent);
fitAddon.fit();
}
@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 || 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;
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');
// 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 = register(
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(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) {
const imageWorkerUrl = window.URL.createObjectURL(
new Blob([worker], { type: 'text/javascript' })
);
terminal.loadAddon(new ImageAddon(imageWorkerUrl));
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':
default:
break;
}
}
}

19412
src/html.h generated

File diff suppressed because it is too large Load Diff