Files
terminal-lab/xterminal/demo/main.js
douboer@gmail.com 3b7c1d558a first commit
2026-03-03 13:23:14 +08:00

845 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();