mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +08:00
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:
parent
4ea3b737f2
commit
ba33ab5c4f
6 changed files with 227 additions and 40 deletions
|
|
@ -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<WebTerminalHandle, Props>(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<HTMLTextAreaElement | null>(null);
|
||||
const pasteBridgeOpenRef = useRef(false);
|
||||
pasteBridgeOpenRef.current = pasteBridgeOpen;
|
||||
const findInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -356,16 +361,12 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(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<WebTerminalHandle, Props>(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<WebTerminalHandle, Props>(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<WebTerminalHandle, Props>(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<WebTerminalHandle, Props>(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<WebTerminalHandle, Props>(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);
|
||||
const text = await readClipboardText();
|
||||
if (text) {
|
||||
sendStdinThrottled(text);
|
||||
focusTerminal();
|
||||
window.setTimeout(() => setClipStatus(null), 2200);
|
||||
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() || "";
|
||||
try {
|
||||
if (text && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setClipStatus(t("webcrt.actions.copied"));
|
||||
} else {
|
||||
setCtxMenu(null);
|
||||
if (!text) {
|
||||
setClipStatus(t("webcrt.actions.copyEmpty"));
|
||||
window.setTimeout(() => setClipStatus(null), 2200);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
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 (
|
||||
<div className="webcrt-term-wrap">
|
||||
{pasteStatus ? (
|
||||
|
|
@ -799,6 +801,56 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
{clipStatus}
|
||||
</div>
|
||||
) : 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 ? (
|
||||
<div className="webcrt-find">
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -1334,7 +1334,11 @@ const en = {
|
|||
copy: "Copy",
|
||||
paste: "Paste",
|
||||
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",
|
||||
pasting: "Pasting {{done}}/{{total}} lines…",
|
||||
wsError: "[WebSocket error]",
|
||||
|
|
|
|||
|
|
@ -1326,7 +1326,10 @@ const zh = {
|
|||
copy: "复制",
|
||||
paste: "粘贴",
|
||||
pasteEmpty: "剪贴板为空",
|
||||
pasteFailed: "无法读取剪贴板(请用 Ctrl+Shift+V 或检查浏览器权限)",
|
||||
pasteFailed: "无法读取剪贴板(HTTP 远程请用下方粘贴框,或 Ctrl+V)",
|
||||
pasteBridgeHint: "当前为非 HTTPS 访问,浏览器禁止直接读剪贴板。请在此框按 Ctrl+V 粘贴,内容会发送到终端。",
|
||||
pasteBridgePh: "在此按 Ctrl+V…",
|
||||
pasteBridgeCancel: "取消",
|
||||
clear: "清空",
|
||||
pasting: "粘贴中 {{done}}/{{total}} 行…",
|
||||
wsError: "[WebSocket 错误]",
|
||||
|
|
|
|||
|
|
@ -3659,6 +3659,67 @@ pre {
|
|||
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 {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
type WebcrtSftpItem,
|
||||
} from "../services/api";
|
||||
import { pageCount } from "../utils/display";
|
||||
import { writeClipboardText } from "../utils/clipboard";
|
||||
import type { CliTargetItem, ManagedNeItem } from "../types";
|
||||
import {
|
||||
defaultKeywordHighlightConfig,
|
||||
|
|
@ -1374,8 +1375,9 @@ export function WebcrtPage() {
|
|||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(ip);
|
||||
showOk(t("webcrt.tabMenu.copyIpOk", { ip }));
|
||||
const ok = await writeClipboardText(ip);
|
||||
if (ok) showOk(t("webcrt.tabMenu.copyIpOk", { ip }));
|
||||
else showError(t("webcrt.actions.copyFailed"));
|
||||
} catch {
|
||||
showError(t("webcrt.actions.copyFailed"));
|
||||
}
|
||||
|
|
|
|||
65
web/src/utils/clipboard.ts
Normal file
65
web/src/utils/clipboard.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue