convert wetty from submodule to normal directory

This commit is contained in:
douboer@gmail.com
2026-03-03 16:07:18 +08:00
parent 1db76701a6
commit 0d185d2b3c
131 changed files with 15543 additions and 1 deletions

5
wetty/src/client/dev.ts Normal file
View File

@@ -0,0 +1,5 @@
caches.keys().then(cacheNames => {
cacheNames.forEach(cacheName => {
caches.delete(cacheName);
});
});

72
wetty/src/client/wetty.ts Normal file
View File

@@ -0,0 +1,72 @@
import { dom, library } from '@fortawesome/fontawesome-svg-core';
import { faCogs, faKeyboard } from '@fortawesome/free-solid-svg-icons';
import _ from 'lodash';
import '../assets/scss/styles.scss';
import { disconnect } from './wetty/disconnect';
import { overlay } from './wetty/disconnect/elements';
import { verifyPrompt } from './wetty/disconnect/verify';
import { FileDownloader } from './wetty/download';
import { mobileKeyboard } from './wetty/mobile';
import { socket } from './wetty/socket';
import { terminal, Term } from './wetty/term';
// Setup for fontawesome
library.add(faCogs);
library.add(faKeyboard);
dom.watch();
function onResize(term: Term): () => void {
return function resize() {
term.resizeTerm();
};
}
const term = terminal(socket);
if (_.isUndefined(term)) {
disconnect('终端初始化失败:未找到 #terminal 容器');
} else {
window.addEventListener('beforeunload', verifyPrompt, false);
window.addEventListener('resize', onResize(term), false);
term.resizeTerm();
term.focus();
mobileKeyboard();
const fileDownloader = new FileDownloader();
socket
.on('connect', () => {
if (!_.isNull(overlay)) overlay.style.display = 'none';
})
.on('data', (data: string) => {
const remainingData = fileDownloader.buffer(data);
if (remainingData) {
term.write(remainingData);
}
})
.on('login', () => {
if (!_.isNull(overlay)) overlay.style.display = 'none';
term.writeln('');
term.resizeTerm();
term.focus();
})
.on('logout', disconnect)
.on('disconnect', disconnect)
.on('error', (err: string | null) => {
if (err) disconnect(err);
});
term.onData((data: string) => {
socket.emit('input', data);
});
term.onResize((size: { cols: number; rows: number }) => {
socket.emit('resize', size);
});
socket.connect().catch((error: unknown) => {
const message =
error instanceof Error ? error.message : '连接网关失败,请检查配置';
disconnect(message);
});
}

View File

@@ -0,0 +1,11 @@
import _ from 'lodash';
import { overlay } from './disconnect/elements';
import { verifyPrompt } from './disconnect/verify';
export function disconnect(reason: string): void {
if (_.isNull(overlay)) return;
overlay.style.display = 'block';
const msg = document.getElementById('msg');
if (!_.isUndefined(reason) && !_.isNull(msg)) msg.innerHTML = reason;
window.removeEventListener('beforeunload', verifyPrompt, false);
}

View File

@@ -0,0 +1,5 @@
export const overlay = document.getElementById('overlay');
export const terminal = document.getElementById('terminal');
export const editor = document.querySelector(
'#options .editor',
) as HTMLIFrameElement;

View File

@@ -0,0 +1,4 @@
export function verifyPrompt(e: { returnValue: string }): string {
e.returnValue = 'Are you sure?';
return e.returnValue;
}

View File

