From ba33ab5c4f1e4fe8ea0b53a8fd49c6a51f71d17a Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 6 Aug 2026 11:02:10 +0800 Subject: [PATCH] Fix WebCRT copy/paste on HTTP LAN by falling back off Clipboard API. Co-authored-by: Cursor --- web/src/components/WebTerminal.tsx | 124 ++++++++++++++++++++--------- web/src/i18n/en.ts | 6 +- web/src/i18n/zh.ts | 5 +- web/src/index.css | 61 ++++++++++++++ web/src/pages/WebcrtPage.tsx | 6 +- web/src/utils/clipboard.ts | 65 +++++++++++++++ 6 files changed, 227 insertions(+), 40 deletions(-) create mode 100644 web/src/utils/clipboard.ts diff --git a/web/src/components/WebTerminal.tsx b/web/src/components/WebTerminal.tsx index 4dc8fad..5c12c8b 100644 --- a/web/src/components/WebTerminal.tsx +++ b/web/src/components/WebTerminal.tsx @@ -8,6 +8,7 @@ import { applyKeywordHighlight, type KeywordHighlightConfig, } from "../utils/webcrtKeywordHighlight"; +import { readClipboardText, writeClipboardText } from "../utils/clipboard"; export type WebTerminalHandle = { clear: () => void; @@ -225,6 +226,10 @@ export const WebTerminal = forwardRef(function WebTerm const [findOpen, setFindOpen] = useState(false); const [findQuery, setFindQuery] = useState(""); const [pasteStatus, setPasteStatus] = useState<{ done: number; total: number } | null>(null); + const [pasteBridgeOpen, setPasteBridgeOpen] = useState(false); + const pasteBridgeRef = useRef(null); + const pasteBridgeOpenRef = useRef(false); + pasteBridgeOpenRef.current = pasteBridgeOpen; const findInputRef = useRef(null); useEffect(() => { @@ -356,16 +361,12 @@ export const WebTerminal = forwardRef(function WebTerm }, copyAll: async () => { const text = termRef.current ? serializeTerminal(termRef.current) : ""; - if (text && navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); - } + if (text) await writeClipboardText(text); return text; }, copySelection: async () => { const text = termRef.current?.getSelection() || ""; - if (text && navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); - } + if (text) await writeClipboardText(text); return text; }, getText: () => (termRef.current ? serializeTerminal(termRef.current) : ""), @@ -621,9 +622,7 @@ export const WebTerminal = forwardRef(function WebTerm const selDisposable = term.onSelectionChange(() => { if (!copyOnSelectRef.current) return; const sel = term.getSelection(); - if (sel && navigator.clipboard?.writeText) { - void navigator.clipboard.writeText(sel).catch(() => undefined); - } + if (sel) void writeClipboardText(sel).catch(() => undefined); }); const onKeyDownCapture = (e: KeyboardEvent) => { @@ -644,21 +643,20 @@ export const WebTerminal = forwardRef(function WebTerm e.preventDefault(); e.stopPropagation(); const text = term.getSelection() || ""; - if (text && navigator.clipboard?.writeText) { - void navigator.clipboard.writeText(text).catch(() => undefined); - } + if (text) void writeClipboardText(text).catch(() => undefined); return; } if ((mod && e.shiftKey && (e.key === "V" || e.key === "v")) || (e.shiftKey && e.key === "Insert")) { e.preventDefault(); e.stopPropagation(); void (async () => { - try { - const text = await navigator.clipboard.readText(); - if (text) sendStdinThrottled(text); - } catch { - /* permission denied */ + const text = await readClipboardText(); + if (text) { + sendStdinThrottled(text); + return; } + // http://LAN: Clipboard API blocked — open paste bridge (paste event still works). + setPasteBridgeOpen(true); })(); return; } @@ -666,9 +664,7 @@ export const WebTerminal = forwardRef(function WebTerm e.preventDefault(); e.stopPropagation(); const text = term.getSelection() || ""; - if (text && navigator.clipboard?.writeText) { - void navigator.clipboard.writeText(text).catch(() => undefined); - } + if (text) void writeClipboardText(text).catch(() => undefined); return; } @@ -699,6 +695,7 @@ export const WebTerminal = forwardRef(function WebTerm host.addEventListener("contextmenu", onContextMenu); const onPasteCapture = (e: ClipboardEvent) => { + if (pasteBridgeOpenRef.current) return; if (!autoFocusRef.current) return; if (host.closest("[hidden]")) return; if (isSidebarSearchTarget(e.target)) return; @@ -759,34 +756,39 @@ export const WebTerminal = forwardRef(function WebTerm }, [sessionId || wsUrl]); const pasteFromClipboard = async () => { - try { - const text = await navigator.clipboard.readText(); - if (text) sendStdinThrottled(text); - else setClipStatus(t("webcrt.term.pasteEmpty")); - } catch { - setClipStatus(t("webcrt.term.pasteFailed")); - } setCtxMenu(null); - focusTerminal(); - window.setTimeout(() => setClipStatus(null), 2200); + const text = await readClipboardText(); + if (text) { + sendStdinThrottled(text); + focusTerminal(); + return; + } + // Insecure http://IP cannot read clipboard via API — bridge via paste event. + setPasteBridgeOpen(true); }; const copySelection = async () => { const text = menuSelectionRef.current || termRef.current?.getSelection() || ""; + setCtxMenu(null); + if (!text) { + setClipStatus(t("webcrt.actions.copyEmpty")); + window.setTimeout(() => setClipStatus(null), 2200); + return; + } try { - if (text && navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); - setClipStatus(t("webcrt.actions.copied")); - } else { - setClipStatus(t("webcrt.actions.copyEmpty")); - } + const ok = await writeClipboardText(text); + setClipStatus(ok ? t("webcrt.actions.copied") : t("webcrt.actions.copyFailed")); } catch { setClipStatus(t("webcrt.actions.copyFailed")); } - setCtxMenu(null); window.setTimeout(() => setClipStatus(null), 2200); }; + useEffect(() => { + if (!pasteBridgeOpen) return; + window.setTimeout(() => pasteBridgeRef.current?.focus(), 0); + }, [pasteBridgeOpen]); + return (
{pasteStatus ? ( @@ -799,6 +801,56 @@ export const WebTerminal = forwardRef(function WebTerm {clipStatus}
) : null} + {pasteBridgeOpen ? ( +
+
+

{t("webcrt.term.pasteBridgeHint")}

+