Harden WebCRT interactive sessions for more CRT-like device login.

Improve async connect, binary stdout coalescing, paste throttling with progress, SSH keepalive, login prompt settle, and optional SFTP while keeping core terminal UX focused.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-01 19:01:44 +08:00
parent cb865254e4
commit accd28c9a1
13 changed files with 1884 additions and 224 deletions

View file

@ -1,4 +1,4 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
@ -6,9 +6,14 @@ import "@xterm/xterm/css/xterm.css";
export type WebTerminalHandle = {
clear: () => void;
copyAll: () => Promise<string>;
copySelection: () => Promise<string>;
getText: () => string;
fit: () => void;
focus: () => void;
sendBreak: () => void;
sendText: (data: string, opts?: { throttle?: boolean }) => void;
findNext: (term: string) => void;
findPrevious: (term: string) => void;
};
type Props = {
@ -16,6 +21,9 @@ type Props = {
title?: string;
recording?: boolean;
autoFocus?: boolean;
encoding?: string;
pasteDelayMs?: number;
copyOnSelect?: boolean;
onStatus?: (state: string, message?: string) => void;
onReady?: () => void;
onStdout?: (data: string) => void;
@ -35,11 +43,10 @@ function serializeTerminal(term: Terminal): string {
function isSidebarSearchTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") {
// xterm's hidden textarea must still receive keys normally.
if (target.classList.contains("xterm-helper-textarea")) return false;
return true;
}
return Boolean(target.closest(".webcrt-sidebar__search"));
return Boolean(target.closest(".webcrt-sidebar__search") || target.closest(".webcrt-ctx") || target.closest(".webcrt-find"));
}
function isXtermTextarea(target: EventTarget | null): boolean {
@ -49,7 +56,7 @@ function isXtermTextarea(target: EventTarget | null): boolean {
/** Map a browser key event to the bytes xterm/onData would normally emit. */
function keyEventToStdin(e: KeyboardEvent): string | null {
if (e.ctrlKey || e.altKey || e.metaKey) return null;
if (e.key === "Backspace") return "\x08"; // BS — common SecureCRT/VT default
if (e.key === "Backspace") return "\x08";
if (e.key === "Enter") return "\r";
if (e.key === "Tab") return "\t";
if (e.key === "Escape") return "\x1b";
@ -64,19 +71,65 @@ function keyEventToStdin(e: KeyboardEvent): string | null {
return null;
}
function decodeBytes(buf: ArrayBuffer, _encoding: string): string {
// Server normalizes device encodings (e.g. GBK) to UTF-8 on the wire.
try {
return new TextDecoder("utf-8").decode(buf);
} catch {
return "";
}
}
const PREF_KEY = "netx.webcrt.termPrefs";
function loadPrefs(): { copyOnSelect: boolean; pasteDelayMs: number } {
try {
const raw = localStorage.getItem(PREF_KEY);
if (!raw) return { copyOnSelect: true, pasteDelayMs: 40 };
const j = JSON.parse(raw) as Partial<{ copyOnSelect: boolean; pasteDelayMs: number }>;
return {
copyOnSelect: j.copyOnSelect !== false,
pasteDelayMs: Math.max(0, Math.min(200, Number(j.pasteDelayMs) || 40)),
};
} catch {
return { copyOnSelect: true, pasteDelayMs: 40 };
}
}
export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerminal(
{ wsUrl, title, recording, autoFocus = true, onStatus, onReady, onStdout },
{
wsUrl,
title,
recording,
autoFocus = true,
encoding = "utf-8",
pasteDelayMs,
copyOnSelect,
onStatus,
onReady,
onStdout,
},
ref,
) {
const hostRef = useRef<HTMLDivElement | null>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const findIndexRef = useRef(0);
const onStatusRef = useRef(onStatus);
const onReadyRef = useRef(onReady);
const onStdoutRef = useRef(onStdout);
const recordingRef = useRef(!!recording);
const autoFocusRef = useRef(autoFocus);
const encodingRef = useRef(encoding);
const pasteDelayRef = useRef(pasteDelayMs ?? loadPrefs().pasteDelayMs);
const copyOnSelectRef = useRef(copyOnSelect ?? loadPrefs().copyOnSelect);
const pasteQueueRef = useRef<Promise<void>>(Promise.resolve());
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
const [findOpen, setFindOpen] = useState(false);
const [findQuery, setFindQuery] = useState("");
const [pasteStatus, setPasteStatus] = useState<{ done: number; total: number } | null>(null);
const findInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
onStatusRef.current = onStatus;
@ -92,6 +145,18 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
autoFocusRef.current = autoFocus;
}, [autoFocus]);
useEffect(() => {
encodingRef.current = encoding;
}, [encoding]);
useEffect(() => {
if (pasteDelayMs != null) pasteDelayRef.current = pasteDelayMs;
}, [pasteDelayMs]);
useEffect(() => {
if (copyOnSelect != null) copyOnSelectRef.current = copyOnSelect;
}, [copyOnSelect]);
const focusTerminal = () => {
try {
termRef.current?.focus();
@ -100,6 +165,46 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}
};
const sendJson = (payload: Record<string, unknown>) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
};
const sendStdinImmediate = (data: string) => {
if (!data) return;
sendJson({ type: "stdin", data });
};
/** SecureCRT-like paste: send line-by-line with delay; show progress while sending. */
const sendStdinThrottled = (data: string) => {
if (!data) return;
const delay = pasteDelayRef.current;
if (delay <= 0 || data.length < 8) {
sendStdinImmediate(data);
return;
}
pasteQueueRef.current = pasteQueueRef.current.then(async () => {
const lines = data.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
const total = Math.max(1, lines.length);
setPasteStatus({ done: 0, total });
try {
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const chunk = i < lines.length - 1 ? `${line}\r` : line;
if (chunk) sendStdinImmediate(chunk);
setPasteStatus({ done: i + 1, total });
if (i < lines.length - 1) {
await new Promise((r) => window.setTimeout(r, delay));
}
}
} finally {
setPasteStatus(null);
}
});
};
useImperativeHandle(ref, () => ({
clear: () => {
termRef.current?.clear();
@ -112,6 +217,13 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}
return text;
},
copySelection: async () => {
const text = termRef.current?.getSelection() || "";
if (text && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
}
return text;
},
getText: () => (termRef.current ? serializeTerminal(termRef.current) : ""),
fit: () => {
try {
@ -121,6 +233,39 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}
},
focus: focusTerminal,
sendBreak: () => sendJson({ type: "break" }),
sendText: (data: string, opts?: { throttle?: boolean }) => {
if (opts?.throttle) sendStdinThrottled(data);
else sendStdinImmediate(data);
},
findNext: (q: string) => {
const term = termRef.current;
if (!term || !q) return;
const text = serializeTerminal(term).toLowerCase();
const needle = q.toLowerCase();
let idx = text.indexOf(needle, findIndexRef.current + 1);
if (idx < 0) idx = text.indexOf(needle);
if (idx >= 0) {
findIndexRef.current = idx;
// Approximate scroll: each buffer line ~1 row.
const line = text.slice(0, idx).split("\n").length - 1;
term.scrollToLine(Math.max(0, line - 2));
}
},
findPrevious: (q: string) => {
const term = termRef.current;
if (!term || !q) return;
const text = serializeTerminal(term).toLowerCase();
const needle = q.toLowerCase();
const before = text.slice(0, Math.max(0, findIndexRef.current));
let idx = before.lastIndexOf(needle);
if (idx < 0) idx = text.lastIndexOf(needle);
if (idx >= 0) {
findIndexRef.current = idx;
const line = text.slice(0, idx).split("\n").length - 1;
term.scrollToLine(Math.max(0, line - 2));
}
},
}));
useEffect(() => {
@ -131,10 +276,12 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
cursorBlink: true,
fontSize: 13,
fontFamily: 'Consolas, "Courier New", monospace',
scrollback: 10000,
theme: {
background: "#0b1220",
foreground: "#e2e8f0",
cursor: "#e2e8f0",
selectionBackground: "#334155",
},
convertEol: true,
});
@ -155,7 +302,6 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
if (!autoFocusRef.current) return;
focusTerminal();
};
// Focus immediately so the first keystroke is not lost to the sidebar/body.
maybeFocus();
requestAnimationFrame(() => {
doFit();
@ -167,22 +313,10 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
});
const ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
onStatusRef.current?.("connecting");
const sendJson = (payload: Record<string, unknown>) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
};
const sendStdin = (data: string) => {
if (!data) return;
sendJson({ type: "stdin", data });
};
// Display only what the device echoes (chars, Tab completion, BS erase, etc.).
// No local echo / local erase — those desync on Tab complete and prompt redraw.
const writeStdout = (raw: string) => {
if (raw) term.write(raw);
if (recordingRef.current) onStdoutRef.current?.(raw);
@ -201,6 +335,18 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
};
ws.onmessage = (ev) => {
if (ev.data instanceof ArrayBuffer) {
writeStdout(decodeBytes(ev.data, encodingRef.current));
maybeFocus();
return;
}
if (typeof Blob !== "undefined" && ev.data instanceof Blob) {
void ev.data.arrayBuffer().then((buf) => {
writeStdout(decodeBytes(buf, encodingRef.current));
maybeFocus();
});
return;
}
try {
const msg = JSON.parse(String(ev.data || "{}")) as {
type?: string;
@ -215,7 +361,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}
if (msg.type === "status") {
onStatusRef.current?.(String(msg.state || ""), msg.message);
if (msg.state === "connected") {
if (msg.state === "connected" || msg.state === "connecting") {
maybeFocus();
return;
}
@ -243,27 +389,41 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}
};
// When focused, xterm onData sends keystrokes.
const dataDisposable = term.onData((data) => {
// Normalize Backspace: xterm emits DEL(0x7f); devices expect BS(0x08) like default SecureCRT.
const normalized = data.replace(/\x7f/g, "\x08");
sendStdin(normalized);
// Large pastes from xterm arrive as one onData blob.
if (normalized.length > 32 || normalized.includes("\r") || normalized.includes("\n")) {
sendStdinThrottled(normalized);
} else {
sendStdinImmediate(normalized);
}
});
const selDisposable = term.onSelectionChange(() => {
if (!copyOnSelectRef.current) return;
const sel = term.getSelection();
if (sel && navigator.clipboard?.writeText) {
void navigator.clipboard.writeText(sel).catch(() => undefined);
}
});
// When NOT focused (sidebar still focused after click), capture keys and forward
// so the first character / Backspace are not lost to the browser.
const onKeyDownCapture = (e: KeyboardEvent) => {
if (!autoFocusRef.current) return;
if (host.closest("[hidden]")) return;
if (isSidebarSearchTarget(e.target)) return;
// Always block browser "Backspace = history back" while this session pane is active.
if ((e.ctrlKey || e.metaKey) && (e.key === "f" || e.key === "F")) {
e.preventDefault();
setFindOpen(true);
window.setTimeout(() => findInputRef.current?.focus(), 0);
return;
}
if (e.key === "Backspace") {
e.preventDefault();
}
if (isXtermTextarea(e.target)) {
// Let xterm onData handle it; we only prevented browser back above.
maybeFocus();
return;
}
@ -274,10 +434,31 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
e.preventDefault();
e.stopPropagation();
maybeFocus();
sendStdin(data);
sendStdinImmediate(data);
};
window.addEventListener("keydown", onKeyDownCapture, true);
const onContextMenu = (e: MouseEvent) => {
e.preventDefault();
setCtxMenu({ x: e.clientX, y: e.clientY });
};
host.addEventListener("contextmenu", onContextMenu);
const onPasteCapture = (e: ClipboardEvent) => {
if (!autoFocusRef.current) return;
if (host.closest("[hidden]")) return;
if (isSidebarSearchTarget(e.target)) return;
const text = e.clipboardData?.getData("text") || "";
if (!text) return;
// Intercept paste so we always apply delay (even if focus is odd).
if (document.activeElement === term.textarea || host.contains(document.activeElement) || autoFocusRef.current) {
e.preventDefault();
sendStdinThrottled(text);
maybeFocus();
}
};
window.addEventListener("paste", onPasteCapture, true);
const pingTimer = window.setInterval(() => {
sendJson({ type: "ping" });
}, 25000);
@ -297,8 +478,11 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
window.clearInterval(pingTimer);
window.removeEventListener("resize", onWinResize);
window.removeEventListener("keydown", onKeyDownCapture, true);
window.removeEventListener("paste", onPasteCapture, true);
host.removeEventListener("contextmenu", onContextMenu);
ro?.disconnect();
dataDisposable.dispose();
selDisposable.dispose();
try {
ws.close();
} catch {
@ -311,13 +495,147 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
};
}, [wsUrl, title]);
const pasteFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText();
sendStdinThrottled(text);
} catch {
/* ignore */
}
setCtxMenu(null);
focusTerminal();
};
const copySelection = async () => {
const text = termRef.current?.getSelection() || "";
if (text && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
}
setCtxMenu(null);
};
return (
<div
className="webcrt-term"
ref={hostRef}
onMouseDown={() => {
focusTerminal();
}}
/>
<div className="webcrt-term-wrap">
{pasteStatus ? (
<div className="webcrt-paste-status" role="status" aria-live="polite">
粘贴中 {pasteStatus.done}/{pasteStatus.total} 行…
</div>
) : null}
{findOpen ? (
<div className="webcrt-find">
<input
ref={findInputRef}
type="search"
value={findQuery}
placeholder="Find…"
onChange={(e) => setFindQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const term = termRef.current;
if (!term || !findQuery) return;
const text = serializeTerminal(term).toLowerCase();
const needle = findQuery.toLowerCase();
if (e.shiftKey) {
const before = text.slice(0, Math.max(0, findIndexRef.current));
let idx = before.lastIndexOf(needle);
if (idx < 0) idx = text.lastIndexOf(needle);
if (idx >= 0) {
findIndexRef.current = idx;
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
}
} else {
let idx = text.indexOf(needle, findIndexRef.current + 1);
if (idx < 0) idx = text.indexOf(needle);
if (idx >= 0) {
findIndexRef.current = idx;
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
}
}
} else if (e.key === "Escape") {
setFindOpen(false);
focusTerminal();
}
}}
/>
<button
type="button"
onClick={() => {
const term = termRef.current;
if (!term || !findQuery) return;
const text = serializeTerminal(term).toLowerCase();
const needle = findQuery.toLowerCase();
const before = text.slice(0, Math.max(0, findIndexRef.current));
let idx = before.lastIndexOf(needle);
if (idx < 0) idx = text.lastIndexOf(needle);
if (idx >= 0) {
findIndexRef.current = idx;
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
}
}}
>
↑
</button>
<button
type="button"
onClick={() => {
const term = termRef.current;
if (!term || !findQuery) return;
const text = serializeTerminal(term).toLowerCase();
const needle = findQuery.toLowerCase();
let idx = text.indexOf(needle, findIndexRef.current + 1);
if (idx < 0) idx = text.indexOf(needle);
if (idx >= 0) {
findIndexRef.current = idx;
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
}
}}
>
↓
</button>
<button
type="button"
onClick={() => {
setFindOpen(false);
focusTerminal();
}}
>
×
</button>
</div>
) : null}
<div
className="webcrt-term"
ref={hostRef}
onMouseDown={() => {
focusTerminal();
setCtxMenu(null);
}}
/>
{ctxMenu ? (
<div
className="webcrt-ctx"
style={{ left: ctxMenu.x, top: ctxMenu.y }}
onMouseDown={(e) => e.stopPropagation()}
>
<button type="button" onClick={() => void copySelection()}>
Copy
</button>
<button type="button" onClick={() => void pasteFromClipboard()}>
Paste
</button>
<button
type="button"
onClick={() => {
termRef.current?.clear();
termRef.current?.write("\x1b[H\x1b[2J");
setCtxMenu(null);
}}
>
Clear
</button>
</div>
) : null}
</div>
);
});