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;
|
2025-12-08 10:13:30 +00:00
|
|
|
|
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 = "";
|
2025-12-08 10:13:30 +00:00
|
|
|
|
private attachCommandSent = false;
|
|
|
|
|
|
private attachCommandSentAt?: number;
|
2026-01-07 15:41:32 +08:00
|
|
|
|
private ptyOutputReceived = false;
|
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']);
|
2026-01-07 15:41:32 +08:00
|
|
|
|
this.ptyOutputReceived = false;
|
2025-09-02 21:03:35 +08:00
|
|
|
|
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() {
|
2026-01-04 22:30:17 +08:00
|
|
|
|
console.log('[webTerminal] WebSocket opened, containerStatus:', this.containerStatus, 'connectStatus:', this.connectStatus, 'attachCommandSent:', this.attachCommandSent);
|
2025-09-02 21:03:35 +08:00
|
|
|
|
|
|
|
|
|
|
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);
|
2026-01-04 22:30:17 +08:00
|
|
|
|
// 重新连接后,如果状态是5且未连接,重置连接状态以便重新发送连接命令
|
|
|
|
|
|
if (this.containerStatus === '5' && !this.connectStatus) {
|
|
|
|
|
|
console.log('[webTerminal] Reconnected, resetting attach command state');
|
|
|
|
|
|
this.attachCommandSent = false;
|
|
|
|
|
|
this.attachCommandSentAt = undefined;
|
|
|
|
|
|
}
|
2025-09-02 21:03:35 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
this.opened = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.doReconnect = this.reconnect;
|
|
|
|
|
|
this.initListeners();
|
|
|
|
|
|
terminal.focus();
|
2026-01-07 15:41:32 +08:00
|
|
|
|
this.tryExecuteAttachCommand();
|
2025-09-02 21:03:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@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');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-07 15:41:32 +08:00
|
|
|
|
@bind
|
|
|
|
|
|
private tryExecuteAttachCommand() {
|
|
|
|
|
|
console.log('[Xterm] tryExecuteAttachCommand called:', {
|
|
|
|
|
|
attachCommandSent: this.attachCommandSent,
|
|
|
|
|
|
connectStatus: this.connectStatus,
|
|
|
|
|
|
hasCommand: !!(this.postAttachCommand && this.postAttachCommand.length > 0),
|
|
|
|
|
|
socketReady: this.socket?.readyState === WebSocket.OPEN,
|
|
|
|
|
|
ptyOutputReceived: this.ptyOutputReceived
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (this.attachCommandSent || this.connectStatus) {
|
|
|
|
|
|
console.log('[Xterm] Skipping: command already sent or connected');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!this.postAttachCommand || this.postAttachCommand.length === 0) {
|
|
|
|
|
|
console.log('[Xterm] Skipping: no command available');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this.socket?.readyState !== WebSocket.OPEN) {
|
|
|
|
|
|
console.log('[Xterm] Skipping: WebSocket not ready, state:', this.socket?.readyState);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!this.ptyOutputReceived) {
|
|
|
|
|
|
console.log('[Xterm] Skipping: ttyd not ready yet (waiting for first output)');
|
|
|
|
|
|
return; // Wait for TTY readiness confirm via output
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const cmd = this.postAttachCommand[0];
|
|
|
|
|
|
if (cmd) {
|
|
|
|
|
|
console.log('[Xterm] ✅ All conditions met, executing attach command...');
|
|
|
|
|
|
this.sendData(cmd + "\n");
|
|
|
|
|
|
this.attachCommandSent = true;
|
|
|
|
|
|
this.attachCommandSentAt = Date.now();
|
|
|
|
|
|
console.log('[Xterm] Command sent at:', new Date(this.attachCommandSentAt).toISOString());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 22:30:17 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 获取 URL 查询参数
|
|
|
|
|
|
*/
|
|
|
|
|
|
private getUrlParams(): { options: URLSearchParams; params: URLSearchParams; baseUrl: string } {
|
2025-10-18 16:35:39 +08:00
|
|
|
|
const options = new URLSearchParams(decodeURIComponent(window.location.search));
|
|
|
|
|
|
const params = new URLSearchParams({
|
|
|
|
|
|
repo: options.get('repoid') as string,
|
|
|
|
|
|
user: options.get('userid') as string,
|
|
|
|
|
|
});
|
2026-01-04 22:30:17 +08:00
|
|
|
|
const baseUrl = `http://${options.get('domain')}:${options.get('port')}/${options.get('user')}/${options.get('repo')}`;
|
|
|
|
|
|
return { options, params, baseUrl };
|
|
|
|
|
|
}
|
2025-10-18 16:35:39 +08:00
|
|
|
|
|
2026-01-04 22:30:17 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 获取并执行连接容器的命令(带重试机制)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 重试机制:
|
|
|
|
|
|
* - 最多重试 5 次
|
|
|
|
|
|
* - 每次重试间隔递增(1s, 2s, 3s, 4s, 5s)
|
|
|
|
|
|
* - 如果成功获取命令,立即执行并停止重试
|
|
|
|
|
|
*/
|
|
|
|
|
|
@bind
|
|
|
|
|
|
public loadCommandOnce() {
|
|
|
|
|
|
this.loadCommandWithRetry(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 带重试的命令获取
|
|
|
|
|
|
* @param retryCount 当前重试次数
|
|
|
|
|
|
*/
|
|
|
|
|
|
@bind
|
|
|
|
|
|
private loadCommandWithRetry(retryCount: number = 0) {
|
|
|
|
|
|
const maxRetries = 5;
|
|
|
|
|
|
const { params, baseUrl } = this.getUrlParams();
|
|
|
|
|
|
|
|
|
|
|
|
fetch(`${baseUrl}/devcontainer/command?${params}`)
|
|
|
|
|
|
.then(response => {
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
return response.json();
|
|
|
|
|
|
})
|
2025-10-18 16:35:39 +08:00
|
|
|
|
.then(data => {
|
2026-01-04 22:30:17 +08:00
|
|
|
|
// 验证数据有效性
|
|
|
|
|
|
if (!data || !data.command) {
|
|
|
|
|
|
throw new Error('Invalid command data received');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-19 01:13:49 +08:00
|
|
|
|
if (this.workdir === ''){
|
|
|
|
|
|
this.workdir = data.workdir;
|
|
|
|
|
|
}
|
2026-01-04 22:30:17 +08:00
|
|
|
|
|
|
|
|
|
|
// 执行连接容器的命令(只执行一次)
|
2026-01-07 15:41:32 +08:00
|
|
|
|
this.postAttachCommand = data.command.split('\n');
|
|
|
|
|
|
this.tryExecuteAttachCommand();
|
2025-10-18 16:35:39 +08:00
|
|
|
|
})
|
|
|
|
|
|
.catch(error => {
|
2026-01-04 22:30:17 +08:00
|
|
|
|
console.error(`[Xterm] Error loading command (attempt ${retryCount + 1}/${maxRetries}):`, error);
|
|
|
|
|
|
|
|
|
|
|
|
// 如果还有重试次数,继续重试
|
|
|
|
|
|
if (retryCount < maxRetries - 1) {
|
|
|
|
|
|
const delay = (retryCount + 1) * 1000; // 递增延迟:1s, 2s, 3s, 4s, 5s
|
|
|
|
|
|
console.log(`[Xterm] Retrying command load in ${delay}ms...`);
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
this.loadCommandWithRetry(retryCount + 1);
|
|
|
|
|
|
}, delay);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.error('[Xterm] Failed to load command after all retries');
|
|
|
|
|
|
// 可以在这里显示错误提示给用户
|
|
|
|
|
|
}
|
2025-10-18 16:35:39 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-10-29 23:09:03 +08:00
|
|
|
|
@bind
|
|
|
|
|
|
public changeContainerStatus(v: string){
|
|
|
|
|
|
this.containerStatus = v;
|
|
|
|
|
|
}
|
2026-01-04 22:30:17 +08:00
|
|
|
|
|
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:
|
2026-01-07 15:41:32 +08:00
|
|
|
|
if (!this.ptyOutputReceived) {
|
|
|
|
|
|
this.ptyOutputReceived = true;
|
|
|
|
|
|
console.log('[Xterm] ✅ ttyd is now ready (received first output), attempting to execute attach command');
|
|
|
|
|
|
this.tryExecuteAttachCommand();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-08 10:13:30 +00:00
|
|
|
|
const decodedData = textDecoder.decode(data);
|
|
|
|
|
|
console.log('[ttyd] output:', decodedData);
|
|
|
|
|
|
const compactOutput = decodedData.replace(/\s/g, '');
|
2026-01-04 22:30:17 +08:00
|
|
|
|
const { options } = this.getUrlParams();
|
2025-10-18 16:35:39 +08:00
|
|
|
|
if (options.get('type') === 'docker') {
|
2025-10-19 01:13:49 +08:00
|
|
|
|
// 保存host的标题
|
2026-01-07 15:41:32 +08:00
|
|
|
|
const pureContent = decodedData.replace(/\u001B\[[0-9;?]*[ -\/]*[@-~]/g, '').replace(/\u0007/g, '').trim();
|
|
|
|
|
|
if (this.hostTitle === '' && pureContent.length > 0){
|
2025-12-08 10:13:30 +00:00
|
|
|
|
this.hostTitle = compactOutput;
|
2026-01-07 15:41:32 +08:00
|
|
|
|
console.log('[Xterm] Host title captured:', this.hostTitle);
|
2025-10-19 01:13:49 +08:00
|
|
|
|
}
|
2026-01-07 15:41:32 +08:00
|
|
|
|
|
2025-10-19 01:13:49 +08:00
|
|
|
|
// 检测是否退出devcontainer,标题等于host的标题
|
2026-01-07 15:41:32 +08:00
|
|
|
|
if (this.connectStatus && this.hostTitle && compactOutput.includes(this.hostTitle)){
|
|
|
|
|
|
console.log('[Xterm] Detected exit to host shell');
|
2025-12-08 10:13:30 +00:00
|
|
|
|
this.connectStatus = false;
|
|
|
|
|
|
this.connectionMessageBuffer = '';
|
|
|
|
|
|
this.attachCommandSent = false;
|
|
|
|
|
|
this.attachCommandSentAt = undefined;
|
|
|
|
|
|
this.postAttachCommandStatus = false;
|
2026-01-07 15:41:32 +08:00
|
|
|
|
this.ptyOutputReceived = false; // 重置 PTY 状态
|
2025-10-19 01:13:49 +08:00
|
|
|
|
}
|
2026-01-07 15:41:32 +08:00
|
|
|
|
|
|
|
|
|
|
if (this.connectStatus) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
this.writeFunc(data);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('[Xterm] writeFunc error:', e);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 未连接状态:缓冲所有输出
|
|
|
|
|
|
this.connectionMessageBuffer += decodedData;
|
|
|
|
|
|
|
|
|
|
|
|
const successMarker = 'Successfully connected to the devcontainer';
|
|
|
|
|
|
// 尝试在 buffer 中查找成功标记
|
|
|
|
|
|
const markerIndex = this.connectionMessageBuffer.indexOf(successMarker);
|
|
|
|
|
|
|
|
|
|
|
|
if (markerIndex !== -1) {
|
|
|
|
|
|
console.log('[Xterm] Connection established, flushing buffer.');
|
2025-12-08 10:13:30 +00:00
|
|
|
|
this.connectStatus = true;
|
2026-01-04 22:30:17 +08:00
|
|
|
|
this.terminal.options.disableStdin = false;
|
2026-01-07 15:41:32 +08:00
|
|
|
|
|
|
|
|
|
|
const validOutput = this.connectionMessageBuffer.substring(markerIndex);
|
|
|
|
|
|
this.writeData(validOutput);
|
|
|
|
|
|
this.connectionMessageBuffer = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this.connectionMessageBuffer.length > 20000) {
|
|
|
|
|
|
console.warn('[Xterm] Buffer overflow protection. Flushing all.');
|
|
|
|
|
|
this.writeData(this.connectionMessageBuffer);
|
|
|
|
|
|
this.connectionMessageBuffer = '';
|
2025-12-08 10:13:30 +00:00
|
|
|
|
}
|
2025-10-18 16:35:39 +08:00
|
|
|
|
}
|
2026-01-07 15:41:32 +08:00
|
|
|
|
|
2025-10-29 23:09:03 +08:00
|
|
|
|
// 连接完成且出现容器的标题,且没有执行过postAttach命令
|
2025-12-08 10:13:30 +00:00
|
|
|
|
if (this.connectStatus && compactOutput.includes(this.workdir) && !this.postAttachCommandStatus){
|
2026-01-04 22:30:17 +08:00
|
|
|
|
console.log('[Xterm] Detected workdir in output, executing postAttachCommand');
|
2025-10-19 01:13:49 +08:00
|
|
|
|
for (let i = 1; i < this.postAttachCommand.length; i++){
|
2025-12-08 10:13:30 +00:00
|
|
|
|
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:
|
|
|
|
|
|
this.title = textDecoder.decode(data);
|
|
|
|
|
|
document.title = this.title;
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Command.SET_PREFERENCES:
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-08 10:13:30 +00:00
|
|
|
|
|
|
|
|
|
|
private stripAnsi(input: string): string {
|
|
|
|
|
|
return input.replace(/\u001B\[[0-9;?]*[ -\/]*[@-~]/g, '').replace(/\u0007/g, '');
|
|
|
|
|
|
}
|
2025-09-02 21:03:35 +08:00
|
|
|
|
}
|