fix(web): copy assistant code blocks in the desktop app

This commit is contained in:
tianma-if committed 2026-10-23 22:23:46 +08:00
1 parent e1210c664b
commit 27936a3b25
3 files changed
+132 -1

No files matched your search

+71 -1
View File
@@ -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" ? "<pre>A-->B</pre>" : "A-->B"], { type }),
}]);
expect(calls).toEqual([
["text", "graph TD\n A-->B"],
["html", "<pre>A-->B</pre>", "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 = [];
+58
View File
@@ -186,3 +186,61 @@ export const copyImageBlobToClipboard = async (blob: Blob): Promise<boolean> =>
}
};
const desktopClipboardBridges = new WeakSet<object>();
const clipboardWriteFailed = () => new DOMException("Clipboard write failed", "NotAllowedError");
const clipboardItemText = async (item: ClipboardItem, type: string) => (await item.getType(type)).text();
const imageClipboardType = (types: ReadonlyArray<string>) => (
["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;
};
+3
View File
@@ -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();