first commit
This commit is contained in:
844
xterminal/demo/main.js
Normal file
844
xterminal/demo/main.js
Normal file
@@ -0,0 +1,844 @@
|
||||
import XTerminal from "../out/index.js";
|
||||
import terminalConfig from "../terminal.config.json";
|
||||
|
||||
// 证书路径由网关服务端使用,前端仅保留常量便于排障对齐。
|
||||
const DEV_CERT_PATH = "/Users/gavin/.acme.sh/shell.biboer.cn_ecc/fullchain.cer";
|
||||
const DEV_KEY_PATH = "/Users/gavin/.acme.sh/shell.biboer.cn_ecc/shell.biboer.cn.key";
|
||||
|
||||
const term = new XTerminal();
|
||||
term.mount("#app");
|
||||
|
||||
let socket = null;
|
||||
let connected = false;
|
||||
let ansiRemainder = "";
|
||||
let lastFingerprint = "";
|
||||
const pendingInputs = [];
|
||||
let pendingNoticeShown = false;
|
||||
// 将短时间内的 stdout/stderr 分片合并后再渲染,避免 `a` 与 `\b as` 跨帧导致视觉重影。
|
||||
const STREAM_BATCH_MS = 12;
|
||||
let stdoutBatch = "";
|
||||
let stderrBatch = "";
|
||||
let streamFlushTimer = 0;
|
||||
// 回车问题定位开关:开启后会在 Console 输出“回车 -> 发送 -> 回显”链路日志。
|
||||
const DEBUG_ENTER_FLOW = false;
|
||||
// 是否启用底部触控工具条
|
||||
const ENABLE_TOUCH_TOOLS = true;
|
||||
const DEBUG_WINDOW_MS = 5000;
|
||||
let enterSeq = 0;
|
||||
let lastEnterMeta = { id: 0, at: 0, input: "" };
|
||||
|
||||
function setConnected(next) {
|
||||
connected = next;
|
||||
}
|
||||
|
||||
function writeSystem(message) {
|
||||
term.writelnSafe(`[system] ${message}`);
|
||||
}
|
||||
|
||||
function toVisibleText(data) {
|
||||
return String(data || "")
|
||||
.replace(/\u001b/g, "<ESC>")
|
||||
.replace(/\r/g, "<CR>")
|
||||
.replace(/\n/g, "<LF>\n")
|
||||
.replace(/\t/g, "<TAB>")
|
||||
.replace(/\x08/g, "<BS>");
|
||||
}
|
||||
|
||||
function toCharCodes(data, limit = 80) {
|
||||
const list = Array.from(String(data || ""), (ch) => ch.charCodeAt(0));
|
||||
const head = list.slice(0, limit).join(" ");
|
||||
return list.length > limit ? `${head} ... (total=${list.length})` : head;
|
||||
}
|
||||
|
||||
function isWithinEnterDebugWindow() {
|
||||
return Date.now() - lastEnterMeta.at <= DEBUG_WINDOW_MS;
|
||||
}
|
||||
|
||||
function logEnterFlow(stage, data, extra = "") {
|
||||
if (!DEBUG_ENTER_FLOW) return;
|
||||
if (!lastEnterMeta.at) return;
|
||||
if (!isWithinEnterDebugWindow()) return;
|
||||
const delta = Date.now() - lastEnterMeta.at;
|
||||
const text = String(data || "");
|
||||
const suffix = extra ? ` ${extra}` : "";
|
||||
console.log(
|
||||
`[xterm-debug][${stage}] enter#${lastEnterMeta.id} +${delta}ms len=${text.length}${suffix}`
|
||||
);
|
||||
console.log(`[xterm-debug][${stage}] text: ${toVisibleText(text)}`);
|
||||
console.log(`[xterm-debug][${stage}] code: ${toCharCodes(text)}`);
|
||||
}
|
||||
|
||||
function findSelectedServer(config) {
|
||||
const list = Array.isArray(config.servers) ? config.servers : [];
|
||||
if (!list.length) {
|
||||
throw new Error("terminal.config.json 未配置 servers");
|
||||
}
|
||||
if (config.selectedServerId) {
|
||||
const found = list.find((item) => item.id === config.selectedServerId);
|
||||
if (found) return found;
|
||||
}
|
||||
return list[0];
|
||||
}
|
||||
|
||||
function buildCredential(server) {
|
||||
if (server.authType === "password") {
|
||||
if (!server.password) {
|
||||
throw new Error("authType=password 但缺少 password");
|
||||
}
|
||||
return { type: "password", password: server.password };
|
||||
}
|
||||
|
||||
if (server.authType === "privateKey") {
|
||||
if (!server.privateKey) {
|
||||
throw new Error("authType=privateKey 但缺少 privateKey");
|
||||
}
|
||||
return {
|
||||
type: "privateKey",
|
||||
privateKey: server.privateKey,
|
||||
...(server.passphrase ? { passphrase: server.passphrase } : {})
|
||||
};
|
||||
}
|
||||
|
||||
if (server.authType === "certificate") {
|
||||
if (!server.privateKey || !server.certificate) {
|
||||
throw new Error(
|
||||
"authType=certificate 但缺少 privateKey 或 certificate"
|
||||
);
|
||||
}
|
||||
return {
|
||||
type: "certificate",
|
||||
privateKey: server.privateKey,
|
||||
certificate: server.certificate,
|
||||
...(server.passphrase ? { passphrase: server.passphrase } : {})
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`不支持的 authType: ${String(server.authType || "")}`);
|
||||
}
|
||||
|
||||
function buildGatewayEndpoint(config) {
|
||||
const pageProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const rawInput = String(config.gatewayUrl || "").trim();
|
||||
|
||||
if (!rawInput) {
|
||||
throw new Error("缺少 gatewayUrl:为避免误连受保护站点,已禁用默认兜底地址");
|
||||
}
|
||||
|
||||
let url;
|
||||
try {
|
||||
if (rawInput.startsWith("/")) {
|
||||
// 相对路径仅允许落到当前页面域名,避免误连其它受保护站点。
|
||||
url = new URL(`${pageProtocol}//${window.location.host}${rawInput}`);
|
||||
} else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(rawInput)) {
|
||||
url = new URL(rawInput);
|
||||
} else {
|
||||
url = new URL(`${pageProtocol}//${rawInput}`);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`gatewayUrl 无效: ${rawInput} (${error instanceof Error ? error.message : String(error)})`
|
||||
);
|
||||
}
|
||||
|
||||
if (url.protocol === "http:") url.protocol = "ws:";
|
||||
if (url.protocol === "https:") url.protocol = "wss:";
|
||||
|
||||
const pathname = url.pathname.replace(/\/+$/, "");
|
||||
url.pathname = pathname.endsWith("/ws/terminal")
|
||||
? pathname
|
||||
: `${pathname}/ws/terminal`.replace(/\/{2,}/g, "/");
|
||||
|
||||
// 握手阶段禁止携带业务参数,仅保留认证信息(token)。
|
||||
url.search = "";
|
||||
const gatewayToken = String(config.gatewayToken || "").trim();
|
||||
if (gatewayToken) {
|
||||
url.searchParams.set("token", gatewayToken);
|
||||
}
|
||||
url.hash = "";
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function sendFrame(frame) {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
throw new Error("网关连接未建立");
|
||||
}
|
||||
socket.send(JSON.stringify(frame));
|
||||
}
|
||||
|
||||
/**
|
||||
* ANSI SGR 颜色映射(标准 16 色)。
|
||||
* 说明:
|
||||
* - 终端输出若包含 `\x1b[31m` 这类序列,会在这里转换为对应 CSS 颜色;
|
||||
* - 不引入额外依赖,避免增加生产包体与维护复杂度。
|
||||
*/
|
||||
const ANSI_COLOR_TABLE = {
|
||||
30: "#1c1c1c",
|
||||
31: "#d70000",
|
||||
32: "#008700",
|
||||
33: "#875f00",
|
||||
34: "#005faf",
|
||||
35: "#870087",
|
||||
36: "#008787",
|
||||
37: "#bcbcbc",
|
||||
90: "#767676",
|
||||
91: "#ff5f5f",
|
||||
92: "#5fff5f",
|
||||
93: "#ffd75f",
|
||||
94: "#5fafff",
|
||||
95: "#ff5fff",
|
||||
96: "#5fffff",
|
||||
97: "#ffffff"
|
||||
};
|
||||
|
||||
function createAnsiState() {
|
||||
return {
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
inverse: false,
|
||||
fg: null,
|
||||
bg: null
|
||||
};
|
||||
}
|
||||
|
||||
const ansiState = createAnsiState();
|
||||
|
||||
function resetAnsiState() {
|
||||
Object.assign(ansiState, createAnsiState());
|
||||
}
|
||||
|
||||
function parseAnsiColor8Bit(code) {
|
||||
if (code < 0 || code > 255) return null;
|
||||
if (code < 16) {
|
||||
const basic = {
|
||||
0: "#000000",
|
||||
1: "#800000",
|
||||
2: "#008000",
|
||||
3: "#808000",
|
||||
4: "#000080",
|
||||
5: "#800080",
|
||||
6: "#008080",
|
||||
7: "#c0c0c0",
|
||||
8: "#808080",
|
||||
9: "#ff0000",
|
||||
10: "#00ff00",
|
||||
11: "#ffff00",
|
||||
12: "#0000ff",
|
||||
13: "#ff00ff",
|
||||
14: "#00ffff",
|
||||
15: "#ffffff"
|
||||
};
|
||||
return basic[code] || null;
|
||||
}
|
||||
if (code >= 16 && code <= 231) {
|
||||
const offset = code - 16;
|
||||
const r = Math.floor(offset / 36);
|
||||
const g = Math.floor((offset % 36) / 6);
|
||||
const b = offset % 6;
|
||||
const steps = [0, 95, 135, 175, 215, 255];
|
||||
return `rgb(${steps[r]},${steps[g]},${steps[b]})`;
|
||||
}
|
||||
const gray = 8 + (code - 232) * 10;
|
||||
return `rgb(${gray},${gray},${gray})`;
|
||||
}
|
||||
|
||||
function applySgr(params) {
|
||||
const values = params.length ? params : [0];
|
||||
for (let i = 0; i < values.length; i += 1) {
|
||||
const p = values[i];
|
||||
if (p === 0) {
|
||||
resetAnsiState();
|
||||
continue;
|
||||
}
|
||||
if (p === 1) {
|
||||
ansiState.bold = true;
|
||||
continue;
|
||||
}
|
||||
if (p === 3) {
|
||||
ansiState.italic = true;
|
||||
continue;
|
||||
}
|
||||
if (p === 4) {
|
||||
ansiState.underline = true;
|
||||
continue;
|
||||
}
|
||||
if (p === 7) {
|
||||
ansiState.inverse = true;
|
||||
continue;
|
||||
}
|
||||
if (p === 22) {
|
||||
ansiState.bold = false;
|
||||
continue;
|
||||
}
|
||||
if (p === 23) {
|
||||
ansiState.italic = false;
|
||||
continue;
|
||||
}
|
||||
if (p === 24) {
|
||||
ansiState.underline = false;
|
||||
continue;
|
||||
}
|
||||
if (p === 27) {
|
||||
ansiState.inverse = false;
|
||||
continue;
|
||||
}
|
||||
if (p >= 30 && p <= 37) {
|
||||
ansiState.fg = ANSI_COLOR_TABLE[p];
|
||||
continue;
|
||||
}
|
||||
if (p >= 90 && p <= 97) {
|
||||
ansiState.fg = ANSI_COLOR_TABLE[p];
|
||||
continue;
|
||||
}
|
||||
if (p >= 40 && p <= 47) {
|
||||
ansiState.bg = ANSI_COLOR_TABLE[p - 10];
|
||||
continue;
|
||||
}
|
||||
if (p >= 100 && p <= 107) {
|
||||
ansiState.bg = ANSI_COLOR_TABLE[p - 10];
|
||||
continue;
|
||||
}
|
||||
if (p === 39) {
|
||||
ansiState.fg = null;
|
||||
continue;
|
||||
}
|
||||
if (p === 49) {
|
||||
ansiState.bg = null;
|
||||
continue;
|
||||
}
|
||||
// 兼容 256 色与 true color:
|
||||
// - 38;5;n / 48;5;n
|
||||
// - 38;2;r;g;b / 48;2;r;g;b
|
||||
if (p === 38 || p === 48) {
|
||||
const isForeground = p === 38;
|
||||
const mode = values[i + 1];
|
||||
if (mode === 5) {
|
||||
const code = values[i + 2];
|
||||
const color = parseAnsiColor8Bit(code);
|
||||
if (color) {
|
||||
if (isForeground) ansiState.fg = color;
|
||||
else ansiState.bg = color;
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (mode === 2) {
|
||||
const r = values[i + 2];
|
||||
const g = values[i + 3];
|
||||
const b = values[i + 4];
|
||||
if (
|
||||
Number.isFinite(r) &&
|
||||
Number.isFinite(g) &&
|
||||
Number.isFinite(b)
|
||||
) {
|
||||
const color = `rgb(${Math.max(0, Math.min(255, r))},${Math.max(0, Math.min(255, g))},${Math.max(0, Math.min(255, b))})`;
|
||||
if (isForeground) ansiState.fg = color;
|
||||
else ansiState.bg = color;
|
||||
}
|
||||
i += 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildAnsiStyle() {
|
||||
let fg = ansiState.fg;
|
||||
let bg = ansiState.bg;
|
||||
if (ansiState.inverse) {
|
||||
const nextFg = bg || "#111";
|
||||
const nextBg = fg || "#e5e5e5";
|
||||
fg = nextFg;
|
||||
bg = nextBg;
|
||||
}
|
||||
const styles = [];
|
||||
if (fg) styles.push(`color:${fg}`);
|
||||
if (bg) styles.push(`background-color:${bg}`);
|
||||
if (ansiState.bold) styles.push("font-weight:700");
|
||||
if (ansiState.italic) styles.push("font-style:italic");
|
||||
if (ansiState.underline) styles.push("text-decoration:underline");
|
||||
return styles.join(";");
|
||||
}
|
||||
|
||||
function pushTextFragment(parts, text) {
|
||||
if (!text) return;
|
||||
const safeText = XTerminal.escapeHTML(text);
|
||||
const style = buildAnsiStyle();
|
||||
if (style) {
|
||||
parts.push(`<span style="${style}">${safeText}</span>`);
|
||||
} else {
|
||||
parts.push(safeText);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 ANSI 序列转换为 HTML 片段(保留样式,过滤控制指令)。
|
||||
* 说明:
|
||||
* - 支持跨帧残缺序列(通过 ansiRemainder 缓冲);
|
||||
* - 未实现光标定位/清屏等复杂控制,仅做样式渲染与噪声过滤,
|
||||
* 可覆盖当前 SSH 输出里的主流序列。
|
||||
*/
|
||||
function renderAnsiToHtml(chunk) {
|
||||
const input = `${ansiRemainder}${String(chunk || "")}`;
|
||||
const parts = [];
|
||||
let textBuffer = "";
|
||||
let i = 0;
|
||||
ansiRemainder = "";
|
||||
|
||||
const applyBackspace = () => {
|
||||
if (!textBuffer.length) return;
|
||||
textBuffer = textBuffer.slice(0, -1);
|
||||
};
|
||||
|
||||
const applyCarriageReturn = () => {
|
||||
// CR 语义:光标回到“当前行首”。
|
||||
// 这里通过清理 textBuffer 里最后一个换行之后的内容来模拟行首覆盖。
|
||||
const lastNewlineIndex = textBuffer.lastIndexOf("\n");
|
||||
textBuffer =
|
||||
lastNewlineIndex === -1
|
||||
? ""
|
||||
: textBuffer.slice(0, lastNewlineIndex + 1);
|
||||
};
|
||||
|
||||
while (i < input.length) {
|
||||
const ch = input[i];
|
||||
if (ch === "\x08") {
|
||||
// 退格:删除前一个可见字符,避免出现类似 `a<BS>as` 的重影。
|
||||
applyBackspace();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "\r") {
|
||||
// CRLF 统一折叠为 LF,减少重复换行导致的空白行。
|
||||
if (input[i + 1] === "\n") {
|
||||
textBuffer += "\n";
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
// 裸 CR 按“回到行首”处理。
|
||||
applyCarriageReturn();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch !== "\u001b") {
|
||||
textBuffer += ch;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
pushTextFragment(parts, textBuffer);
|
||||
textBuffer = "";
|
||||
|
||||
const next = input[i + 1];
|
||||
if (!next) {
|
||||
ansiRemainder = "\u001b";
|
||||
break;
|
||||
}
|
||||
|
||||
// CSI: ESC [ ... final
|
||||
if (next === "[") {
|
||||
let j = i + 2;
|
||||
while (j < input.length) {
|
||||
const code = input.charCodeAt(j);
|
||||
if (code >= 0x40 && code <= 0x7e) break;
|
||||
j += 1;
|
||||
}
|
||||
if (j >= input.length) {
|
||||
ansiRemainder = input.slice(i);
|
||||
break;
|
||||
}
|
||||
|
||||
const finalChar = input[j];
|
||||
const rawParams = input.slice(i + 2, j);
|
||||
// 私有模式(如 ?2004h/?2004l)不参与渲染,直接吞掉。
|
||||
if (finalChar === "m") {
|
||||
const params = rawParams
|
||||
.split(";")
|
||||
.filter((item) => item.length > 0)
|
||||
.map((item) => Number.parseInt(item, 10))
|
||||
.filter((item) => Number.isFinite(item));
|
||||
applySgr(params);
|
||||
}
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// OSC: ESC ] ... BEL 或 ESC \
|
||||
if (next === "]") {
|
||||
let j = i + 2;
|
||||
let foundEnd = false;
|
||||
while (j < input.length) {
|
||||
if (input[j] === "\u0007") {
|
||||
foundEnd = true;
|
||||
j += 1;
|
||||
break;
|
||||
}
|
||||
if (input[j] === "\u001b" && input[j + 1] === "\\") {
|
||||
foundEnd = true;
|
||||
j += 2;
|
||||
break;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
if (!foundEnd) {
|
||||
ansiRemainder = input.slice(i);
|
||||
break;
|
||||
}
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 其余 ESC 序列按单字符忽略。
|
||||
i += 2;
|
||||
}
|
||||
|
||||
pushTextFragment(parts, textBuffer);
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function sendInputData(input) {
|
||||
logEnterFlow("TX stdin", `${input}\n`, "note=append-newline");
|
||||
sendFrame({
|
||||
type: "stdin",
|
||||
payload: {
|
||||
data: `${input}\n`,
|
||||
meta: { source: "keyboard" }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function writeStreamOutput(kind, data) {
|
||||
const html = renderAnsiToHtml(data);
|
||||
if (!html) return;
|
||||
if (kind === "stderr") {
|
||||
term.write(`<span class="error">${html}</span>`);
|
||||
return;
|
||||
}
|
||||
term.write(html);
|
||||
}
|
||||
|
||||
function flushStreamBatch() {
|
||||
if (streamFlushTimer) {
|
||||
clearTimeout(streamFlushTimer);
|
||||
streamFlushTimer = 0;
|
||||
}
|
||||
const stdout = stdoutBatch;
|
||||
const stderr = stderrBatch;
|
||||
stdoutBatch = "";
|
||||
stderrBatch = "";
|
||||
|
||||
if (stdout) {
|
||||
logEnterFlow("RX stdout", stdout, "batched=yes");
|
||||
writeStreamOutput("stdout", stdout);
|
||||
}
|
||||
if (stderr) {
|
||||
logEnterFlow("RX stderr", stderr, "batched=yes");
|
||||
writeStreamOutput("stderr", stderr);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleStreamBatchFlush() {
|
||||
if (streamFlushTimer) return;
|
||||
streamFlushTimer = window.setTimeout(() => {
|
||||
streamFlushTimer = 0;
|
||||
flushStreamBatch();
|
||||
}, STREAM_BATCH_MS);
|
||||
}
|
||||
|
||||
function queueStreamFrame(kind, data) {
|
||||
const text = String(data || "");
|
||||
if (!text) return;
|
||||
if (kind === "stderr") {
|
||||
stderrBatch += text;
|
||||
logEnterFlow("RX stderr-chunk", text, `queue=${stderrBatch.length}`);
|
||||
} else {
|
||||
stdoutBatch += text;
|
||||
logEnterFlow("RX stdout-chunk", text, `queue=${stdoutBatch.length}`);
|
||||
}
|
||||
scheduleStreamBatchFlush();
|
||||
}
|
||||
|
||||
function flushPendingInputs() {
|
||||
if (!pendingInputs.length) return;
|
||||
const queue = pendingInputs.splice(0, pendingInputs.length);
|
||||
for (const text of queue) {
|
||||
sendInputData(text);
|
||||
}
|
||||
pendingNoticeShown = false;
|
||||
}
|
||||
|
||||
function handleGatewayFrame(frame) {
|
||||
if (!frame || typeof frame !== "object") return;
|
||||
|
||||
if (frame.type === "stdout" && frame.payload?.data) {
|
||||
queueStreamFrame("stdout", frame.payload.data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === "stderr" && frame.payload?.data) {
|
||||
queueStreamFrame("stderr", frame.payload.data);
|
||||
return;
|
||||
}
|
||||
|
||||
flushStreamBatch();
|
||||
|
||||
if (frame.type === "error") {
|
||||
setConnected(false);
|
||||
writeSystem(
|
||||
`网关错误 ${frame.payload?.code || "UNKNOWN"}: ${frame.payload?.message || "未知错误"}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === "control") {
|
||||
const action = frame.payload?.action;
|
||||
if (action === "connected") {
|
||||
const wasConnected = connected;
|
||||
setConnected(true);
|
||||
if (!wasConnected) {
|
||||
term.resume();
|
||||
term.focus();
|
||||
}
|
||||
const nextFingerprint = frame.payload?.fingerprint
|
||||
? String(frame.payload.fingerprint)
|
||||
: "";
|
||||
if (nextFingerprint && nextFingerprint !== lastFingerprint) {
|
||||
lastFingerprint = nextFingerprint;
|
||||
writeSystem(`SSH 已连接,指纹 ${nextFingerprint}`);
|
||||
} else if (!wasConnected) {
|
||||
writeSystem("SSH 已连接");
|
||||
}
|
||||
if (!wasConnected) {
|
||||
flushPendingInputs();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "ping") {
|
||||
sendFrame({ type: "control", payload: { action: "pong" } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "pong") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "disconnect") {
|
||||
setConnected(false);
|
||||
writeSystem(`连接断开: ${frame.payload?.reason || "unknown"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function connectByConfig(config) {
|
||||
const server = findSelectedServer(config);
|
||||
const endpoint = buildGatewayEndpoint(config);
|
||||
const cols = Number(server.cols) > 0 ? Number(server.cols) : 120;
|
||||
const rows = Number(server.rows) > 0 ? Number(server.rows) : 30;
|
||||
|
||||
// writeSystem(`正在连接网关 ${endpoint}`);
|
||||
// writeSystem(`TLS 证书: ${DEV_CERT_PATH}`);
|
||||
// writeSystem(`TLS 私钥: ${DEV_KEY_PATH}`);
|
||||
|
||||
socket = new WebSocket(endpoint);
|
||||
|
||||
socket.onopen = () => {
|
||||
const initFrame = {
|
||||
type: "init",
|
||||
payload: {
|
||||
host: server.host,
|
||||
port: Number(server.port) || 22,
|
||||
username: server.username,
|
||||
clientSessionKey: `xterminal-${Date.now()}`,
|
||||
credential: buildCredential(server),
|
||||
pty: { cols, rows }
|
||||
}
|
||||
};
|
||||
sendFrame(initFrame);
|
||||
term.pause();
|
||||
// writeSystem(
|
||||
// `已发送 init,目标 ${server.username}@${server.host}:${server.port || 22}`
|
||||
// );
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
handleGatewayFrame(JSON.parse(String(event.data || "{}")));
|
||||
} catch (error) {
|
||||
writeSystem(
|
||||
`帧解析失败: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = (event) => {
|
||||
flushStreamBatch();
|
||||
setConnected(false);
|
||||
writeSystem(`WebSocket 关闭 code=${event.code}`);
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
flushStreamBatch();
|
||||
setConnected(false);
|
||||
writeSystem("WebSocket 异常");
|
||||
};
|
||||
}
|
||||
|
||||
term.on("data", (input) => {
|
||||
enterSeq += 1;
|
||||
lastEnterMeta = { id: enterSeq, at: Date.now(), input };
|
||||
logEnterFlow(
|
||||
"ENTER local",
|
||||
input,
|
||||
`connected=${connected ? "yes" : "no"}`
|
||||
);
|
||||
|
||||
// 关闭本地回显:xterminal 在 Enter 时会先写一行 `${input}\n`,
|
||||
// SSH 远端通常也会回显同一行,二者叠加会出现“中间空一行/偶发两行空白”。
|
||||
term.clearLast();
|
||||
|
||||
if (input === "clear") {
|
||||
term.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
logEnterFlow("QUEUE local", input, "reason=not-connected");
|
||||
pendingInputs.push(input);
|
||||
if (!pendingNoticeShown) {
|
||||
pendingNoticeShown = true;
|
||||
writeSystem("会话建立中,输入已排队,连接后自动发送");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sendInputData(input);
|
||||
} catch (error) {
|
||||
writeSystem(
|
||||
`发送失败: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
try {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
sendFrame({
|
||||
type: "control",
|
||||
payload: { action: "disconnect", reason: "page_unload" }
|
||||
});
|
||||
socket.close();
|
||||
}
|
||||
} catch {
|
||||
// 忽略页面卸载时的关闭异常。
|
||||
}
|
||||
});
|
||||
|
||||
connectByConfig(terminalConfig);
|
||||
|
||||
// --- 触控工具条逻辑 ---
|
||||
function setupTouchTools() {
|
||||
const touchTools = document.getElementById("touch-tools");
|
||||
if (!touchTools) return;
|
||||
|
||||
if (!ENABLE_TOUCH_TOOLS) {
|
||||
touchTools.classList.add("is-hidden");
|
||||
document.documentElement.style.setProperty('--tc-toolbar-height', '0px');
|
||||
return;
|
||||
}
|
||||
|
||||
// 动态调整位置:当键盘弹起时,保持在软键盘和地址胶囊上方
|
||||
const adjustPosition = () => {
|
||||
if (window.visualViewport) {
|
||||
const bottomInset = Math.max(0, window.innerHeight - (window.visualViewport.height + window.visualViewport.offsetTop));
|
||||
document.documentElement.style.setProperty('--tc-bottom-inset', `${bottomInset}px`);
|
||||
}
|
||||
};
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener("resize", adjustPosition);
|
||||
window.visualViewport.addEventListener("scroll", adjustPosition);
|
||||
}
|
||||
|
||||
// 阻止获得焦点,保留 xterminal 的焦点
|
||||
touchTools.addEventListener("pointerdown", (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const btn = e.target.closest(".tc-key-btn");
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.action;
|
||||
|
||||
const textarea = document.querySelector('textarea[name="xterminal_input"]');
|
||||
|
||||
switch (action) {
|
||||
case "up":
|
||||
case "down":
|
||||
if (textarea) {
|
||||
const keyMap = { up: "ArrowUp", down: "ArrowDown" };
|
||||
const ev = new Event('keydown', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(ev, "key", { value: keyMap[action] });
|
||||
textarea.dispatchEvent(ev);
|
||||
}
|
||||
break;
|
||||
case "tab":
|
||||
if (textarea) {
|
||||
if (socket && connected) {
|
||||
// 发送当前已经输入的字符串以及一个 Tab 字符给网关交互(为了触发远端 Shell 的真实补全逻辑)
|
||||
sendFrame({ type: "stdin", payload: { data: textarea.value + "\t", meta: { source: "keyboard" } } });
|
||||
// 清空本地输入缓冲。远端 PTY 在收到后会主动将该字符串 + 补全的结果完整 Echo 回显到页面上。
|
||||
textarea.value = "";
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
} else {
|
||||
const ev = new Event('keydown', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(ev, "key", { value: "Tab" });
|
||||
textarea.dispatchEvent(ev);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "enter":
|
||||
if (textarea) {
|
||||
const ev = new Event('keydown', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(ev, "key", { value: "Enter" });
|
||||
textarea.dispatchEvent(ev);
|
||||
}
|
||||
break;
|
||||
case "ls":
|
||||
if (textarea) {
|
||||
textarea.value += "ls";
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// 防抖同步有延迟,等下一个事件循环再触发回车,确保 XTerminal 已取到 'ls' 并更新内部的 data.value
|
||||
setTimeout(() => {
|
||||
const ev = new Event('keydown', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(ev, "key", { value: "Enter" });
|
||||
textarea.dispatchEvent(ev);
|
||||
}, 10);
|
||||
}
|
||||
break;
|
||||
case "left":
|
||||
case "right":
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart;
|
||||
if (action === "left") {
|
||||
textarea.setSelectionRange(Math.max(0, start - 1), Math.max(0, start - 1));
|
||||
} else {
|
||||
textarea.setSelectionRange(Math.min(textarea.value.length, start + 1), Math.min(textarea.value.length, start + 1));
|
||||
}
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
break;
|
||||
case "ctrlc":
|
||||
if (socket && connected) {
|
||||
sendFrame({ type: "stdin", payload: { data: "\x03", meta: { source: "keyboard" } } });
|
||||
}
|
||||
if (textarea) {
|
||||
textarea.value = "";
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
setupTouchTools();
|
||||
Reference in New Issue
Block a user