Fix WebCRT copy/paste on HTTP LAN by falling back off Clipboard API.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-06 11:02:10 +08:00
parent 4ea3b737f2
commit ba33ab5c4f
6 changed files with 227 additions and 40 deletions

View file

@ -8,6 +8,7 @@ import {
applyKeywordHighlight, applyKeywordHighlight,
type KeywordHighlightConfig, type KeywordHighlightConfig,
} from "../utils/webcrtKeywordHighlight"; } from "../utils/webcrtKeywordHighlight";
import { readClipboardText, writeClipboardText } from "../utils/clipboard";
export type WebTerminalHandle = { export type WebTerminalHandle = {
clear: () => void; clear: () => void;
@ -225,6 +226,10 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
const [findOpen, setFindOpen] = useState(false); const [findOpen, setFindOpen] = useState(false);
const [findQuery, setFindQuery] = useState(""); const [findQuery, setFindQuery] = useState("");
const [pasteStatus, setPasteStatus] = useState<{ done: number; total: number } | null>(null); const [pasteStatus, setPasteStatus] = useState<{ done: number; total: number } | null>(null);
const [pasteBridgeOpen, setPasteBridgeOpen] = useState(false);
const pasteBridgeRef = useRef<HTMLTextAreaElement | null>(null);
const pasteBridgeOpenRef = useRef(false);
pasteBridgeOpenRef.current = pasteBridgeOpen;
const findInputRef = useRef<HTMLInputElement | null>(null); const findInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => { useEffect(() => {
@ -356,16 +361,12 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}, },
copyAll: async () => { copyAll: async () => {
const text = termRef.current ? serializeTerminal(termRef.current) : ""; const text = termRef.current ? serializeTerminal(termRef.current) : "";
if (text && navigator.clipboard?.writeText) { if (text) await writeClipboardText(text);
await navigator.clipboard.writeText(text);
}
return text; return text;
}, },
copySelection: async () => { copySelection: async () => {
const text = termRef.current?.getSelection() || ""; const text = termRef.current?.getSelection() || "";
if (text && navigator.clipboard?.writeText) { if (text) await writeClipboardText(text);
await navigator.clipboard.writeText(text);
}
return text; return text;
}, },
getText: () => (termRef.current ? serializeTerminal(termRef.current) : ""), getText: () => (termRef.current ? serializeTerminal(termRef.current) : ""),
@ -621,9 +622,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
const selDisposable = term.onSelectionChange(() => { const selDisposable = term.onSelectionChange(() => {
if (!copyOnSelectRef.current) return; if (!copyOnSelectRef.current) return;
const sel = term.getSelection(); const sel = term.getSelection();
if (sel && navigator.clipboard?.writeText) { if (sel) void writeClipboardText(sel).catch(() => undefined);
void navigator.clipboard.writeText(sel).catch(() => undefined);
}
}); });
const onKeyDownCapture = (e: KeyboardEvent) => { const onKeyDownCapture = (e: KeyboardEvent) => {
@ -644,21 +643,20 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const text = term.getSelection() || ""; const text = term.getSelection() || "";
if (text && navigator.clipboard?.writeText) { if (text) void writeClipboardText(text).catch(() => undefined);
void navigator.clipboard.writeText(text).catch(() => undefined);
}
return; return;
} }
if ((mod && e.shiftKey && (e.key === "V" || e.key === "v")) || (e.shiftKey && e.key === "Insert")) { if ((mod && e.shiftKey && (e.key === "V" || e.key === "v")) || (e.shiftKey && e.key === "Insert")) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
void (async () => { void (async () => {
try { const text = await readClipboardText();
const text = await navigator.clipboard.readText(); if (text) {
if (text) sendStdinThrottled(text); sendStdinThrottled(text);
} catch { return;
/* permission denied */
} }
// http://LAN: Clipboard API blocked — open paste bridge (paste event still works).
setPasteBridgeOpen(true);
})(); })();
return; return;
} }
@ -666,9 +664,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const text = term.getSelection() || ""; const text = term.getSelection() || "";
if (text && navigator.clipboard?.writeText) { if (text) void writeClipboardText(text).catch(() => undefined);
void navigator.clipboard.writeText(text).catch(() => undefined);
}
return; return;
} }
@ -699,6 +695,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
host.addEventListener("contextmenu", onContextMenu); host.addEventListener("contextmenu", onContextMenu);
const onPasteCapture = (e: ClipboardEvent) => { const onPasteCapture = (e: ClipboardEvent) => {
if (pasteBridgeOpenRef.current) return;
if (!autoFocusRef.current) return; if (!autoFocusRef.current) return;
if (host.closest("[hidden]")) return; if (host.closest("[hidden]")) return;
if (isSidebarSearchTarget(e.target)) return; if (isSidebarSearchTarget(e.target)) return;
@ -759,34 +756,39 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}, [sessionId || wsUrl]); }, [sessionId || wsUrl]);
const pasteFromClipboard = async () => { 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); setCtxMenu(null);
focusTerminal(); const text = await readClipboardText();
window.setTimeout(() => setClipStatus(null), 2200); if (text) {
sendStdinThrottled(text);
focusTerminal();
return;
}
// Insecure http://IP cannot read clipboard via API — bridge via paste event.
setPasteBridgeOpen(true);
}; };
const copySelection = async () => { const copySelection = async () => {
const text = menuSelectionRef.current || termRef.current?.getSelection() || ""; const text = menuSelectionRef.current || termRef.current?.getSelection() || "";
setCtxMenu(null);
if (!text) {
setClipStatus(t("webcrt.actions.copyEmpty"));
window.setTimeout(() => setClipStatus(null), 2200);
return;
}
try { try {
if (text && navigator.clipboard?.writeText) { const ok = await writeClipboardText(text);
await navigator.clipboard.writeText(text); setClipStatus(ok ? t("webcrt.actions.copied") : t("webcrt.actions.copyFailed"));
setClipStatus(t("webcrt.actions.copied"));
} else {
setClipStatus(t("webcrt.actions.copyEmpty"));
}
} catch { } catch {
setClipStatus(t("webcrt.actions.copyFailed")); setClipStatus(t("webcrt.actions.copyFailed"));
} }
setCtxMenu(null);
window.setTimeout(() => setClipStatus(null), 2200); window.setTimeout(() => setClipStatus(null), 2200);
}; };
useEffect(() => {
if (!pasteBridgeOpen) return;
window.setTimeout(() => pasteBridgeRef.current?.focus(), 0);
}, [pasteBridgeOpen]);
return ( return (
<div className="webcrt-term-wrap"> <div className="webcrt-term-wrap">
{pasteStatus ? ( {pasteStatus ? (
@ -799,6 +801,56 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
{clipStatus} {clipStatus}
</div> </div>
) : null} ) : null}
{pasteBridgeOpen ? (
<div className="webcrt-paste-bridge" role="dialog" aria-label={t("webcrt.term.paste")}>
<div className="webcrt-paste-bridge__card">
<p className="webcrt-paste-bridge__hint">{t("webcrt.term.pasteBridgeHint")}</p>
<textarea
ref={pasteBridgeRef}
className="webcrt-paste-bridge__input"
rows={4}
placeholder={t("webcrt.term.pasteBridgePh")}
onPaste={(e) => {
const text = e.clipboardData?.getData("text") || "";
if (!text) return;
e.preventDefault();
setPasteBridgeOpen(false);
sendStdinThrottled(text);
focusTerminal();
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
setPasteBridgeOpen(false);
focusTerminal();
}
}}
/>
<div className="webcrt-paste-bridge__actions">
<button
type="button"
onClick={() => {
const text = pasteBridgeRef.current?.value || "";
setPasteBridgeOpen(false);
if (text) sendStdinThrottled(text);
focusTerminal();
}}
>
{t("webcrt.term.paste")}
</button>
<button
type="button"
onClick={() => {
setPasteBridgeOpen(false);
focusTerminal();
}}
>
{t("webcrt.term.pasteBridgeCancel")}
</button>
</div>
</div>
</div>
) : null}
{findOpen ? ( {findOpen ? (
<div className="webcrt-find"> <div className="webcrt-find">
<input <input

View file

@ -1334,7 +1334,11 @@ const en = {
copy: "Copy", copy: "Copy",
paste: "Paste", paste: "Paste",
pasteEmpty: "Clipboard is empty", pasteEmpty: "Clipboard is empty",
pasteFailed: "Cannot read clipboard (try Ctrl+Shift+V or check browser permission)", pasteFailed: "Cannot read clipboard (on HTTP use the paste box, or Ctrl+V)",
pasteBridgeHint:
"This page is not HTTPS, so the browser blocks clipboard reads. Press Ctrl+V in this box to send text to the terminal.",
pasteBridgePh: "Press Ctrl+V here…",
pasteBridgeCancel: "Cancel",
clear: "Clear", clear: "Clear",
pasting: "Pasting {{done}}/{{total}} lines…", pasting: "Pasting {{done}}/{{total}} lines…",
wsError: "[WebSocket error]", wsError: "[WebSocket error]",

View file

@ -1326,7 +1326,10 @@ const zh = {
copy: "复制", copy: "复制",
paste: "粘贴", paste: "粘贴",
pasteEmpty: "剪贴板为空", pasteEmpty: "剪贴板为空",
pasteFailed: "无法读取剪贴板(请用 Ctrl+Shift+V 或检查浏览器权限)", pasteFailed: "无法读取剪贴板(HTTP 远程请用下方粘贴框,或 Ctrl+V)",
pasteBridgeHint: "当前为非 HTTPS 访问,浏览器禁止直接读剪贴板。请在此框按 Ctrl+V 粘贴,内容会发送到终端。",
pasteBridgePh: "在此按 Ctrl+V…",
pasteBridgeCancel: "取消",
clear: "清空", clear: "清空",
pasting: "粘贴中 {{done}}/{{total}} 行…", pasting: "粘贴中 {{done}}/{{total}} 行…",
wsError: "[WebSocket 错误]", wsError: "[WebSocket 错误]",

View file

@ -3659,6 +3659,67 @@ pre {
pointer-events: none; pointer-events: none;
} }
.webcrt-paste-bridge {
position: absolute;
inset: 0;
z-index: 8;
display: flex;
align-items: center;
justify-content: center;
background: rgba(2, 6, 23, 0.55);
padding: 16px;
}
.webcrt-paste-bridge__card {
width: min(420px, 100%);
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
border-radius: 10px;
background: #0f172a;
border: 1px solid #334155;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
}
.webcrt-paste-bridge__hint {
margin: 0;
font-size: 13px;
line-height: 1.45;
color: #cbd5e1;
}
.webcrt-paste-bridge__input {
width: 100%;
resize: vertical;
min-height: 88px;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid #475569;
background: #020617;
color: #e2e8f0;
font: inherit;
}
.webcrt-paste-bridge__actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.webcrt-paste-bridge__actions button {
padding: 6px 12px;
border-radius: 6px;
border: 1px solid #475569;
background: #1e293b;
color: #e2e8f0;
cursor: pointer;
}
.webcrt-paste-bridge__actions button:hover {
background: #334155;
}
.webcrt-find { .webcrt-find {
display: flex; display: flex;
gap: 4px; gap: 4px;

View file

@ -33,6 +33,7 @@ import {
type WebcrtSftpItem, type WebcrtSftpItem,
} from "../services/api"; } from "../services/api";
import { pageCount } from "../utils/display"; import { pageCount } from "../utils/display";
import { writeClipboardText } from "../utils/clipboard";
import type { CliTargetItem, ManagedNeItem } from "../types"; import type { CliTargetItem, ManagedNeItem } from "../types";
import { import {
defaultKeywordHighlightConfig, defaultKeywordHighlightConfig,
@ -1374,8 +1375,9 @@ export function WebcrtPage() {
return; return;
} }
try { try {
await navigator.clipboard.writeText(ip); const ok = await writeClipboardText(ip);
showOk(t("webcrt.tabMenu.copyIpOk", { ip })); if (ok) showOk(t("webcrt.tabMenu.copyIpOk", { ip }));
else showError(t("webcrt.actions.copyFailed"));
} catch { } catch {
showError(t("webcrt.actions.copyFailed")); showError(t("webcrt.actions.copyFailed"));
} }

View file

@ -0,0 +1,65 @@
/** Clipboard helpers that work on http://LAN (non-secure) as well as HTTPS. */
export function isClipboardApiAvailable(): boolean {
try {
return Boolean(
typeof window !== "undefined" &&
window.isSecureContext &&
navigator.clipboard &&
typeof navigator.clipboard.writeText === "function",
);
} catch {
return false;
}
}
/** Write text; prefers Clipboard API, falls back to execCommand('copy'). */
export async function writeClipboardText(text: string): Promise<boolean> {
const value = String(text || "");
if (!value) return false;
if (isClipboardApiAvailable() && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value);
return true;
} catch {
/* fall through */
}
}
try {
const ta = document.createElement("textarea");
ta.value = value;
ta.setAttribute("readonly", "");
ta.style.cssText = "position:fixed;left:-9999px;top:0;opacity:0";
document.body.appendChild(ta);
ta.focus();
ta.select();
ta.setSelectionRange(0, value.length);
const ok = document.execCommand("copy");
document.body.removeChild(ta);
return Boolean(ok);
} catch {
return false;
}
}
/**
* Read clipboard via Clipboard API when available.
* On insecure http://IP, returns null — caller must use a paste-event bridge.
*/
export async function readClipboardText(): Promise<string | null> {
if (
typeof window !== "undefined" &&
window.isSecureContext &&
navigator.clipboard &&
typeof navigator.clipboard.readText === "function"
) {
try {
return await navigator.clipboard.readText();
} catch {
return null;
}
}
return null;
}