@@ -0,0 +1,236 @@
import { expect } from 'chai';
import 'mocha';
import { JSDOM } from 'jsdom';
import * as sinon from 'sinon';
import { FileDownloader } from './download';
const noop = (): void => {}; // eslint-disable-line @typescript-eslint/no-empty-function
describe('FileDownloader', () => {
const FILE_BEGIN = 'BEGIN';
const FILE_END = 'END';
let fileDownloader: FileDownloader;
beforeEach(() => {
const { window } = new JSDOM(`...`);
global.document = window.document;
fileDownloader = new FileDownloader(noop, FILE_BEGIN, FILE_END);
});
afterEach(() => {
sinon.restore();
});
it('should return data before file markers', () => {
const onCompleteFileCallbackStub = sinon.stub(
fileDownloader,
'onCompleteFileCallback',
);
expect(
fileDownloader.buffer(`DATA AT THE LEFT${FILE_BEGIN}FILE${FILE_END}`),
).to.equal('DATA AT THE LEFT');
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
});
it('should return data after file markers', () => {
const onCompleteFileCallbackStub = sinon.stub(
fileDownloader,
'onCompleteFileCallback',
);
expect(
fileDownloader.buffer(`${FILE_BEGIN}FILE${FILE_END}DATA AT THE RIGHT`),
).to.equal('DATA AT THE RIGHT');
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
});
it('should return data before and after file markers', () => {
const onCompleteFileCallbackStub = sinon.stub(
fileDownloader,
'onCompleteFileCallback',
);
expect(
fileDownloader.buffer(
`DATA AT THE LEFT${FILE_BEGIN}FILE${FILE_END}DATA AT THE RIGHT`,
),
).to.equal('DATA AT THE LEFTDATA AT THE RIGHT');
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
});
it('should return data before a beginning marker found', () => {
sinon.stub(fileDownloader, 'onCompleteFileCallback');
expect(fileDownloader.buffer(`DATA AT THE LEFT${FILE_BEGIN}FILE`)).to.equal(
'DATA AT THE LEFT',
);
});
it('should return data after an ending marker found', () => {
const onCompleteFileCallbackStub = sinon.stub(
fileDownloader,
'onCompleteFileCallback',
);
expect(fileDownloader.buffer(`${FILE_BEGIN}FI`)).to.equal('');
expect(fileDownloader.buffer(`LE${FILE_END}DATA AT THE RIGHT`)).to.equal(
'DATA AT THE RIGHT',
);
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
});
it('should buffer across incomplete file begin marker sequence on two calls', () => {
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
const onCompleteFileCallbackStub = sinon.stub(
fileDownloader,
'onCompleteFileCallback',
);
expect(fileDownloader.buffer('BEG')).to.equal('');
expect(fileDownloader.buffer('INFILEEND')).to.equal('');
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
});
it('should buffer across incomplete file begin marker sequence on n calls', () => {
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
const onCompleteFileCallbackStub = sinon.stub(
fileDownloader,
'onCompleteFileCallback',
);
expect(fileDownloader.buffer('B')).to.equal('');
expect(fileDownloader.buffer('E')).to.equal('');
expect(fileDownloader.buffer('G')).to.equal('');
expect(fileDownloader.buffer('I')).to.equal('');
expect(fileDownloader.buffer('NFILEEND')).to.equal('');
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
});
it('should buffer across incomplete file begin marker sequence with data on the left and right on multiple calls', () => {
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
const onCompleteFileCallbackStub = sinon.stub(
fileDownloader,
'onCompleteFileCallback',
);
expect(fileDownloader.buffer('DATA AT THE LEFTB')).to.equal(
'DATA AT THE LEFT',
);
expect(fileDownloader.buffer('E')).to.equal('');
expect(fileDownloader.buffer('G')).to.equal('');
expect(fileDownloader.buffer('I')).to.equal('');
expect(fileDownloader.buffer('NFILEENDDATA AT THE RIGHT')).to.equal(
'DATA AT THE RIGHT',
);
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
});
it('should buffer across incomplete file begin marker sequence then handle false positive', () => {
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
const onCompleteFileCallbackStub = sinon.stub(
fileDownloader,
'onCompleteFileCallback',
);
expect(fileDownloader.buffer('DATA AT THE LEFTB')).to.equal(
'DATA AT THE LEFT',
);
expect(fileDownloader.buffer('E')).to.equal('');
expect(fileDownloader.buffer('G')).to.equal('');
// This isn't part of the file_begin marker and should trigger the partial
// file begin marker to be returned with the normal data
expect(fileDownloader.buffer('ZDATA AT THE RIGHT')).to.equal(
'BEGZDATA AT THE RIGHT',
);
expect(onCompleteFileCallbackStub.called).to.be.false;
});
it('should buffer across incomplete file end marker sequence on two calls', () => {
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
const mockFilePart1 = 'DATA AT THE LEFTBEGINFILEE';
const mockFilePart2 = 'NDDATA AT THE RIGHT';
const onCompleteFileCallbackStub = sinon.stub(
fileDownloader,
'onCompleteFileCallback',
);
expect(fileDownloader.buffer(mockFilePart1)).to.equal('DATA AT THE LEFT');
expect(fileDownloader.buffer(mockFilePart2)).to.equal('DATA AT THE RIGHT');
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
});
it('should buffer across incomplete file end and file begin marker sequence with data on the left and right on multiple calls', () => {
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
const onCompleteFileCallbackStub = sinon.stub(
fileDownloader,
'onCompleteFileCallback',
);
expect(fileDownloader.buffer('DATA AT THE LEFTBE')).to.equal(
'DATA AT THE LEFT',
);
expect(fileDownloader.buffer('G')).to.equal('');
expect(fileDownloader.buffer('I')).to.equal('');
expect(fileDownloader.buffer('NFILEE')).to.equal('');
expect(fileDownloader.buffer('N')).to.equal('');
expect(fileDownloader.buffer('DDATA AT THE RIGHT')).to.equal(
'DATA AT THE RIGHT',
);
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
});
it('should be able to handle multiple files', () => {
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
const onCompleteFileCallbackStub = sinon.stub(
fileDownloader,
'onCompleteFileCallback',
);
expect(
fileDownloader.buffer(
'DATA AT THE LEFT' +
'BEGIN' +
'FILE1' +
'END' +
'SECOND DATA' +
'BEGIN',
),
).to.equal('DATA AT THE LEFTSECOND DATA');
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE1');
expect(fileDownloader.buffer('FILE2')).to.equal('');
expect(fileDownloader.buffer('E')).to.equal('');
expect(fileDownloader.buffer('NDRIGHT')).to.equal('RIGHT');
expect(onCompleteFileCallbackStub.calledTwice).to.be.true;
expect(onCompleteFileCallbackStub.getCall(1).args[0]).to.equal('FILE2');
});
it('should be able to handle multiple files with an ending marker', () => {
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
const onCompleteFileCallbackStub = sinon.stub(
fileDownloader,
'onCompleteFileCallback',
);
expect(fileDownloader.buffer('DATA AT THE LEFTBEGINFILE1EN')).to.equal(
'DATA AT THE LEFT',
);
expect(onCompleteFileCallbackStub.calledOnce).to.be.false;
expect(fileDownloader.buffer('DSECOND DATABEGINFILE2EN')).to.equal(
'SECOND DATA',
);
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE1');
expect(fileDownloader.buffer('D')).to.equal('');
expect(onCompleteFileCallbackStub.calledTwice).to.be.true;
expect(onCompleteFileCallbackStub.getCall(1).args[0]).to.equal('FILE2');
});
});

