diff --git a/apps/desktop/src/main/clipboard-write.mjs b/apps/desktop/src/main/clipboard-write.mjs index e640fc66..827dcc2b 100644 --- a/apps/desktop/src/main/clipboard-write.mjs +++ b/apps/desktop/src/main/clipboard-write.mjs @@ -7,6 +7,34 @@ export const writeTextClipboard = async (clipboard, value) => { return await clipboard.readText() === value; }; +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; +const MAX_IMAGE_CLIPBOARD_BYTES = 32 * 1024 * 1024; + +const pngBytes = (value) => { + let bytes = null; + if (value instanceof Uint8Array) bytes = value; + else if (value instanceof ArrayBuffer) bytes = new Uint8Array(value); + else if (ArrayBuffer.isView(value)) bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + else if (value && value.type === "Buffer" && Array.isArray(value.data)) bytes = Uint8Array.from(value.data); + else throw new TypeError("Clipboard image must be PNG bytes"); + + if (bytes.byteLength === 0 || bytes.byteLength > MAX_IMAGE_CLIPBOARD_BYTES) return null; + for (let index = 0; index < PNG_SIGNATURE.length; index += 1) { + if (bytes[index] !== PNG_SIGNATURE[index]) return null; + } + return bytes; +}; + +export const writeImageClipboard = async (clipboard, ClipboardItem, value) => { + const bytes = pngBytes(value); + if (!bytes) return false; + + await clipboard.write([new ClipboardItem({ + "image/png": new Blob([bytes], { type: "image/png" }), + })]); + return await clipboard.has("image/png"); +}; + export const writeRichClipboard = async (clipboard, ClipboardItem, input) => { const html = input?.html; const plainText = input?.plainText; diff --git a/apps/desktop/src/main/clipboard-write.test.mjs b/apps/desktop/src/main/clipboard-write.test.mjs index 7b15171a..d34a7103 100644 --- a/apps/desktop/src/main/clipboard-write.test.mjs +++ b/apps/desktop/src/main/clipboard-write.test.mjs @@ -1,5 +1,18 @@ import { describe, expect, test } from "bun:test"; -import { writeRichClipboard, writeTextClipboard } from "./clipboard-write.mjs"; +import { readFileSync } from "node:fs"; +import { writeImageClipboard, writeRichClipboard, writeTextClipboard } from "./clipboard-write.mjs"; + +const PNG = Uint8Array.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, + 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, + 0x00, 0x00, 0x03, 0x00, 0x01, 0x18, 0xdd, 0x8d, + 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, + 0x44, 0xae, 0x42, 0x60, 0x82, +]); class TestClipboardItem { constructor(data) { @@ -42,6 +55,48 @@ describe("writeTextClipboard", () => { }); }); +describe("writeImageClipboard", () => { + test("writes PNG bytes and verifies the image is on the clipboard", async () => { + const writes = []; + const clipboard = { + write: async (value) => writes.push(value), + has: async (type) => type === "image/png", + }; + + await expect(writeImageClipboard(clipboard, TestClipboardItem, PNG)).resolves.toBe(true); + expect(writes).toHaveLength(1); + const blob = writes[0][0].data["image/png"]; + expect(blob).toBeInstanceOf(Blob); + expect(blob.type).toBe("image/png"); + expect(new Uint8Array(await blob.arrayBuffer())).toEqual(PNG); + }); + + test("does not report success when the image cannot be read back", async () => { + const clipboard = { + write: async () => {}, + has: async () => false, + }; + + await expect(writeImageClipboard(clipboard, TestClipboardItem, PNG)).resolves.toBe(false); + }); + + test("rejects a non-image payload and ignores bytes that are not a PNG", async () => { + const clipboard = { write: async () => {}, has: async () => true }; + await expect(writeImageClipboard(clipboard, TestClipboardItem, "nope")).rejects.toThrow( + "Clipboard image must be PNG bytes", + ); + await expect(writeImageClipboard(clipboard, TestClipboardItem, Uint8Array.from([1, 2, 3]))).resolves.toBe(false); + await expect(writeImageClipboard(clipboard, TestClipboardItem, new Uint8Array())).resolves.toBe(false); + }); + + test("desktop image copy is wired through the main process", () => { + const main = readFileSync(new URL("./index.mjs", import.meta.url), "utf8"); + const preload = readFileSync(new URL("../preload/index.cjs", import.meta.url), "utf8"); + expect(main).toContain('ipcMain.handle("desktop:copy-image"'); + expect(preload).toContain('ipcRenderer.invoke("desktop:copy-image"'); + }); +}); + describe("writeRichClipboard", () => { test("writes and verifies both rich HTML and plain text with the Electron 44 API", async () => { const writes = []; diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index 1f1dae08..1b6de6da 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -30,7 +30,7 @@ import { userDataDirectoryFromArguments } from "./user-data-directory.mjs"; import { isAllowedPrintPreviewUrl } from "./window-open-policy.mjs"; import { showWindow } from "./window-visibility.mjs"; import { trayIconPath } from "./tray-icon.mjs"; -import { writeRichClipboard, writeTextClipboard } from "./clipboard-write.mjs"; +import { writeImageClipboard, writeRichClipboard, writeTextClipboard } from "./clipboard-write.mjs"; import { captureScreenToNote, createScreenshotCaptureGuard, screenshotImportIpcPayload, writeScreenshotTempPath } from "./screenshot-capture.mjs"; import { LocalDataResetError, scheduleMacLocalDataReset } from "./local-data-reset.mjs"; import { buildDesktopDiagnosticIssueUrl, normalizeDesktopDiagnostic } from "./desktop-diagnostics.mjs"; @@ -1531,6 +1531,7 @@ const startApplication = async () => { ipcMain.on("desktop:recovered-after-abnormal-exit-sync", (event) => { event.returnValue = recoveredAfterAbnormalExit; }); ipcMain.handle("desktop:copy-text", (_event, value) => writeTextClipboard(clipboard, value)); ipcMain.handle("desktop:copy-html", (_event, input) => writeRichClipboard(clipboard, ClipboardItem, input)); + ipcMain.handle("desktop:copy-image", (_event, bytes) => writeImageClipboard(clipboard, ClipboardItem, bytes)); ipcMain.handle("desktop:set-session-token", async (_event, value) => { await saveDesktopSessionToken(value); return { stored: Boolean(desktopSessionToken) }; diff --git a/apps/desktop/src/preload/index.cjs b/apps/desktop/src/preload/index.cjs index 2888a3d6..49e2f1fe 100644 --- a/apps/desktop/src/preload/index.cjs +++ b/apps/desktop/src/preload/index.cjs @@ -36,6 +36,7 @@ contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({ getSessionToken: () => ipcRenderer.sendSync("desktop:session-token-sync"), copyText: (value) => ipcRenderer.invoke("desktop:copy-text", value), copyHtml: (html, plainText) => ipcRenderer.invoke("desktop:copy-html", { html, plainText }), + copyImage: (bytes) => ipcRenderer.invoke("desktop:copy-image", bytes), setSessionToken: (value) => ipcRenderer.invoke("desktop:set-session-token", value), clearSessionToken: () => ipcRenderer.invoke("desktop:clear-session-token"), publicNetworkFetch: (requestId, input) => ipcRenderer.invoke("desktop:public-network-fetch", requestId, input), diff --git a/apps/web/src/components/dialogs/ShareNoteImageDialog.tsx b/apps/web/src/components/dialogs/ShareNoteImageDialog.tsx index 9e2afde2..0b892ebe 100644 --- a/apps/web/src/components/dialogs/ShareNoteImageDialog.tsx +++ b/apps/web/src/components/dialogs/ShareNoteImageDialog.tsx @@ -86,8 +86,9 @@ export const ShareNoteImageDialog = ({ const [prepared, setPrepared] = useState(null); const [previewUrl, setPreviewUrl] = useState(""); const [error, setError] = useState(false); - const [copied, setCopied] = useState(false); + const [copyState, setCopyState] = useState<"idle" | "copying" | "copied" | "failed">("idle"); const generationRef = useRef(0); + const copyAttemptRef = useRef(0); useEffect(() => { if (!open) return; @@ -101,12 +102,14 @@ export const ShareNoteImageDialog = ({ setShowTags(false); setShowUpdatedAt(true); setShowBranding(true); - setCopied(false); + setCopyState("idle"); }, [open, source.title]); useEffect(() => { if (!open) return; const generation = ++generationRef.current; + copyAttemptRef.current += 1; + setCopyState("idle"); setPrepared(null); setError(false); const timer = window.setTimeout(() => { @@ -170,12 +173,20 @@ export const ShareNoteImageDialog = ({ const noticeKind = prepared ? getHtmlImageEmbedNoticeKind(prepared.images) : "none"; const handleCopyImage = async () => { - if (!prepared) return; + if (!prepared || copyState === "copying") return; + const attempt = ++copyAttemptRef.current; + setCopyState("copying"); const success = await copyImageBlobToClipboard(prepared.blob); - if (success) { - setCopied(true); - window.setTimeout(() => setCopied(false), 2000); + if (attempt !== copyAttemptRef.current) return; + if (!success) { + setCopyState("failed"); + return; } + setCopyState("copied"); + window.setTimeout(() => { + if (attempt !== copyAttemptRef.current) return; + setCopyState((current) => (current === "copied" ? "idle" : current)); + }, 2000); }; const share = async () => { @@ -388,6 +399,11 @@ export const ShareNoteImageDialog = ({ + {copyState === "failed" ? ( +

+ {t("editor.imageShare.copyFailed")} +

+ ) : null}