fix(web): copy assistant code blocks in the desktop app
This commit is contained in:
1 parent
e1210c664b
commit
27936a3b25
3 files changed
+132
-1
No files matched your search
@@ -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 = [];
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user