Files
webTerminal/html/src/components/terminal/xterm/index.ts

686 lines
25 KiB
TypeScript
Raw Normal View History

2025-09-02 21:03:35 +08:00
import { bind } from 'decko';
import type { IDisposable, ITerminalOptions } from '@xterm/xterm';
import { Terminal } from '@xterm/xterm';
import { CanvasAddon } from '@xterm/addon-canvas';
import { ClipboardAddon } from '@xterm/addon-clipboard';
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 { Unicode11Addon } from '@xterm/addon-unicode11';
import { OverlayAddon } from './addons/overlay';
import { ZmodemAddon } from './addons/zmodem';
import '@xterm/xterm/css/xterm.css';
interface TtydTerminal extends Terminal {
fit(): void;
}
declare global {
interface Window {
term: TtydTerminal;
}
}
enum Command {
// server side
OUTPUT = '0',
SET_WINDOW_TITLE = '1',
SET_PREFERENCES = '2',
// client side
INPUT = '0',
RESIZE_TERMINAL = '1',
PAUSE = '2',
RESUME = '3',
}
type Preferences = ITerminalOptions & ClientOptions;
export type RendererType = 'dom' | 'canvas' | 'webgl';
export interface ClientOptions {
rendererType: RendererType;
disableLeaveAlert: boolean;
disableResizeOverlay: boolean;
enableZmodem: boolean;
enableTrzsz: boolean;
enableSixel: boolean;
titleFixed?: string;
isWindows: boolean;
trzszDragInitTimeout: number;
unicodeVersion: string;
closeOnDisconnect: boolean;
}
export interface FlowControl {
limit: number;
highWater: number;
lowWater: number;
}
export interface XtermOptions {
wsUrl: string;
tokenUrl: string;
flowControl: FlowControl;
clientOptions: ClientOptions;
termOptions: ITerminalOptions;
}
function toDisposable(f: () => void): IDisposable {
return { dispose: f };
}
function addEventListener(target: EventTarget, type: string, listener: EventListener): IDisposable {
target.addEventListener(type, listener);
return toDisposable(() => target.removeEventListener(type, listener));
}
export class Xterm {
private disposables: IDisposable[] = [];
private textEncoder = new TextEncoder();
private textDecoder = new TextDecoder();
private written = 0;
private pending = 0;
private terminal: Terminal;
private fitAddon = new FitAddon();
private overlayAddon = new OverlayAddon();
private clipboardAddon = new ClipboardAddon();
private webLinksAddon = new WebLinksAddon();
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 closeOnDisconnect = false;
2025-10-18 16:35:39 +08:00
private intervalID: NodeJS.Timeout;
2025-09-02 21:03:35 +08:00
private writeFunc = (data: ArrayBuffer) => this.writeData(new Uint8Array(data));
2025-10-29 08:41:29 +08:00
private connectStatus = false;
private connectionMessageBuffer = "";
2025-10-19 01:13:49 +08:00
private hostTitle = "";
private postAttachCommand = [];
private postAttachCommandStatus = false;
private workdir = "";
2025-10-29 08:41:29 +08:00
private containerStatus = "";
private attachCommandSent = false;
private attachCommandSentAt?: number;
2025-10-18 16:35:39 +08:00
private beforeCommand?: string;
2025-09-02 21:03:35 +08:00
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, clipboardAddon, webLinksAddon } = this;
window.term = terminal as TtydTerminal;
window.term.fit = () => {
this.fitAddon.fit();
};
terminal.loadAddon(fitAddon);
terminal.loadAddon(overlayAddon);
terminal.loadAddon(clipboardAddon);
terminal.loadAddon(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;
}
})
);
2025-10-18 16:35:39 +08:00
register(
terminal.onData(data =>
{
2025-10-29 08:41:29 +08:00
if (this.connectStatus) {
2025-10-18 16:35:39 +08:00
sendData(data);
} else {
this.writeData('\b \b');
}
})
);
2025-09-02 21:03:35 +08:00
register(terminal.onBinary(data => sendData(Uint8Array.from(data, v => v.charCodeAt(0)))));
register(
terminal.onResize(({ cols, rows }) => {
const msg = JSON.stringify({ columns: cols, rows: rows });
this.socket?.send(this.textEncoder.encode(Command.RESIZE_TERMINAL + msg));
if (this.resizeOverlay) overlayAddon.showOverlay(`${cols}x${rows}`, 300);
})
);
register(
terminal.onSelectionChange(() => {
if (this.terminal.getSelection() === '') return;
try {
document.execCommand('copy');
} catch (e) {
return;
}
this.overlayAddon?.showOverlay('\u2702', 200);
})
);
register(addEventListener(window, 'resize', () => fitAddon.fit()));
register(addEventListener(window, 'beforeunload', this.onWindowUnload));
}
@bind
public writeData(data: string | Uint8Array) {
const { terminal, textEncoder } = this;
const { limit, highWater, lowWater } = this.options.flowControl;
this.written += data.length;
if (this.written > limit) {
terminal.write(data, () => {
this.pending = Math.max(this.pending - 1, 0);
if (this.pending < lowWater) {
this.socket?.send(textEncoder.encode(Command.RESUME));
}
});
this.pending++;
this.written = 0;
if (this.pending > highWater) {
this.socket?.send(textEncoder.encode(Command.PAUSE));
}
} else {
terminal.write(data);
}
}
@bind
public sendData(data: string | Uint8Array) {
const { socket, textEncoder } = this;
if (socket?.readyState !== WebSocket.OPEN) return;
if (typeof data === 'string') {
const payload = new Uint8Array(data.length * 3 + 1);
payload[0] = Command.INPUT.charCodeAt(0);
const stats = textEncoder.encodeInto(data, payload.subarray(1));
socket.send(payload.subarray(0, (stats.written as number) + 1));
} else {
const payload = new Uint8Array(data.length + 1);
payload[0] = Command.INPUT.charCodeAt(0);
payload.set(data, 1);
socket.send(payload);
}
}
2025-10-18 16:35:39 +08:00
@bind
public changeUrl(ip: string, port: string) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
this.options.wsUrl = [protocol, '//' + ip + ':' + port +'/ws', window.location.search].join('');
this.options.tokenUrl = [window.location.protocol, '//' + ip + ':' + port +'/token'].join('');
}
@bind
public changeStatus(v: boolean){
2025-10-29 08:41:29 +08:00
this.connectStatus = v;
2025-10-18 16:35:39 +08:00
}
2025-09-02 21:03:35 +08:00
@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)));
2025-10-18 16:35:39 +08:00
const options = new URLSearchParams(decodeURIComponent(window.location.search));
if (options.get('type') === 'docker') {
2025-10-29 23:09:03 +08:00
if(this.containerStatus === '4' || this.containerStatus === '-1'){
this.intervalID = setInterval(this.loadCommand, 1000);
}else{
this.intervalID = setInterval(this.loadCommand, 8000);
}
2025-10-18 16:35:39 +08:00
}
2025-09-02 21:03:35 +08:00
}
@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 if (this.closeOnDisconnect) {
window.close();
} 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');
}
}
2025-10-18 16:35:39 +08:00
@bind
private loadCommand() {
const options = new URLSearchParams(decodeURIComponent(window.location.search));
const params = new URLSearchParams({
repo: options.get('repoid') as string,
user: options.get('userid') as string,
});
fetch(
'http://' + options.get('domain') + ':'+ options.get('port') +'/' +
options.get('user') +
'/' +
options.get('repo') +
'/devcontainer/command?' +
params
)
.then(response => response.json())
.then(data => {
2025-10-19 01:13:49 +08:00
if (this.workdir === ''){
this.workdir = data.workdir;
}
2025-10-18 16:35:39 +08:00
if (data.status !== '4' && data.status !== '0') {
2025-10-29 08:41:29 +08:00
if(this.containerStatus !== data.status){
this.sendData(data.command);
}
this.containerStatus = data.status;
2025-10-18 16:35:39 +08:00
} else {
if (this.containerStatus !== '4'){
this.writeData("\x1b[31mCreation completed.\x1b[0m\r\n");
2025-10-30 22:26:10 +08:00
}
2025-10-29 23:09:03 +08:00
this.containerStatus = data.status;
2025-10-18 16:35:39 +08:00
if (data.status === '4') {
2025-10-19 01:13:49 +08:00
const parts = data.command.split('\n');
const shouldResend = this.attachCommandSent && this.attachCommandSentAt !== undefined && Date.now() - this.attachCommandSentAt > 5000;
if ((!this.attachCommandSent || shouldResend) && !this.connectStatus && parts[0]) {
this.sendData(parts[0]+"\n");
this.attachCommandSent = true;
this.attachCommandSentAt = Date.now();
}
2025-10-19 01:13:49 +08:00
this.postAttachCommand = parts;
2025-10-18 16:35:39 +08:00
}
}
})
.catch(error => {
console.error('Error:', error);
});
}
2025-10-29 23:09:03 +08:00
@bind
public changeContainerStatus(v: string){
this.containerStatus = v;
}
2025-09-02 21:03:35 +08:00
@bind
private parseOptsFromUrlQuery(query: string): Preferences {
const { terminal } = this;
const { clientOptions } = this.options;
const prefs = {} as Preferences;
const queryObj = Array.from(new URLSearchParams(query) as unknown as Iterable<[string, string]>);
for (const [k, queryVal] of queryObj) {
let v = clientOptions[k];
if (v === undefined) v = terminal.options[k];
switch (typeof v) {
case 'boolean':
prefs[k] = queryVal === 'true' || queryVal === '1';
break;
case 'number':
case 'bigint':
prefs[k] = Number.parseInt(queryVal, 10);
break;
case 'string':
prefs[k] = queryVal;
break;
case 'object':
prefs[k] = JSON.parse(queryVal);
break;
default:
console.warn(`[ttyd] maybe unknown option: ${k}=${queryVal}, treating as string`);
prefs[k] = queryVal;
break;
}
}
return prefs;
}
@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:
const decodedData = textDecoder.decode(data);
console.log('[ttyd] output:', decodedData);
const compactOutput = decodedData.replace(/\s/g, '');
2025-10-18 16:35:39 +08:00
const options = new URLSearchParams(decodeURIComponent(window.location.search));
2025-10-22 11:59:58 +08:00
const params = new URLSearchParams({
repo: options.get('repoid') as string,
user: options.get('userid') as string,
});
2025-10-18 16:35:39 +08:00
if (options.get('type') === 'docker') {
2025-10-19 01:13:49 +08:00
// 保存host的标题
if (this.hostTitle === ''){
this.hostTitle = compactOutput;
2025-10-19 01:13:49 +08:00
}
// 检测是否退出devcontainer标题等于host的标题
if (this.connectStatus && compactOutput.includes(this.hostTitle)){
this.connectStatus = false;
this.connectionMessageBuffer = '';
this.attachCommandSent = false;
this.attachCommandSentAt = undefined;
this.postAttachCommandStatus = false;
2025-10-19 01:13:49 +08:00
}
2025-10-29 08:41:29 +08:00
// this.connectStatus = true 连接完成
//由于第二条docker命令中包含Successfully connected to the devcontainer,需要过滤否则会导致轮询终止卡在状态2
if (!this.connectStatus) {
const sanitizedOutput = this.stripAnsi(decodedData).replace(/\r/g, '\n');
const combinedOutput = this.connectionMessageBuffer + sanitizedOutput;
const segments = combinedOutput.split(/\n/);
this.connectionMessageBuffer = segments.pop() ?? '';
const hasSuccessLine = segments.some(line => line.trim() === 'Successfully connected to the devcontainer');
if (hasSuccessLine) {
this.connectStatus = true;
this.connectionMessageBuffer = '';
this.attachCommandSentAt = undefined;
if (this.intervalID) {
clearInterval(this.intervalID);
}
}
2025-10-18 16:35:39 +08:00
}
2025-10-19 01:13:49 +08:00
// 连接完成之前不输出标题和docker命令
2025-10-18 16:35:39 +08:00
if (
2025-10-30 22:26:10 +08:00
!(this.connectStatus === false &&
(textDecoder.decode(data).includes('\x1b') ||
textDecoder.decode(data).replace(/\s/g, '').includes('docker-H')))
2025-10-18 16:35:39 +08:00
){
this.writeFunc(data);
}
2025-10-29 23:09:03 +08:00
// 连接完成且出现容器的标题且没有执行过postAttach命令
if (this.connectStatus && compactOutput.includes(this.workdir) && !this.postAttachCommandStatus){
2025-10-19 01:13:49 +08:00
for (let i = 1; i < this.postAttachCommand.length; i++){
this.sendData(this.postAttachCommand[i]+'\n');
2025-10-19 01:13:49 +08:00
}
this.postAttachCommandStatus = true;
2025-10-18 16:35:39 +08:00
}
2025-10-22 11:59:58 +08:00
2025-10-18 16:35:39 +08:00
} else {
this.writeFunc(data);
}
2025-09-02 21:03:35 +08:00
break;
case Command.SET_WINDOW_TITLE:
2025-10-18 16:35:39 +08:00
console.log('SET_WINDOW_TITLESET_WINDOW_TITLE');
2025-09-02 21:03:35 +08:00
this.title = textDecoder.decode(data);
document.title = this.title;
break;
case Command.SET_PREFERENCES:
2025-10-18 16:35:39 +08:00
console.log('SET_PREFERENCESSET_PREFERENCESSET_PREFERENCES');
2025-09-02 21:03:35 +08:00
this.applyPreferences({
...this.options.clientOptions,
...JSON.parse(textDecoder.decode(data)),
...this.parseOptsFromUrlQuery(window.location.search),
} as Preferences);
break;
default:
console.warn(`[ttyd] unknown command: ${cmd}`);
break;
}
}
@bind
private applyPreferences(prefs: Preferences) {
const { terminal, fitAddon, register } = this;
if (prefs.enableZmodem || prefs.enableTrzsz) {
this.zmodemAddon = new ZmodemAddon({
zmodem: prefs.enableZmodem,
trzsz: prefs.enableTrzsz,
windows: prefs.isWindows,
trzszDragInitTimeout: prefs.trzszDragInitTimeout,
onSend: this.sendCb,
sender: this.sendData,
writer: this.writeData,
});
this.writeFunc = data => this.zmodemAddon?.consume(data);
terminal.loadAddon(register(this.zmodemAddon));
}
for (const [key, value] of Object.entries(prefs)) {
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 'trzszDragInitTimeout':
if (value) console.log(`[ttyd] trzsz drag init timeout: ${value}`);
break;
case 'enableSixel':
if (value) {
terminal.loadAddon(register(new ImageAddon()));
console.log('[ttyd] Sixel enabled');
}
break;
case 'closeOnDisconnect':
if (value) {
console.log('[ttyd] close on disconnect enabled (Reconnect disabled)');
this.closeOnDisconnect = true;
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;
case 'isWindows':
if (value) console.log('[ttyd] is windows');
break;
case 'unicodeVersion':
switch (value) {
case 6:
case '6':
console.log('[ttyd] setting Unicode version: 6');
break;
case 11:
case '11':
default:
console.log('[ttyd] setting Unicode version: 11');
terminal.loadAddon(new Unicode11Addon());
terminal.unicode.activeVersion = '11';
break;
}
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;
}
}
private stripAnsi(input: string): string {
return input.replace(/\u001B\[[0-9;?]*[ -\/]*[@-~]/g, '').replace(/\u0007/g, '');
}
2025-09-02 21:03:35 +08:00
}