845 lines
26 KiB
JavaScript
845 lines
26 KiB
JavaScript
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();
|