diff --git a/apps/web/src/lib/clipboard.test.mjs b/apps/web/src/lib/clipboard.test.mjs index 94363dee..c94c1b9f 100644 --- a/apps/web/src/lib/clipboard.test.mjs +++ b/apps/web/src/lib/clipboard.test.mjs @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; -import { copyHtmlToClipboard, copyImageBlobToClipboard, copyImageUrlToClipboard, copyTextToClipboard } from "./clipboard.ts"; +import { copyHtmlToClipboard, copyImageBlobToClipboard, copyImageUrlToClipboard, copyTextToClipboard, installDesktopClipboardBridge } from "./clipboard.ts"; const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator"); @@ -71,6 +71,76 @@ describe("copyTextToClipboard", () => { }); }); +describe("installDesktopClipboardBridge", () => { + test("routes renderer clipboard writes through the desktop bridge", async () => { + const calls = []; + const browserWrites = []; + globalThis.window = { + edgeeverDesktop: { + isAvailable: true, + copyText: async (value) => { + calls.push(["text", value]); + return true; + }, + copyHtml: async (html, plainText) => { + calls.push(["html", html, plainText]); + return true; + }, + }, + }; + const clipboard = { + writeText: async (value) => browserWrites.push(["writeText", value]), + write: async (items) => browserWrites.push(["write", items]), + }; + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { clipboard } }); + + expect(installDesktopClipboardBridge()).toBe(true); + expect(installDesktopClipboardBridge()).toBe(false); + await clipboard.writeText("graph TD\n A-->B"); + await clipboard.write([{ + types: ["text/plain", "text/html"], + getType: async (type) => new Blob([type === "text/html" ? "
A-->B
" : "A-->B"], { type }), + }]); + + expect(calls).toEqual([ + ["text", "graph TD\n A-->B"], + ["html", "
A-->B
", "A-->B"], + ]); + expect(browserWrites).toEqual([]); + }); + + test("rejects when the desktop clipboard bridge cannot verify the write", async () => { + globalThis.window = { + edgeeverDesktop: { + isAvailable: true, + copyText: async () => false, + }, + }; + const clipboard = { writeText: async () => { throw new Error("browser clipboard should not be used"); } }; + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { clipboard } }); + + expect(installDesktopClipboardBridge()).toBe(true); + await expect(clipboard.writeText("source")).rejects.toThrow("Clipboard write failed"); + }); + + test("leaves the browser clipboard alone outside the desktop app", () => { + const writeText = async () => {}; + globalThis.window = {}; + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { clipboard: { writeText } }, + }); + + expect(installDesktopClipboardBridge()).toBe(false); + expect(navigator.clipboard.writeText).toBe(writeText); + }); + + test("the desktop shell installs the bridge before the app renders", () => { + const source = readFileSync(new URL("../main.tsx", import.meta.url), "utf8"); + expect(source).toContain("installDesktopClipboardBridge()"); + }); +}); + describe("copyHtmlToClipboard", () => { test("uses the native rich clipboard bridge in the desktop app", async () => { const calls = []; diff --git a/apps/web/src/lib/clipboard.ts b/apps/web/src/lib/clipboard.ts index ea2d6288..713a0535 100644 --- a/apps/web/src/lib/clipboard.ts +++ b/apps/web/src/lib/clipboard.ts @@ -186,3 +186,61 @@ export const copyImageBlobToClipboard = async (blob: Blob): Promise => } }; +const desktopClipboardBridges = new WeakSet(); + +const clipboardWriteFailed = () => new DOMException("Clipboard write failed", "NotAllowedError"); + +const clipboardItemText = async (item: ClipboardItem, type: string) => (await item.getType(type)).text(); + +const imageClipboardType = (types: ReadonlyArray) => ( + ["image/png", "image/jpeg", "image/webp", "image/gif"].find((type) => types.includes(type)) +); + +// Electron denies every renderer permission request, including clipboard-write. +// Streamdown's code, diagram, and table controls call navigator.clipboard and +// ignore the rejection, so those buttons do nothing in the desktop app. +const writeDesktopClipboardText = async (data: string) => { + if (!await copyTextToClipboard(data)) throw clipboardWriteFailed(); +}; + +const writeDesktopClipboardItems = async (items: ClipboardItems) => { + if (!items.length) throw clipboardWriteFailed(); + for (const item of items) { + const types = item.types; + if (types.includes("text/html")) { + const plain = types.includes("text/plain") ? await clipboardItemText(item, "text/plain") : ""; + await copyHtmlToClipboard(await clipboardItemText(item, "text/html"), plain); + continue; + } + const imageType = imageClipboardType(types); + if (imageType) { + if (!await copyImageBlobToClipboard(await item.getType(imageType))) throw clipboardWriteFailed(); + continue; + } + if (types.includes("text/plain")) { + if (!await copyTextToClipboard(await clipboardItemText(item, "text/plain"))) throw clipboardWriteFailed(); + continue; + } + throw clipboardWriteFailed(); + } +}; + +const assignClipboardMethod = (clipboard: Clipboard, name: "write" | "writeText", value: Clipboard["write"] | Clipboard["writeText"]) => { + try { + if (name === "write") clipboard.write = value as Clipboard["write"]; + else clipboard.writeText = value as Clipboard["writeText"]; + } catch { + Object.defineProperty(clipboard, name, { configurable: true, enumerable: true, writable: true, value }); + } +}; + +export const installDesktopClipboardBridge = () => { + if (typeof window === "undefined" || window.edgeeverDesktop?.isAvailable !== true) return false; + const clipboard = navigator.clipboard; + if (!clipboard || desktopClipboardBridges.has(clipboard)) return false; + assignClipboardMethod(clipboard, "writeText", writeDesktopClipboardText); + assignClipboardMethod(clipboard, "write", writeDesktopClipboardItems); + desktopClipboardBridges.add(clipboard); + return true; +}; + diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index a9c7b8b1..f8cb760c 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,6 +9,7 @@ import { emitPwaUpdateNotice } from "./lib/pwa-update-notice"; import { withEnvironmentTitlePrefix } from "./lib/environment-title"; import { initializeTheme, ThemeProvider } from "./components/ThemeProvider"; import { installPhoneImageFillStyle } from "@edgeever/shared/image-display"; +import { installDesktopClipboardBridge } from "./lib/clipboard"; import { applyEditorBodyFontPreference } from "./lib/editor-body-font"; import { installEditorBodyFontFaces } from "./lib/editor-body-font-faces"; import { applyUiFontPreference } from "./lib/ui-font"; @@ -18,6 +19,7 @@ import "./styles/globals.css"; import "./styles/editor-body-fonts.css"; installPhoneImageFillStyle(); +installDesktopClipboardBridge(); const PWA_UPDATE_CHECK_INTERVAL_MS = 10 * 60 * 1_000; const DEVELOPMENT_PWA_RELOAD_KEY = "edgeever.dev-pwa-reset"; @@ -120,6 +122,7 @@ const mountApp = () => { } initializeTheme(); + installDesktopClipboardBridge(); installEditorBodyFontFaces(); applyEditorBodyFontPreference(); applyUiFontPreference();