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:
1 parent
5780e009db
commit
ba22e2cb74
6 files changed
+138
-6
No files matched your search
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user