mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-09 15:52:14 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb1e3cd29e | ||
|
|
4605e913c1 | ||
|
|
c2c7b8a72f | ||
|
|
1c63ab42b5 | ||
|
|
28e5e08346 |
No files matched your search
@@ -200,7 +200,22 @@ export const useTerminalContextActions = ({
|
||||
isSensitiveInput: () => passwordPromptActiveRef?.current === true,
|
||||
broadcastPasswordBypass: () => broadcastPasswordBypassRef?.current === true,
|
||||
onClipboardImageUploadResult,
|
||||
readClipboardText: () => navigator.clipboard.readText(),
|
||||
// Prefer the main-process clipboard read: the renderer's clipboard
|
||||
// view can still be empty right after Windows clipboard history
|
||||
// (Win+V) writes the picked item while the window was unfocused
|
||||
// (#3582), while the bridge always reads the live system clipboard.
|
||||
readClipboardText: async () => {
|
||||
try {
|
||||
const bridged = await bridge?.readClipboardText?.();
|
||||
// Treat any successful bridge read as authoritative — including an
|
||||
// empty string, which means the live clipboard is genuinely empty
|
||||
// and must not fall back to possibly-stale renderer clipboard text.
|
||||
if (typeof bridged === "string") return bridged;
|
||||
} catch (err) {
|
||||
logger.warn("Bridge clipboard read failed; falling back to navigator", err);
|
||||
}
|
||||
return navigator.clipboard.readText();
|
||||
},
|
||||
scrollOnPaste: scrollOnPasteRef?.current ?? false,
|
||||
onPasteData: broadcastUserPasteData,
|
||||
sessionId: sessionRef.current,
|
||||
|
||||
@@ -67,10 +67,20 @@ export function useTerminalFilePaste({
|
||||
// remote keyboard pastes; otherwise xterm's default handler would send
|
||||
// the lines without the safety dialog when neither image upload nor
|
||||
// local file handling applies.
|
||||
//
|
||||
// Windows clipboard history (Win+V) can deliver the picked item with an
|
||||
// empty paste event: the picker writes the system clipboard while the
|
||||
// window is unfocused and Chromium still serves its stale (empty)
|
||||
// clipboard view on the synthetic paste (#3582). Intercept these events
|
||||
// and re-read through the main-process bridge, which sees the live
|
||||
// system clipboard.
|
||||
const eventText = event.clipboardData?.getData("text/plain") ?? "";
|
||||
const emptyPasteEventRecovery = !eventText && !!bridge?.readClipboardText;
|
||||
const shouldInterceptPaste =
|
||||
wantsImageUpload
|
||||
|| canHandleLocalPaste
|
||||
|| !!multilinePasteConfirmRef?.current?.enabled;
|
||||
|| !!multilinePasteConfirmRef?.current?.enabled
|
||||
|| emptyPasteEventRecovery;
|
||||
if (!shouldInterceptPaste) return;
|
||||
|
||||
// ⚡ Must call preventDefault SYNCHRONOUSLY — the event lifecycle
|
||||
@@ -79,12 +89,6 @@ export function useTerminalFilePaste({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Capture the pasted text synchronously: navigator.clipboard.readText()
|
||||
// can be rejected (permissions / platform quirks) even though the event
|
||||
// already carries the text, so prefer it and only fall back to the
|
||||
// async clipboard API when the event has no text data.
|
||||
const eventText = event.clipboardData?.getData("text/plain") ?? "";
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const term = termRef.current;
|
||||
@@ -110,6 +114,17 @@ export function useTerminalFilePaste({
|
||||
onClipboardImageUploadResult,
|
||||
readClipboardText: async () => {
|
||||
if (eventText) return eventText;
|
||||
// Prefer the main-process bridge: the renderer's clipboard view
|
||||
// can lag behind a Windows clipboard history write (#3582).
|
||||
// Any successful string result is authoritative — including ""
|
||||
// (e.g. the clipboard was genuinely cleared) — so treat only a
|
||||
// missing bridge or thrown read as a fallback condition.
|
||||
try {
|
||||
const bridged = await bridge?.readClipboardText?.();
|
||||
if (typeof bridged === "string") return bridged;
|
||||
} catch (err) {
|
||||
logger.warn("Bridge clipboard read failed; falling back to navigator", err);
|
||||
}
|
||||
return navigator.clipboard.readText();
|
||||
},
|
||||
scrollOnPaste: scrollOnPasteRef?.current ?? false,
|
||||
|
||||
@@ -1113,3 +1113,26 @@ test("Shift+Enter send-text fallback snapshots prompt sensitivity before the loc
|
||||
/broadcastKittyInput\(\s*\{\s*kind: "key",\s*event: kittyEvent,\s*fallbackToLegacy: true,\s*\},\s*false,\s*undefined,\s*sourceSensitivePrompt \? \{ sourceSensitive: true \} : undefined\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("plain Ctrl+V keydowns that escape the menu accelerator route through the shared paste path (#3582)", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
|
||||
// Windows clipboard history (Win+V) injects a plain Ctrl+V keydown that the
|
||||
// Edit > Paste menu accelerator does not consume. Unclaimed, xterm encodes
|
||||
// it as bare \x16 and the shell shows ^V instead of pasting. The claim must
|
||||
// share the context-menu paste path so local image-only clipboards keep
|
||||
// their Ctrl+V forward and remote auto-upload stays gated.
|
||||
const claimIdx = source.indexOf("platform === \"win32\" && matchesPlainCtrlVChord(e)");
|
||||
assert.ok(claimIdx >= 0, "plain Ctrl+V claim must exist in the keydown handler");
|
||||
const claim = source.slice(claimIdx, claimIdx + 600);
|
||||
assert.match(claim, /platform === "win32" && matchesPlainCtrlVChord\(e\)/);
|
||||
// Auto-repeat Ctrl+V (held key) must be consumed without re-pasting, or
|
||||
// each repeat reaches xterm and is encoded as bare \x16.
|
||||
assert.match(claim, /if \(!e\.repeat\) void ctx\.terminalContextActionsRef\?\.current\?\.onPaste\?\.()/u);
|
||||
assert.match(claim, /return false;/);
|
||||
// The claim sits after the configured shortcut bindings so a re-mapped
|
||||
// paste chord keeps precedence.
|
||||
const bindingsIdx = source.indexOf("const currentBindings = ctx.keyBindingsRef.current;");
|
||||
assert.ok(bindingsIdx >= 0 && bindingsIdx < claimIdx);
|
||||
});
|
||||
@@ -172,6 +172,7 @@ import {
|
||||
type TerminalOutputHistoryPreview,
|
||||
} from "./terminalOutputHistory";
|
||||
import { shouldPassThroughCopyShortcut } from "./terminalCopyShortcut";
|
||||
import { matchesPlainCtrlVChord } from "./win32ClipboardHistoryPaste";
|
||||
import {
|
||||
isMacCommandPeriodInterruptChord,
|
||||
shouldUseUrgentTerminalInterrupt,
|
||||
@@ -2497,6 +2498,24 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
}
|
||||
|
||||
// Windows clipboard history (Win+V) delivers the picked item as a plain
|
||||
// Ctrl+V keydown that escapes the Edit > Paste menu accelerator (#3582).
|
||||
// Left to xterm's default handling it is encoded as bare \x16, so the
|
||||
// shell shows ^V instead of pasting. Claim the chord on Windows only —
|
||||
// on Linux plain Ctrl+V is terminal input (e.g. Readline's quoted-insert);
|
||||
// the PC paste binding there is Ctrl+Shift+V. Route the claimed chord
|
||||
// through the shared clipboard paste path, which reads the live system
|
||||
// clipboard via the main-process bridge.
|
||||
if (platform === "win32" && matchesPlainCtrlVChord(e)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Auto-repeat keydowns of a held Ctrl+V must be consumed too: left
|
||||
// unclaimed, each repeat reaches xterm and is encoded as bare \x16
|
||||
// (^V in the shell). Paste only on the initial keydown.
|
||||
if (!e.repeat) void ctx.terminalContextActionsRef?.current?.onPaste?.();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sogou/macOS CJK punctuation: keydown still reports ASCII "," while the
|
||||
// full-width "," arrives on insertText. Returning false (without
|
||||
// preventDefault) stops xterm/Kitty from sending the half-width key so the
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
isPlainCtrlVPasteChord,
|
||||
matchesPlainCtrlVChord,
|
||||
} from "./win32ClipboardHistoryPaste";
|
||||
|
||||
const keyboardEvent = (
|
||||
overrides: Partial<KeyboardEvent> = {},
|
||||
): KeyboardEvent => ({
|
||||
key: "v",
|
||||
code: "KeyV",
|
||||
ctrlKey: true,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
...overrides,
|
||||
}) as KeyboardEvent;
|
||||
|
||||
test("plain Ctrl+V from the Windows clipboard history picker is claimed", () => {
|
||||
assert.equal(isPlainCtrlVPasteChord(keyboardEvent()), true);
|
||||
});
|
||||
|
||||
test("plain Ctrl+V follows the physical V key on non-Latin layouts", () => {
|
||||
assert.equal(isPlainCtrlVPasteChord(keyboardEvent({ key: "м" })), true);
|
||||
});
|
||||
|
||||
test("Ctrl+Shift+V stays the configured paste binding", () => {
|
||||
assert.equal(isPlainCtrlVPasteChord(keyboardEvent({ key: "V", shiftKey: true })), false);
|
||||
});
|
||||
|
||||
test("chords with extra modifiers are not clipboard history pastes", () => {
|
||||
assert.equal(isPlainCtrlVPasteChord(keyboardEvent({ altKey: true })), false);
|
||||
assert.equal(isPlainCtrlVPasteChord(keyboardEvent({ metaKey: true })), false);
|
||||
assert.equal(isPlainCtrlVPasteChord(keyboardEvent({ ctrlKey: false })), false);
|
||||
});
|
||||
|
||||
test("auto-repeat keydowns are not claimed to avoid paste spam", () => {
|
||||
assert.equal(isPlainCtrlVPasteChord(keyboardEvent({ repeat: true })), false);
|
||||
});
|
||||
|
||||
test("auto-repeat keydowns still match the chord so the keydown claim can consume them", () => {
|
||||
assert.equal(matchesPlainCtrlVChord(keyboardEvent({ repeat: true })), true);
|
||||
assert.equal(matchesPlainCtrlVChord(keyboardEvent({ shiftKey: true, repeat: true })), false);
|
||||
});
|
||||
|
||||
test("other Ctrl chords are untouched", () => {
|
||||
assert.equal(isPlainCtrlVPasteChord(keyboardEvent({ key: "c", code: "KeyC" })), false);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
type PasteChordKeyEvent = Pick<
|
||||
KeyboardEvent,
|
||||
"key" | "code" | "ctrlKey" | "shiftKey" | "altKey" | "metaKey" | "repeat"
|
||||
>;
|
||||
|
||||
function isPhysicalVKey(e: PasteChordKeyEvent): boolean {
|
||||
return e.key?.toLowerCase() === "v" || e.code === "KeyV";
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain Ctrl+V as injected by the Windows clipboard history picker (Win+V).
|
||||
*
|
||||
* The picker delivers the chosen item by synthesizing a Ctrl+V keydown that
|
||||
* must not rely on the Electron Edit > Paste menu accelerator (#3582): when
|
||||
* the synthesized chord reaches the page unclaimed, xterm encodes it as bare
|
||||
* \x16 and the shell shows ^V instead of pasting. Netcatty claims the chord
|
||||
* and routes it through the shared clipboard paste path (which reads the live
|
||||
* system clipboard via the main-process bridge) instead.
|
||||
*/
|
||||
/**
|
||||
* Physical plain Ctrl+V chord, ignoring auto-repeat. Used by the keydown
|
||||
* claim so held-key repeats of a clipboard-history paste are consumed instead
|
||||
* of escaping to xterm (which would encode each repeat as bare \x16).
|
||||
*/
|
||||
export function matchesPlainCtrlVChord(e: PasteChordKeyEvent): boolean {
|
||||
return Boolean(e.ctrlKey)
|
||||
&& !e.shiftKey
|
||||
&& !e.altKey
|
||||
&& !e.metaKey
|
||||
&& isPhysicalVKey(e);
|
||||
}
|
||||
|
||||
export function isPlainCtrlVPasteChord(e: PasteChordKeyEvent): boolean {
|
||||
return !e.repeat && matchesPlainCtrlVChord(e);
|
||||
}
|
||||
@@ -111,3 +111,22 @@ test("bypassed context paste at a password prompt is marked sourceSensitive (#34
|
||||
{ options: { lineDelayMs: 250, sourceSensitive: true } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("context paste prefers the main-process clipboard read (#3582)", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const source = readFileSync(new URL("./hooks/useTerminalContextActions.ts", import.meta.url), "utf8");
|
||||
|
||||
// Windows clipboard history (Win+V) writes the picked item while the window
|
||||
// is unfocused, so the renderer's clipboard view can still be empty when
|
||||
// the synthetic paste arrives. The shared onPaste path must read the live
|
||||
// system clipboard through the main-process bridge first.
|
||||
const onPasteIdx = source.indexOf("const onPaste = useCallback");
|
||||
assert.ok(onPasteIdx >= 0);
|
||||
const onPaste = source.slice(onPasteIdx, onPasteIdx + 5000);
|
||||
assert.match(onPaste, /bridge\?\.readClipboardText\?\.\(\)/u);
|
||||
assert.match(onPaste, /navigator\.clipboard\.readText\(\)/u);
|
||||
assert.ok(
|
||||
onPaste.indexOf("bridge?.readClipboardText?.()")
|
||||
< onPaste.indexOf("navigator.clipboard.readText()"),
|
||||
);
|
||||
});
|
||||
@@ -897,11 +897,13 @@ function createBridgeRegistrar(context) {
|
||||
|
||||
// Clipboard helpers for renderer fallback paths (e.g. Monaco paste in Electron)
|
||||
ipcMain.handle("netcatty:clipboard:readText", async () => {
|
||||
try {
|
||||
return clipboard?.readText?.() || "";
|
||||
} catch {
|
||||
return "";
|
||||
// Reject on failure so renderer callers can fall back to their own
|
||||
// clipboard paths; only a successful read returns a string (possibly ""
|
||||
// for a genuinely empty clipboard).
|
||||
if (!clipboard || typeof clipboard.readText !== "function") {
|
||||
throw new Error("Clipboard API unavailable");
|
||||
}
|
||||
return clipboard.readText();
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:clipboard:writeText", async (_event, text) => {
|
||||
|
||||
Reference in new issue
Block a user