diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index ca7320d8..a9fe6f03 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -29,7 +29,7 @@ import { isAllowedPrintPreviewUrl } from "./window-open-policy.mjs"; import { showWindow } from "./window-visibility.mjs"; import { trayIconPath } from "./tray-icon.mjs"; import { writeRichClipboard } from "./clipboard-write.mjs"; -import { captureScreenToNote, screenshotImportIpcPayload, writeScreenshotTempPath } from "./screenshot-capture.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"; import { createRendererStartupGuard } from "./renderer-startup-guard.mjs"; @@ -508,7 +508,7 @@ const importMarkdownFile = async (filePath) => { let pendingMarkdownImport = null; let pendingScreenshotImport = null; -let screenshotCaptureInFlight = false; +const screenshotCaptureGuard = createScreenshotCaptureGuard(); let rendererReady = false; const flushPendingMarkdownImport = () => { @@ -525,6 +525,7 @@ const sendScreenshotImport = (payload) => { pendingScreenshotImport = ipcPayload; return; } + pendingScreenshotImport = null; mainWindow.webContents.send("desktop:import-screenshot", ipcPayload); }; @@ -537,8 +538,7 @@ const flushPendingScreenshotImport = () => { }; const captureScreenshotToNote = async () => { - if (screenshotCaptureInFlight) return; - screenshotCaptureInFlight = true; + if (!screenshotCaptureGuard.tryBegin()) return; const copy = desktopMenuCopy(app.getLocale()); const wasVisible = Boolean(mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()); const revealWindow = () => { @@ -572,7 +572,7 @@ const captureScreenshotToNote = async () => { message: copy.screenshotFailed, }); } finally { - screenshotCaptureInFlight = false; + screenshotCaptureGuard.end(); } }; diff --git a/apps/desktop/src/main/screenshot-capture.mjs b/apps/desktop/src/main/screenshot-capture.mjs index ae3f6306..ccab1c07 100644 --- a/apps/desktop/src/main/screenshot-capture.mjs +++ b/apps/desktop/src/main/screenshot-capture.mjs @@ -37,6 +37,38 @@ export const screenshotImportIpcPayload = (captured) => ({ bytes: normalizeIpcBytes(captured.bytes), }); +// Full-screen capture finishes in a few hundred milliseconds. macOS/Electron +// tray menus can deliver the same click twice after that, so keep the capture +// locked through a short cooldown instead of releasing it in the same tick. +export const SCREENSHOT_CAPTURE_COOLDOWN_MS = 2000; + +export const createScreenshotCaptureGuard = ({ + cooldownMs = SCREENSHOT_CAPTURE_COOLDOWN_MS, + schedule = setTimeout, + cancel = clearTimeout, +} = {}) => { + let inFlight = false; + let cooldownTimer = null; + return { + tryBegin() { + if (inFlight) return false; + inFlight = true; + if (cooldownTimer != null) { + cancel(cooldownTimer); + cooldownTimer = null; + } + return true; + }, + end() { + if (cooldownTimer != null) cancel(cooldownTimer); + cooldownTimer = schedule(() => { + inFlight = false; + cooldownTimer = null; + }, cooldownMs); + }, + }; +}; + export const isChineseDesktopLocale = (locale) => typeof locale === "string" && locale.toLowerCase().startsWith("zh"); diff --git a/apps/desktop/src/main/screenshot-capture.test.mjs b/apps/desktop/src/main/screenshot-capture.test.mjs index 90860e55..2d16f4b2 100644 --- a/apps/desktop/src/main/screenshot-capture.test.mjs +++ b/apps/desktop/src/main/screenshot-capture.test.mjs @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { captureDisplayScreenshot, + createScreenshotCaptureGuard, isScreenshotCancelledExit, macScreencaptureArgs, normalizeIpcBytes, @@ -85,18 +86,42 @@ describe("screenshot capture helpers", () => { }); }); +describe("screenshot capture guard", () => { + test("rejects a second capture until the cooldown elapses", () => { + const timers = []; + const guard = createScreenshotCaptureGuard({ + cooldownMs: 20, + schedule: (fn) => { + timers.push(fn); + return timers.length; + }, + cancel: () => {}, + }); + expect(guard.tryBegin()).toBe(true); + expect(guard.tryBegin()).toBe(false); + guard.end(); + expect(guard.tryBegin()).toBe(false); + timers.at(-1)(); + expect(guard.tryBegin()).toBe(true); + }); +}); + describe("desktop screenshot to note wiring", () => { test("replaces the tray sync and backup actions with screenshot capture", () => { expect(mainSource).toContain("captureScreenshotToNote"); expect(mainSource).toContain("captureScreenToNote"); + expect(mainSource).toContain("createScreenshotCaptureGuard"); + expect(mainSource).toContain("pendingScreenshotImport = null"); expect(mainSource).not.toContain("overlay.html"); expect(mainSource).toContain("copy.screenshotToNote"); expect(mainSource).not.toContain('label: copy.syncNow, click: () => sendDesktopCommand("sync-now")'); expect(mainSource).not.toContain('label: copy.backupNow, click: () => sendDesktopCommand("backup-now")'); expect(preloadSource).toContain("onImportScreenshot"); + expect(preloadSource).toContain("screenshotImportListener"); expect(preloadSource).toContain('value.type === "Buffer"'); expect(workspaceSource).toContain("onImportScreenshot"); expect(workspaceSource).toContain("createScreenshotMemo"); + expect(workspaceSource).toContain("screenshotImportGate"); expect(workspaceSource).toContain("screenshotFileFromImportPayload"); expect(workspaceSource).not.toContain("setPendingEditorInsert"); }); diff --git a/apps/desktop/src/preload/index.cjs b/apps/desktop/src/preload/index.cjs index f42520f8..29c3300f 100644 --- a/apps/desktop/src/preload/index.cjs +++ b/apps/desktop/src/preload/index.cjs @@ -21,6 +21,8 @@ const normalizeIpcBytes = (value) => { return new Uint8Array(); }; +let screenshotImportListener = null; + contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({ isAvailable: true, canClearLocalData: ipcRenderer.sendSync("desktop:local-data-reset-available-sync"), @@ -86,10 +88,17 @@ contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({ return () => ipcRenderer.removeListener("desktop:import-markdown", listener); }, onImportScreenshot: (callback) => { + if (screenshotImportListener) { + ipcRenderer.removeListener("desktop:import-screenshot", screenshotImportListener); + } const listener = (_event, payload) => { callback({ ...payload, bytes: normalizeIpcBytes(payload?.bytes) }); }; + screenshotImportListener = listener; ipcRenderer.on("desktop:import-screenshot", listener); - return () => ipcRenderer.removeListener("desktop:import-screenshot", listener); + return () => { + ipcRenderer.removeListener("desktop:import-screenshot", listener); + if (screenshotImportListener === listener) screenshotImportListener = null; + }; }, })); diff --git a/apps/web/src/lib/screenshot-import.test.mjs b/apps/web/src/lib/screenshot-import.test.mjs index 573fff16..fb979dae 100644 --- a/apps/web/src/lib/screenshot-import.test.mjs +++ b/apps/web/src/lib/screenshot-import.test.mjs @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test"; import { + createScreenshotImportGate, createScreenshotMemo, normalizeScreenshotBytes, screenshotFileFromImportPayload, + screenshotImportDedupeKey, screenshotNoteContent, } from "./screenshot-import.ts"; @@ -120,3 +122,37 @@ describe("screenshot note content", () => { expect(deleted).toEqual(["memo_failed"]); }); }); + +describe("screenshot import gate", () => { + test("builds a stable key from the title, filename, and byte length", () => { + expect(screenshotImportDedupeKey({ + name: "screenshot-20260915-222604.png", + title: "截图 2026-09-15 22:26", + bytes: new Uint8Array(4), + })).toBe(["截图 2026-09-15 22:26", "screenshot-20260915-222604.png", "4"].join("\u0000")); + }); + + test("drops a second import while the first screenshot is still being saved", () => { + const gate = createScreenshotImportGate(1000); + expect(gate.tryBegin("a")).toBe(true); + expect(gate.tryBegin("a")).toBe(false); + expect(gate.tryBegin("b")).toBe(false); + }); + + test("ignores the same screenshot payload during the cooldown after a successful import", () => { + const gate = createScreenshotImportGate(1000); + expect(gate.tryBegin("a")).toBe(true); + gate.finish("a", 0); + expect(gate.tryBegin("a", 500)).toBe(false); + expect(gate.tryBegin("b", 500)).toBe(true); + gate.fail("b"); + expect(gate.tryBegin("a", 1500)).toBe(true); + }); + + test("allows a retry after a failed import", () => { + const gate = createScreenshotImportGate(1000); + expect(gate.tryBegin("a")).toBe(true); + gate.fail("a"); + expect(gate.tryBegin("a")).toBe(true); + }); +}); diff --git a/apps/web/src/lib/screenshot-import.ts b/apps/web/src/lib/screenshot-import.ts index 567c6940..60a9b9e2 100644 --- a/apps/web/src/lib/screenshot-import.ts +++ b/apps/web/src/lib/screenshot-import.ts @@ -32,6 +32,36 @@ export const screenshotFileFromImportPayload = (payload: { return new File([blobPart], payload.name || "screenshot.png", { type: payload.type || "image/png" }); }; +export const screenshotImportDedupeKey = (payload: { + name?: string; + title?: string; + bytes?: { byteLength?: number } | null; +}) => [payload.title?.trim() || "", payload.name || "", String(payload.bytes?.byteLength ?? 0)].join("\u0000"); + +export const createScreenshotImportGate = (cooldownMs = 2000) => { + let inFlightKey: string | null = null; + let lastKey: string | null = null; + let lastAt = 0; + return { + tryBegin(key: string, now = Date.now()) { + if (inFlightKey) return false; + if (lastKey === key && now - lastAt < cooldownMs) return false; + inFlightKey = key; + return true; + }, + finish(key: string, now = Date.now()) { + if (inFlightKey === key) inFlightKey = null; + lastKey = key; + lastAt = now; + }, + fail(key: string) { + if (inFlightKey === key) inFlightKey = null; + }, + }; +}; + +export const screenshotImportGate = createScreenshotImportGate(); + const escapeMarkdownImageAlt = (value: string) => value.replaceAll("\\", "\\\\").replaceAll("]", "\\]"); export const screenshotNoteContent = (filename: string, url: string) => {