View File

@@ -0,0 +1,169 @@
import fileType from 'file-type';
import Toastify from 'toastify-js';
const DEFAULT_FILE_BEGIN = '\u001b[5i';
const DEFAULT_FILE_END = '\u001b[4i';
type OnCompleteFile = (bufferCharacters: string) => void;
function onCompleteFile(bufferCharacters: string): void {
let fileNameBase64;
let fileCharacters = bufferCharacters;
if (bufferCharacters.includes(":")) {
[fileNameBase64, fileCharacters] = bufferCharacters.split(":");
}
// Try to decode it as base64, if it fails we assume it's not base64
try {
fileCharacters = window.atob(fileCharacters);
} catch (err) {
// Assuming it's not base64...
}
const bytes = new Uint8Array(fileCharacters.length);
for (let i = 0; i < fileCharacters.length; i += 1) {
bytes[i] = fileCharacters.charCodeAt(i);
}
let mimeType = 'application/octet-stream';
let fileExt = '';
const typeData = fileType(bytes);
if (typeData) {
mimeType = typeData.mime;
fileExt = typeData.ext;
}
// Check if the buffer is ASCII
// Ref: https://stackoverflow.com/a/14313213
// eslint-disable-next-line no-control-regex
else if (/^[\x00-\xFF]*$/.test(fileCharacters)) {
mimeType = 'text/plain';
fileExt = 'txt';
}
let fileName;
try {
if (fileNameBase64 !== undefined) {
fileName = window.atob(fileNameBase64);
}
} catch (err) {
// Filename wasn't base64-encoded so let's ignore it
}
if (fileName === undefined) {
fileName = `file-${new Date()
.toISOString()
.split('.')[0]
.replace(/-/g, '')
.replace('T', '')
.replace(/:/g, '')}${fileExt ? `.${fileExt}` : ''}`;
}
const blob = new Blob([new Uint8Array(bytes.buffer)], {
type: mimeType,
});
const blobUrl = URL.createObjectURL(blob);
Toastify({
text: `Download ready: <a href="${blobUrl}" target="_blank" download="${fileName}">${fileName}</a>`,
duration: 10000,
newWindow: true,
gravity: 'bottom',
position: 'right',
backgroundColor: '#fff',
stopOnFocus: true,
escapeMarkup: false,
}).showToast();
}
export class FileDownloader {
fileBuffer: string[];
fileBegin: string;
fileEnd: string;
partialFileBegin: string;
onCompleteFileCallback: OnCompleteFile;
constructor(
onCompleteFileCallback: OnCompleteFile = onCompleteFile,
fileBegin: string = DEFAULT_FILE_BEGIN,
fileEnd: string = DEFAULT_FILE_END,
) {
this.fileBuffer = [];
this.fileBegin = fileBegin;
this.fileEnd = fileEnd;
this.partialFileBegin = '';
this.onCompleteFileCallback = onCompleteFileCallback;
}
bufferCharacter(character: string): string {
// If we are not currently buffering a file.
if (this.fileBuffer.length === 0) {
// If we are not currently expecting the rest of the fileBegin sequences.
if (this.partialFileBegin.length === 0) {
// If the character is the first character of fileBegin we know to start
// expecting the rest of the fileBegin sequence.
if (character === this.fileBegin[0]) {
this.partialFileBegin = character;
return '';
}
// Otherwise, we just return the character for printing to the terminal.
return character;
}
// We're currently in the state of buffering a beginner marker...
const nextExpectedCharacter =
this.fileBegin[this.partialFileBegin.length];
// If the next character *is* the next character in the fileBegin sequence.
if (character === nextExpectedCharacter) {
this.partialFileBegin += character;
// Do we now have the complete fileBegin sequence.
if (this.partialFileBegin === this.fileBegin) {
this.partialFileBegin = '';
this.fileBuffer = this.fileBuffer.concat(this.fileBegin.split(''));
return '';
}
// Otherwise, we just wait until the next character.
return '';
}
// If the next expected character wasn't found for the fileBegin sequence,
// we need to return all the data that was bufferd in the partialFileBegin
// back to the terminal.
const dataToReturn = this.partialFileBegin + character;
this.partialFileBegin = '';
return dataToReturn;
}
// If we are currently in the state of buffering a file.
this.fileBuffer.push(character);
// If we now have an entire fileEnd marker, we have a complete file!
if (
this.fileBuffer.length >= this.fileBegin.length + this.fileEnd.length &&
this.fileBuffer.slice(-this.fileEnd.length).join('') === this.fileEnd
) {
this.onCompleteFileCallback(
this.fileBuffer
.slice(
this.fileBegin.length,
this.fileBuffer.length - this.fileEnd.length,
)
.join(''),
);
this.fileBuffer = [];
}
return '';
}
buffer(data: string): string {
// This is a optimization to quickly return if we know for
// sure we don't need to loop over each character.
if (
this.fileBuffer.length === 0 &&
this.partialFileBegin.length === 0 &&
data.indexOf(this.fileBegin[0]) === -1
) {
return data;
}
return data.split('').map(this.bufferCharacter.bind(this)).join('');
}
}

