From c73367315040def082c5f1275ec39f6196653e39 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 13 Jul 2026 22:59:58 +0800 Subject: [PATCH] Fix Firefox RDP clipboard paste (#1033) --- .../features/guacamole/GuacamoleDisplay.tsx | 33 +++++++- .../guacamole/guacamole-clipboard.test.ts | 77 +++++++++++++++++++ .../features/guacamole/guacamole-clipboard.ts | 43 +++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 src/ui/features/guacamole/guacamole-clipboard.test.ts create mode 100644 src/ui/features/guacamole/guacamole-clipboard.ts diff --git a/src/ui/features/guacamole/GuacamoleDisplay.tsx b/src/ui/features/guacamole/GuacamoleDisplay.tsx index 5643d92b..c3e5ef27 100644 --- a/src/ui/features/guacamole/GuacamoleDisplay.tsx +++ b/src/ui/features/guacamole/GuacamoleDisplay.tsx @@ -12,6 +12,11 @@ import { getGuacamoleToken, isElectron, isEmbeddedMode } from "@/main-axios.ts"; import { SimpleLoader } from "@/lib/SimpleLoader.tsx"; import { getBasePath } from "@/lib/base-path.ts"; import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts"; +import { + isFirefoxBrowser, + isPasteShortcut, + pasteTextToRemote, +} from "./guacamole-clipboard.ts"; export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet"; @@ -338,6 +343,32 @@ export const GuacamoleDisplay = forwardRef< displayElement.setAttribute("tabindex", "0"); displayElement.style.outline = "none"; + const useNativePasteFallback = isFirefoxBrowser(); + if (useNativePasteFallback) { + displayElement.addEventListener( + "keydown", + (event) => { + if (isPasteShortcut(event)) { + event.stopImmediatePropagation(); + } + }, + true, + ); + displayElement.addEventListener( + "paste", + (event) => { + if (clientRef.current !== client) return; + const text = event.clipboardData?.getData("text/plain"); + if (!text) return; + + event.preventDefault(); + event.stopImmediatePropagation(); + pasteTextToRemote(client, text); + }, + true, + ); + } + display.onresize = () => { if (!isMountedRef.current) return; rescaleDisplay(true); @@ -576,7 +607,7 @@ export const GuacamoleDisplay = forwardRef< const syncClipboard = useCallback(() => { const client = clientRef.current; - if (!client || !navigator.clipboard?.readText) return; + if (!client || isFirefoxBrowser() || !navigator.clipboard?.readText) return; navigator.clipboard .readText() .then((text) => { diff --git a/src/ui/features/guacamole/guacamole-clipboard.test.ts b/src/ui/features/guacamole/guacamole-clipboard.test.ts new file mode 100644 index 00000000..ee22d4f8 --- /dev/null +++ b/src/ui/features/guacamole/guacamole-clipboard.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; +import { + isFirefoxBrowser, + isPasteShortcut, + pasteTextToRemote, + type GuacamoleClipboardClient, +} from "./guacamole-clipboard.js"; + +describe("Guacamole Firefox clipboard fallback", () => { + it("only enables the native paste path for Firefox", () => { + expect( + isFirefoxBrowser( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0", + ), + ).toBe(true); + expect( + isFirefoxBrowser( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140.0.0.0", + ), + ).toBe(false); + }); + + it("recognizes Ctrl+V and Command+V without intercepting Alt+V", () => { + expect( + isPasteShortcut({ + key: "v", + ctrlKey: true, + metaKey: false, + altKey: false, + }), + ).toBe(true); + expect( + isPasteShortcut({ + key: "V", + ctrlKey: false, + metaKey: true, + altKey: false, + }), + ).toBe(true); + expect( + isPasteShortcut({ + key: "v", + ctrlKey: true, + metaKey: false, + altKey: true, + }), + ).toBe(false); + }); + + it("updates the remote clipboard before sending Ctrl+V", () => { + const events: string[] = []; + const client: GuacamoleClipboardClient = { + createClipboardStream: vi.fn((mimetype: string) => { + events.push(`stream:${mimetype}`); + return { + sendBlob: () => events.push("blob"), + sendEnd: () => events.push("end"), + }; + }), + sendKeyEvent: vi.fn((pressed: number, keysym: number) => { + events.push(`key:${pressed}:${keysym}`); + }), + }; + + pasteTextToRemote(client, "Firefox clipboard"); + + expect(events).toEqual([ + "stream:text/plain", + "blob", + "end", + "key:1:65507", + "key:1:118", + "key:0:118", + "key:0:65507", + ]); + }); +}); diff --git a/src/ui/features/guacamole/guacamole-clipboard.ts b/src/ui/features/guacamole/guacamole-clipboard.ts new file mode 100644 index 00000000..8dc176ec --- /dev/null +++ b/src/ui/features/guacamole/guacamole-clipboard.ts @@ -0,0 +1,43 @@ +import Guacamole from "guacamole-common-js"; + +const CONTROL_LEFT_KEYSYM = 0xffe3; +const V_KEYSYM = 0x76; + +interface ClipboardOutputStream { + sendBlob(data: string): void; + sendEnd(): void; +} + +export interface GuacamoleClipboardClient { + createClipboardStream(mimetype: string): ClipboardOutputStream; + sendKeyEvent(pressed: number, keysym: number): void; +} + +export function isFirefoxBrowser(userAgent = navigator.userAgent): boolean { + return /(?:Firefox|FxiOS)\//.test(userAgent); +} + +export function isPasteShortcut( + event: Pick, +): boolean { + return ( + !event.altKey && + (event.ctrlKey || event.metaKey) && + event.key.toLowerCase() === "v" + ); +} + +export function pasteTextToRemote( + client: GuacamoleClipboardClient, + text: string, +): void { + const stream = client.createClipboardStream("text/plain"); + const writer = new Guacamole.StringWriter(stream); + writer.sendText(text); + writer.sendEnd(); + + client.sendKeyEvent(1, CONTROL_LEFT_KEYSYM); + client.sendKeyEvent(1, V_KEYSYM); + client.sendKeyEvent(0, V_KEYSYM); + client.sendKeyEvent(0, CONTROL_LEFT_KEYSYM); +}