mirror of
https://github.com/tsl0922/ttyd.git
synced 2026-01-05 10:24:22 +01:00
html: refactor terminal component
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
447
html/src/components/terminal/xterm/index.ts
Normal file
447
html/src/components/terminal/xterm/index.ts
Normal 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
19412
src/html.h
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user