fix(desktop): keep one tray screenshot from creating two notes

Full-screen capture finishes before Electron can deliver a duplicate tray
click, so keep the capture locked through a short cooldown and drop a
second import of the same screenshot.
This commit is contained in:
tianma-if committed 2026-09-15 23:02:12 +08:00
1 parent 5780e009db
commit ba22e2cb74
6 files changed
+138 -6

No files matched your search

+5 -5
View File
@@ -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();
}
};
@@ -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");
@@ -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");
});
+10 -1
View File
@@ -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;
};
},
}));
@@ -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);
});
});
+30
View File
@@ -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) => {