first commit

This commit is contained in:
douboer@gmail.com
2026-03-03 13:23:14 +08:00
commit 3b7c1d558a
161 changed files with 28120 additions and 0 deletions

844
xterminal/demo/main.js Normal file
View 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();