View File

@@ -0,0 +1,24 @@
/**
* Flow control client side.
* For low impact on overall throughput simply commits every `ackBytes`
* (default 2^18).
*/
export class FlowControlClient {
public counter = 0;
public ackBytes = 262144;
constructor(ackBytes?: number) {
if (ackBytes) {
this.ackBytes = ackBytes;
}
}
public needsCommit(length: number): boolean {
this.counter += length;
if (this.counter >= this.ackBytes) {
this.counter -= this.ackBytes;
return true;
}
return false;
}
}

View File

@@ -0,0 +1,14 @@
import _ from 'lodash';
export function mobileKeyboard(): void {
const [screen] = Array.from(document.getElementsByClassName('xterm-screen'));
if (_.isNull(screen)) return;
screen.setAttribute('contenteditable', 'true');
screen.setAttribute('spellcheck', 'false');
screen.setAttribute('autocorrect', 'false');
screen.setAttribute('autocomplete', 'false');
screen.setAttribute('autocapitalize', 'false');
/*
term.scrollPort_.screen_.setAttribute('contenteditable', 'false');
*/
}

View File

@@ -0,0 +1,118 @@
export type GatewayAuthType = 'password' | 'privateKey' | 'certificate';
export interface TerminalServerConfig {
id: string;
name?: string;
host: string;
port: number;
username: string;
transportMode?: string;
authType: GatewayAuthType;
password?: string;
privateKey?: string;
passphrase?: string;
certificate?: string;
knownHostFingerprint?: string;
cols?: number;
rows?: number;
}
export interface TerminalRuntimeConfig {
gatewayUrl?: string;
gatewayToken?: string;
selectedServerId?: string;
servers: TerminalServerConfig[];
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
function asNumber(value: unknown): number | undefined {
return typeof value === 'number' ? value : undefined;
}
function parseServer(input: unknown): TerminalServerConfig | undefined {
if (!isObject(input)) return undefined;
const id = asString(input.id);
const host = asString(input.host);
const username = asString(input.username);
const authType = asString(input.authType) as GatewayAuthType | undefined;
const port = asNumber(input.port);
if (!id || !host || !username || !authType || !port) return undefined;
if (!['password', 'privateKey', 'certificate'].includes(authType)) {
return undefined;
}
return {
id,
host,
port,
username,
authType,
name: asString(input.name),
transportMode: asString(input.transportMode),
password: asString(input.password),
privateKey: asString(input.privateKey),
passphrase: asString(input.passphrase),
certificate: asString(input.certificate),
knownHostFingerprint: asString(input.knownHostFingerprint),
cols: asNumber(input.cols),
rows: asNumber(input.rows),
};
}
/**
* 加载终端运行配置:
* 1) 统一从项目根目录 `terminal.config.json` 读取;
* 2) 使用 `no-store` 避免开发阶段被浏览器缓存;
* 3) 只返回通过最小字段校验的服务器条目,减少运行时协议错误。
*/
export async function loadRuntimeConfig(): Promise<TerminalRuntimeConfig> {
const response = await fetch('/terminal.config.json', {
cache: 'no-store',
});
if (!response.ok) {
throw new Error(`读取 terminal.config.json 失败: HTTP ${response.status}`);
}
const data = (await response.json()) as unknown;
if (!isObject(data)) {
throw new Error('terminal.config.json 格式错误: 顶层必须是对象');
}
const serversInput = Array.isArray(data.servers) ? data.servers : [];
const servers = serversInput
.map(parseServer)
.filter((item): item is TerminalServerConfig => Boolean(item));
if (servers.length === 0) {
throw new Error('terminal.config.json 格式错误: servers 不能为空');
}
return {
gatewayUrl: asString(data.gatewayUrl),
gatewayToken: asString(data.gatewayToken),
selectedServerId: asString(data.selectedServerId),
servers,
};
}
export function selectServer(
config: TerminalRuntimeConfig,
): TerminalServerConfig {
if (config.selectedServerId) {
const selected = config.servers.find(
(server) => server.id === config.selectedServerId,
);
if (selected) return selected;
}
return config.servers[0];
}

View File

@@ -0,0 +1,273 @@
import { loadRuntimeConfig, selectServer } from './runtimeConfig';
type SocketEvent =
| 'connect'
| 'login'
| 'data'
| 'logout'
| 'disconnect'
| 'error';
type EventPayloadMap = {
connect: [];
login: [];
data: [string];
logout: [string];
disconnect: [string];
error: [string];
};
type EventHandler<K extends SocketEvent> = (...args: EventPayloadMap[K]) => void;
type EmitEvent = 'input' | 'resize' | 'commit';
interface ResizePayload {
cols: number;
rows: number;
}
interface GatewayFrame {
type: string;
payload?: Record<string, unknown>;
}
interface GatewaySocketOptions {
cols: number;
rows: number;
}
export interface TermSocketLike {
emit(event: EmitEvent, payload: string | number | ResizePayload): void;
}
export class GatewaySocket {
private ws: WebSocket | null = null;
private options: GatewaySocketOptions = { cols: 80, rows: 24 };
private initialized = false;
private handlers = new Map<SocketEvent, Set<(...args: unknown[]) => void>>();
public on<K extends SocketEvent>(event: K, handler: EventHandler<K>): this {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set<(...args: unknown[]) => void>());
}
this.handlers.get(event)?.add(handler as (...args: unknown[]) => void);
return this;
}
public emit(
event: EmitEvent,
payload: string | number | ResizePayload,
): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
if (!this.initialized && event !== 'resize') return;
if (event === 'input' && typeof payload === 'string') {
this.sendFrame({
type: 'stdin',
payload: { data: payload, meta: { source: 'keyboard' } },
});
return;
}
if (
event === 'resize' &&
typeof payload === 'object' &&
payload !== null &&
typeof (payload as ResizePayload).cols === 'number' &&
typeof (payload as ResizePayload).rows === 'number'
) {
const size = payload as ResizePayload;
this.options = { cols: size.cols, rows: size.rows };
this.sendFrame({
type: 'resize',
payload: size as unknown as Record<string, unknown>,
});
}
}
public async connect(): Promise<void> {
try {
const runtime = await loadRuntimeConfig();
const selectedServer = selectServer(runtime);
this.options = {
cols: selectedServer.cols ?? 80,
rows: selectedServer.rows ?? 24,
};
const wsUrl = GatewaySocket.buildGatewayWsUrl(
runtime.gatewayUrl,
runtime.gatewayToken,
);
this.ws = new WebSocket(wsUrl);
this.ws.addEventListener('open', () => {
this.fire('connect');
this.sendFrame({
type: 'init',
payload: this.buildInitPayload(selectedServer),
});
});
this.ws.addEventListener('message', (event) => {
this.handleMessage(event.data);
});
this.ws.addEventListener('close', (event) => {
const reason = event.reason || `连接已关闭 (code=${event.code})`;
if (this.initialized) {
this.fire('logout', reason);
}
this.fire('disconnect', reason);
this.initialized = false;
this.ws = null;
});
this.ws.addEventListener('error', () => {
this.fire('error', '网关连接异常');
});
} catch (error) {
this.fire(
'error',
error instanceof Error ? error.message : '初始化网关连接失败',
);
}
}
private static buildGatewayWsUrl(
rawUrl: string | undefined,
token?: string,
): string {
const defaultProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const defaultUrl = `${defaultProtocol}//${window.location.hostname}:8787`;
const normalized = rawUrl && rawUrl.trim() ? rawUrl : defaultUrl;
const url = new URL(normalized);
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
url.protocol = defaultProtocol;
}
url.pathname = '/ws/terminal';
if (token) {
url.searchParams.set('token', token);
}
return url.toString();
}
/**
* 构建 gateway init 帧:
* 1) 认证信息按 authType 映射到协议要求字段;
* 2) `clientSessionKey` 固定化,支持网关侧短时断线续接;
* 3) 初始窗口大小从配置注入,后续再由 xterm resize 覆盖。
*/
private buildInitPayload(
server: Awaited<ReturnType<typeof selectServer>>,
): Record<string, unknown> {
const clientSessionKey = `${server.id}:${server.username}@${server.host}:${server.port}`;
const payload: Record<string, unknown> = {
host: server.host,
port: server.port,
username: server.username,
clientSessionKey,
pty: {
cols: this.options.cols,
rows: this.options.rows,
},
};
if (server.knownHostFingerprint) {
payload.knownHostFingerprint = server.knownHostFingerprint;
}
if (server.authType === 'password') {
payload.credential = {
type: 'password',
password: server.password || '',
};
return payload;
}
if (server.authType === 'privateKey') {
payload.credential = {
type: 'privateKey',
privateKey: server.privateKey || '',
...server.passphrase ? { passphrase: server.passphrase } : {},
};
return payload;
}
payload.credential = {
type: 'certificate',
privateKey: server.privateKey || '',
certificate: server.certificate || '',
...server.passphrase ? { passphrase: server.passphrase } : {},
};
return payload;
}
private handleMessage(raw: string | Blob | ArrayBuffer): void {
let data = '';
if (typeof raw === 'string') {
data = raw;
} else if (raw instanceof ArrayBuffer) {
data = new TextDecoder().decode(raw);
} else {
return;
}
let frame: GatewayFrame;
try {
frame = JSON.parse(data) as GatewayFrame;
} catch {
return;
}
if (!frame || typeof frame.type !== 'string') return;
if (frame.type === 'stdout' || frame.type === 'stderr') {
const text = `${frame.payload?.data || ''}`;
if (text) this.fire('data', text);
return;
}
if (frame.type === 'error') {
const msg = `${frame.payload?.message || '网关返回错误'}`;
this.fire('error', msg);
return;
}
if (frame.type === 'control') {
const action = `${frame.payload?.action || ''}`;
if (action === 'ping') {
this.sendFrame({ type: 'control', payload: { action: 'pong' } });
return;
}
if (action === 'connected') {
if (!this.initialized) {
this.initialized = true;
this.fire('login');
}
return;
}
if (action === 'disconnect') {
const reason = `${frame.payload?.reason || '连接已断开'}`;
this.fire('logout', reason);
}
}
}
private sendFrame(frame: GatewayFrame): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
this.ws.send(JSON.stringify(frame));
}
private fire<K extends SocketEvent>(
event: K,
...args: EventPayloadMap[K]
): void {
this.handlers.get(event)?.forEach((handler) => {
handler(...args);
});
}
}
export const socket = new GatewaySocket();

