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, "") .replace(/\r/g, "") .replace(/\n/g, "\n") .replace(/\t/g, "") .replace(/\x08/g, ""); } 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(`${safeText}`); } 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") { // 退格:删除前一个可见字符,避免出现类似 `aas` 的重影。 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(`${html}`); 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();