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,
|
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
|
||||||
|
|
|
||||||
|
|
@ -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]",
|
||||||
|
|
|
||||||
|
|
@ -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 错误]",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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