View File

@@ -0,0 +1,236 @@
import { FitAddon } from '@xterm/addon-fit';
import { ImageAddon } from '@xterm/addon-image';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { Terminal } from '@xterm/xterm';
import _ from 'lodash';
import { terminal as termElement } from './disconnect/elements';
import { configureTerm } from './term/confiruragtion';
import { loadOptions } from './term/load';
import type { TermSocketLike } from './socket';
import type { Options } from './term/options';
export class Term extends Terminal {
socket: TermSocketLike;
fitAddon: FitAddon;
loadOptions: () => Options;
constructor(socket: TermSocketLike) {
super({ allowProposedApi: true });
this.socket = socket;
this.fitAddon = new FitAddon();
this.loadAddon(this.fitAddon);
this.loadAddon(new WebLinksAddon());
this.loadAddon(new ImageAddon());
this.loadOptions = loadOptions;
}
resizeTerm(): void {
this.refresh(0, this.rows - 1);
if (this.shouldFitTerm) this.fitAddon.fit();
this.socket.emit('resize', { cols: this.cols, rows: this.rows });
}
get shouldFitTerm(): boolean {
return this.loadOptions().wettyFitTerminal ?? true;
}
}
const ctrlButton = document.getElementById('onscreen-ctrl');
let ctrlFlag = false; // This indicates whether the CTRL key is pressed or not
/**
* Toggles the state of the `ctrlFlag` variable and updates the visual state
* of the `ctrlButton` element accordingly. If `ctrlFlag` is set to `true`,
* the `active` class is added to the `ctrlButton`; otherwise, it is removed.
* After toggling, the terminal (`wetty_term`) is focused if it exists.
*/
const toggleCTRL = (): void => {
ctrlFlag = !ctrlFlag;
if (ctrlButton) {
if (ctrlFlag) {
ctrlButton.classList.add('active');
} else {
ctrlButton.classList.remove('active');
}
}
window.wetty_term?.focus();
}
/**
* Simulates a backspace key press by sending the backspace character
* (ASCII code 127) to the terminal. This function is intended to be used
* in conjunction with the `simulateCTRLAndKey` function to handle
* keyboard shortcuts.
*
*/
const simulateBackspace = (): void => {
window.wetty_term?.input('\x7F', true);
}
/**
* Simulates a CTRL + key press by sending the corresponding character
* (converted from the key's ASCII code) to the terminal. This function
* is intended to be used in conjunction with the `toggleCTRL` function
* to handle keyboard shortcuts.
*
* @param key - The key that was pressed, which will be converted to
* its corresponding character code.
*/
const simulateCTRLAndKey = (key: string): void => {
window.wetty_term?.input(`${String.fromCharCode(key.toUpperCase().charCodeAt(0) - 64)}`, false);
}
/**
* Handles the keydown event for the CTRL key. When the CTRL key is pressed,
* it sets the `ctrlFlag` variable to true and updates the visual state of
* the `ctrlButton` element. If the CTRL key is released, it sets `ctrlFlag`
* to false and updates the visual state of the `ctrlButton` element.
*
* @param e - The keyboard event object.
*/
document.addEventListener('keyup', (e) => {
if (ctrlFlag) {
// if key is a character
if (e.key.length === 1 && e.key.match(/^[a-zA-Z0-9]$/)) {
simulateCTRLAndKey(e.key);
// delayed backspace is needed to remove the character added to the terminal
// when CTRL + key is pressed.
// this is a workaround because e.preventDefault() cannot be used.
_.debounce(() => {
simulateBackspace();
}, 100)();
}
toggleCTRL();
}
});
/**
* Simulates pressing the ESC key by sending the ESC character (ASCII code 27)
* to the terminal. If the CTRL key is active, it toggles the CTRL state off.
* After sending the ESC character, the terminal is focused.
*/
const pressESC = (): void => {
if (ctrlFlag) {
toggleCTRL();
}
window.wetty_term?.input('\x1B', false);
window.wetty_term?.focus();
}
/**
* Simulates pressing the UP arrow key by sending the UP character (ASCII code 65)
* to the terminal. If the CTRL key is active, it toggles the CTRL state off.
* After sending the UP character, the terminal is focused.
*/
const pressUP = (): void => {
if (ctrlFlag) {
toggleCTRL();
}
window.wetty_term?.input('\x1B[A', false);
window.wetty_term?.focus();
}
/**
* Simulates pressing the DOWN arrow key by sending the DOWN character (ASCII code 66)
* to the terminal. If the CTRL key is active, it toggles the CTRL state off.
* After sending the DOWN character, the terminal is focused.
*/
const pressDOWN = (): void => {
if (ctrlFlag) {
toggleCTRL();
}
window.wetty_term?.input('\x1B[B', false);
window.wetty_term?.focus();
}
/**
* Simulates pressing the TAB key by sending the TAB character (ASCII code 9)
* to the terminal. If the CTRL key is active, it toggles the CTRL state off.
* After sending the TAB character, the terminal is focused.
*/
const pressTAB = (): void => {
if (ctrlFlag) {
toggleCTRL();
}
window.wetty_term?.input('\x09', false);
window.wetty_term?.focus();
}
/**
* Simulates pressing the LEFT arrow key by sending the LEFT character (ASCII code 68)
* to the terminal. If the CTRL key is active, it toggles the CTRL state off.
* After sending the LEFT character, the terminal is focused.
*/
const pressLEFT = (): void => {
if (ctrlFlag) {
toggleCTRL();
}
window.wetty_term?.input('\x1B[D', false);
window.wetty_term?.focus();
}
/**
* Simulates pressing the RIGHT arrow key by sending the RIGHT character (ASCII code 67)
* to the terminal. If the CTRL key is active, it toggles the CTRL state off.
* After sending the RIGHT character, the terminal is focused.
*/
const pressRIGHT = (): void => {
if (ctrlFlag) {
toggleCTRL();
}
window.wetty_term?.input('\x1B[C', false);
window.wetty_term?.focus();
}
/**
* Toggles the visibility of the onscreen buttons by adding or removing
* the 'active' class to the element with the ID 'onscreen-buttons'.
*/
const toggleFunctions = (): void => {
const element = document.querySelector('div#functions > div.onscreen-buttons')
if (element?.classList.contains('active')) {
element?.classList.remove('active');
} else {
element?.classList.add('active');
}
}
declare global {
interface Window {
wetty_term?: Term;
wetty_close_config?: () => void;
wetty_save_config?: (newConfig: Options) => void;
clipboardData: DataTransfer;
loadOptions: (conf: Options) => void;
toggleFunctions?: () => void;
toggleCTRL? : () => void;
pressESC?: () => void;
pressUP?: () => void;
pressDOWN?: () => void;
pressTAB?: () => void;
pressLEFT?: () => void;
pressRIGHT?: () => void;
}
}
export function terminal(socket: TermSocketLike): Term | undefined {
const term = new Term(socket);
if (_.isNull(termElement)) return undefined;
termElement.innerHTML = '';
term.open(termElement);
configureTerm(term);
window.onresize = function onResize() {
term.resizeTerm();
};
window.wetty_term = term;
window.toggleFunctions = toggleFunctions;
window.toggleCTRL = toggleCTRL;
window.pressESC = pressESC;
window.pressUP = pressUP;
window.pressDOWN = pressDOWN;
window.pressTAB = pressTAB;
window.pressLEFT = pressLEFT;
window.pressRIGHT = pressRIGHT;
return term;
}

View File

@@ -0,0 +1,77 @@
import { editor } from '../disconnect/elements';
import { copySelected, copyShortcut } from './confiruragtion/clipboard';
import { onInput } from './confiruragtion/editor';
import { loadOptions } from './load';
import type { Options } from './options';
import type { Term } from '../term';
export function configureTerm(term: Term): void {
const options = loadOptions();
try {
term.options = options.xterm;
} catch {
/* Do nothing */
}
const toggle = document.querySelector('#options .toggler');
const optionsElem = document.getElementById('options');
if (editor == null || toggle == null || optionsElem == null) {
throw new Error("Couldn't initialize configuration menu");
}
/**
* 将当前配置同步给 iframe 配置面板,并注入回调钩子。
* 返回值表示 iframe 是否已准备好接收配置loadOptions 已可调用)。
*/
const syncEditor = (): boolean => {
const editorWindow = editor.contentWindow;
if (!editorWindow || typeof editorWindow.loadOptions !== 'function') {
return false;
}
editorWindow.loadOptions(loadOptions());
editorWindow.wetty_close_config = () => {
optionsElem?.classList.toggle('opened');
};
editorWindow.wetty_save_config = (newConfig: Options) => {
onInput(term, newConfig);
};
return true;
};
function editorOnLoad() {
if (syncEditor()) return;
// 某些浏览器/开发模式下iframe 的脚本初始化会略晚于 load 事件。
setTimeout(() => {
syncEditor();
}, 50);
}
if (
(
editor.contentDocument ||
(editor.contentWindow?.document ?? {
readyState: '',
})
).readyState === 'complete'
) {
editorOnLoad();
}
editor.addEventListener('load', editorOnLoad);
toggle.addEventListener('click', e => {
syncEditor();
optionsElem.classList.toggle('opened');
e.preventDefault();
});
term.attachCustomKeyEventHandler(copyShortcut);
document.addEventListener(
'mouseup',
() => {
if (term.hasSelection()) copySelected(term.getSelection());
},
false,
);
}

View File

@@ -0,0 +1,40 @@
/**
Copy text selection to clipboard on double click or select
@param text - the selected text to copy
@returns boolean to indicate success or failure
*/
export function copySelected(text: string): boolean {
if (window.clipboardData?.setData) {
window.clipboardData.setData('Text', text);
return true;
}
if (
document.queryCommandSupported &&
document.queryCommandSupported('copy')
) {
const textarea = document.createElement('textarea');
textarea.textContent = text;
textarea.style.position = 'fixed';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
return true;
} catch (ex) {
return false;
} finally {
document.body.removeChild(textarea);
}
}
return false;
}
export function copyShortcut(e: KeyboardEvent): boolean {
// Ctrl + Shift + C
if (e.ctrlKey && e.shiftKey && e.keyCode === 67) {
e.preventDefault();
document.execCommand('copy');
return false;
}
return true;
}

View File

@@ -0,0 +1,23 @@
import { editor } from '../../disconnect/elements';
import type { Term } from '../../term';
import type { Options } from '../options';
export const onInput = (term: Term, updated: Options) => {
try {
const updatedConf = JSON.stringify(updated, null, 2);
if (localStorage.options === updatedConf) return;
term.options = updated.xterm;
if (
!updated.wettyFitTerminal &&
updated.xterm.cols != null &&
updated.xterm.rows != null
)
term.resize(updated.xterm.cols, updated.xterm.rows);
term.resizeTerm();
editor.classList.remove('error');
localStorage.options = updatedConf;
} catch (e) {
console.error('Configuration Error', e); // eslint-disable-line no-console
editor.classList.add('error');
}
};

View File

@@ -0,0 +1,25 @@
import _ from 'lodash';
import type { XTerm, Options } from './options';
export const defaultOptions: Options = {
xterm: { fontSize: 14 },
wettyVoid: 0,
wettyFitTerminal: true,
};
export function loadOptions(): Options {
try {
let options = _.isUndefined(localStorage.options)
? defaultOptions
: JSON.parse(localStorage.options);
// Convert old options to new options
if (!('xterm' in options)) {
const xterm = options;
options = defaultOptions;
options.xterm = xterm as unknown as XTerm;
}
return options;
} catch {
return defaultOptions;
}
}

View File

@@ -0,0 +1,11 @@
export type XTerm = {
cols?: number;
rows?: number;
fontSize: number;
} & Record<string, unknown>;
export interface Options {
xterm: XTerm;
wettyFitTerminal: boolean;
wettyVoid: number;
}