feat(desktop): add configurable global window shortcut

Refs #495
This commit is contained in:
tianma-if committed 2026-10-08 17:03:19 +08:00
1 parent 4481ca22f9
commit 6cb09f4007
11 files changed
+452 -45

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
const SUPPORTED_KEY = /^(?:[a-z]|[0-9]|f(?:[1-9]|1[0-2]))$/;
export const normalizeGlobalShortcutBinding = (value, platform = process.platform) => {
if (value === null) return null;
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const key = typeof value.key === "string" ? value.key.toLowerCase() : "";
if (!SUPPORTED_KEY.test(key)) return undefined;
if (["ctrl", "meta", "alt", "shift"].some((modifier) => typeof value[modifier] !== "boolean")) return undefined;
if (platform !== "darwin" && value.meta) return undefined;
const modifiers = [value.ctrl, value.meta, value.alt, value.shift].filter(Boolean).length;
if (modifiers < 2) return undefined;
return { key, ctrl: value.ctrl, meta: value.meta, alt: value.alt, shift: value.shift };
};
export const globalShortcutAccelerator = (binding) => {
if (!binding) return null;
return [
binding.ctrl && "Control",
binding.meta && "Command",
binding.alt && "Alt",
binding.shift && "Shift",
binding.key.toUpperCase(),
].filter(Boolean).join("+");
};
export const toggleMainWindow = ({ window, showWindow, activateApp }) => {
if (!window || window.isDestroyed()) return false;
if (window.isVisible() && !window.isMinimized() && window.isFocused()) {
window.hide();
return true;
}
activateApp?.();
showWindow(window);
return true;
};
export const replaceGlobalShortcut = async ({ globalShortcut, previous, next, callback, persist }) => {
const oldAccelerator = globalShortcutAccelerator(previous);
const nextAccelerator = globalShortcutAccelerator(next);
if (nextAccelerator && nextAccelerator !== oldAccelerator) {
try {
if (!globalShortcut.register(nextAccelerator, callback)) return { ok: false, reason: "unavailable" };
} catch {
return { ok: false, reason: "unavailable" };
}
}
try {
await persist(next);
} catch {
if (nextAccelerator && nextAccelerator !== oldAccelerator) globalShortcut.unregister(nextAccelerator);
return { ok: false, reason: "saveFailed" };
}
if (oldAccelerator && oldAccelerator !== nextAccelerator) globalShortcut.unregister(oldAccelerator);
return { ok: true };
};
@@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test";
import {
globalShortcutAccelerator,
normalizeGlobalShortcutBinding,
replaceGlobalShortcut,
toggleMainWindow,
} from "./global-shortcut.mjs";
const oldBinding = { key: "w", ctrl: true, meta: false, alt: true, shift: false };
const newBinding = { key: "r", ctrl: true, meta: false, alt: true, shift: false };
describe("desktop global shortcut", () => {
test("accepts safe key combinations and rejects single modifiers or unsupported keys", () => {
expect(normalizeGlobalShortcutBinding(oldBinding, "win32")).toEqual(oldBinding);
expect(globalShortcutAccelerator(oldBinding)).toBe("Control+Alt+W");
expect(normalizeGlobalShortcutBinding({ ...oldBinding, alt: false }, "win32")).toBeUndefined();
expect(normalizeGlobalShortcutBinding({ ...oldBinding, key: "escape" }, "win32")).toBeUndefined();
expect(normalizeGlobalShortcutBinding({ ...oldBinding, meta: true }, "linux")).toBeUndefined();
expect(normalizeGlobalShortcutBinding(null, "win32")).toBeNull();
});
test("shows a background window and hides only a focused window", () => {
const calls = [];
const window = {
isDestroyed: () => false,
isVisible: () => true,
isMinimized: () => false,
isFocused: () => false,
hide: () => calls.push("hide"),
};
const showWindow = () => calls.push("show");
toggleMainWindow({ window, showWindow });
expect(calls).toEqual(["show"]);
window.isFocused = () => true;
toggleMainWindow({ window, showWindow });
expect(calls).toEqual(["show", "hide"]);
});
test("keeps the old shortcut when registration or persistence fails", async () => {
const calls = [];
let canRegister = false;
let canSave = true;
const globalShortcut = {
register: (accelerator) => { calls.push(`register:${accelerator}`); return canRegister; },
unregister: (accelerator) => calls.push(`unregister:${accelerator}`),
};
const replace = () => replaceGlobalShortcut({
globalShortcut,
previous: oldBinding,
next: newBinding,
callback: () => {},
persist: async () => { if (!canSave) throw new Error("disk full"); },
});
expect(await replace()).toEqual({ ok: false, reason: "unavailable" });
expect(calls).toEqual(["register:Control+Alt+R"]);
canRegister = true;
canSave = false;
expect(await replace()).toEqual({ ok: false, reason: "saveFailed" });
expect(calls).toEqual([
"register:Control+Alt+R",
"register:Control+Alt+R",
"unregister:Control+Alt+R",
]);
canSave = true;
expect(await replace()).toEqual({ ok: true });
expect(calls.slice(-2)).toEqual(["register:Control+Alt+R", "unregister:Control+Alt+W"]);
});
});
+92 -2
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, Menu, Tray, nativeImage, ipcMain, session, net, protocol, shell, dialog, safeStorage, clipboard, ClipboardItem, powerMonitor, desktopCapturer, screen } from "electron";
import { app, BrowserWindow, Menu, Tray, globalShortcut, nativeImage, ipcMain, session, net, protocol, shell, dialog, safeStorage, clipboard, ClipboardItem, powerMonitor, desktopCapturer, screen } from "electron";
import { createReadStream, existsSync } from "node:fs";
import { appendFile, mkdir, open, readdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
@@ -31,6 +31,7 @@ import {
import { userDataDirectoryFromArguments } from "./user-data-directory.mjs";
import { isAllowedPrintPreviewUrl } from "./window-open-policy.mjs";
import { showWindow } from "./window-visibility.mjs";
import { globalShortcutAccelerator, normalizeGlobalShortcutBinding, replaceGlobalShortcut, toggleMainWindow } from "./global-shortcut.mjs";
import { trayIconPath } from "./tray-icon.mjs";
import { writeImageClipboard, writeRichClipboard, writeTextClipboard } from "./clipboard-write.mjs";
import { captureScreenToNote, createScreenshotCaptureGuard, screenshotImportIpcPayload, writeScreenshotTempPath } from "./screenshot-capture.mjs";
@@ -81,7 +82,7 @@ const requestedUserDataDirectory = linuxUpdateTestMode
if (requestedUserDataDirectory) app.setPath("userData", requestedUserDataDirectory);
const currentDirectory = fileURLToPath(new URL(".", import.meta.url));
const projectRoot = join(currentDirectory, "../../..");
const projectRoot = join(currentDirectory, app.isPackaged ? "../../.." : "../../../..");
/**
* Force Dock to use our multi-resolution app icon. Bundle Info.plist is still
* the primary source; this covers cases where Launch Services/Dock cache a
@@ -169,12 +170,83 @@ const updateCheckIntervalMs = 60 * 60 * 1_000;
const updateCheckFocusThrottleMs = 15 * 60 * 1_000;
const hasSingleInstanceLock = app.requestSingleInstanceLock();
const windowStatePath = () => join(app.getPath("userData"), "window-state.json");
const globalShortcutPath = () => join(app.getPath("userData"), "global-shortcut.json");
const instanceUrlPath = () => join(app.getPath("userData"), "instance-url");
const sessionTokenPath = () => join(app.getPath("userData"), "session-token");
const crashMarkerPath = () => join(app.getPath("userData"), "last-session-active");
const installationMarkerPath = () => join(app.getPath("userData"), "installation-confirmed");
const logPath = () => join(app.getPath("userData"), "logs", "desktop.log");
let desktopSessionToken = "";
let configuredGlobalShortcut = null;
let registeredGlobalShortcut = null;
let globalShortcutUpdate = Promise.resolve();
const globalShortcutState = () => ({
binding: configuredGlobalShortcut,
registered: Boolean(registeredGlobalShortcut && globalShortcut.isRegistered(globalShortcutAccelerator(registeredGlobalShortcut))),
});
const persistGlobalShortcut = async (binding) => {
const temporaryPath = `${globalShortcutPath()}.tmp`;
try {
await writeFile(temporaryPath, JSON.stringify({ binding }), { mode: 0o600 });
await rename(temporaryPath, globalShortcutPath());
} catch (error) {
await unlink(temporaryPath).catch(() => {});
throw error;
}
};
const toggleWindowFromGlobalShortcut = () => toggleMainWindow({
window: mainWindow,
showWindow,
activateApp: process.platform === "darwin" ? () => app.show() : undefined,
});
const loadGlobalShortcut = async () => {
try {
const stored = JSON.parse(await readFile(globalShortcutPath(), "utf8"));
configuredGlobalShortcut = normalizeGlobalShortcutBinding(stored?.binding);
if (configuredGlobalShortcut === undefined) configuredGlobalShortcut = null;
} catch {
configuredGlobalShortcut = null;
}
const accelerator = globalShortcutAccelerator(configuredGlobalShortcut);
if (!accelerator) return;
try {
if (globalShortcut.register(accelerator, toggleWindowFromGlobalShortcut)) {
registeredGlobalShortcut = configuredGlobalShortcut;
void writeDiagnostic("global-shortcut.registered", { accelerator });
} else {
void writeDiagnostic("global-shortcut.registration-failed", { accelerator });
}
} catch (error) {
void writeDiagnostic("global-shortcut.registration-failed", {
accelerator,
message: error instanceof Error ? error.message : String(error),
});
// Keep the preference so the user can change it when registration fails.
}
};
const setGlobalShortcut = (value) => {
const next = normalizeGlobalShortcutBinding(value);
if (next === undefined) return Promise.resolve({ ...globalShortcutState(), error: "invalid" });
const update = async () => {
const result = await replaceGlobalShortcut({
globalShortcut,
previous: registeredGlobalShortcut,
next,
callback: toggleWindowFromGlobalShortcut,
persist: persistGlobalShortcut,
});
if (!result.ok) {
void writeDiagnostic("global-shortcut.update-failed", { reason: result.reason });
return { ...globalShortcutState(), error: result.reason };
}
configuredGlobalShortcut = next;
registeredGlobalShortcut = next;
return globalShortcutState();
};
const result = globalShortcutUpdate.then(update, update);
globalShortcutUpdate = result.then(() => undefined, () => undefined);
return result;
};
const sidecarDataDirectory = (accountId = null) => {
return accountId
? accountDataDirectory(app.getPath("userData"), configuredApiBaseUrl, accountId)
@@ -1363,6 +1435,7 @@ const createWindow = async () => {
attachEditContextMenu(mainWindow.webContents);
mainWindow.webContents.on("did-create-window", (childWindow) => attachEditContextMenu(childWindow.webContents));
mainWindow.on("hide", syncRendererHibernate);
mainWindow.on("hide", () => globalShortcut.setSuspended(false));
mainWindow.on("show", syncRendererHibernate);
mainWindow.on("minimize", syncRendererHibernate);
mainWindow.on("restore", syncRendererHibernate);
@@ -1391,6 +1464,7 @@ const createWindow = async () => {
});
rendererStartupGuard?.fail({ kind: "preload-error", message: String(error?.message || error).slice(0, 2000) });
});
mainWindow.webContents.on("did-start-loading", () => globalShortcut.setSuspended(false));
mainWindow.webContents.on("console-message", (details) => {
if (details.level !== "error") return;
void writeDiagnostic("renderer.console-error", {
@@ -1403,6 +1477,7 @@ const createWindow = async () => {
void writeDiagnostic("renderer.loaded", { url: mainWindow?.webContents.getURL() || "" });
});
mainWindow.webContents.on("render-process-gone", (_event, details) => {
globalShortcut.setSuspended(false);
clearRendererUnresponsiveTimer();
void writeDiagnostic("renderer.gone", details);
void handleRendererProcessGone(details);
@@ -1533,6 +1608,7 @@ const startApplication = async () => {
await initialSidecar.waitUntilReady();
void writeDiagnostic("sidecar.ready", { scope: sidecarScopeKey });
createTray();
await loadGlobalShortcut();
ipcMain.on("desktop:local-data-reset-available-sync", (event) => {
event.returnValue = process.platform === "darwin" && app.isPackaged && !requestedUserDataDirectory;
@@ -1551,6 +1627,19 @@ const startApplication = async () => {
});
ipcMain.handle("desktop:sidecar-status", () => ({ available: Boolean(sidecar), path: sidecarPath, scope: sidecarScopeKey }));
ipcMain.handle("desktop:system-info", () => desktopRuntimeSystemInfo());
ipcMain.handle("desktop:global-shortcut", (event) => {
if (event.sender !== mainWindow?.webContents) return null;
return globalShortcutState();
});
ipcMain.handle("desktop:set-global-shortcut", (event, binding) => {
if (event.sender !== mainWindow?.webContents) return null;
return setGlobalShortcut(binding);
});
ipcMain.handle("desktop:capture-global-shortcut", (event, capturing) => {
if (event.sender !== mainWindow?.webContents || typeof capturing !== "boolean") return false;
globalShortcut.setSuspended(capturing);
return true;
});
ipcMain.handle("desktop:set-account-scope", async (_event, accountId) => {
const normalizedAccountId = typeof accountId === "string" && accountId.trim() ? accountId.trim() : null;
const nextScopeKey = accountScopeKey(configuredApiBaseUrl, normalizedAccountId);
@@ -1993,6 +2082,7 @@ app.on("before-quit", (event) => {
event.preventDefault();
shutdownCleanupStarted = true;
isQuitting = true;
globalShortcut.unregisterAll();
scheduledTaskScheduler.clear();
rendererStartupGuard?.complete();
clearRendererUnresponsiveTimer();
+3
View File
@@ -30,6 +30,9 @@ contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({
recoveredAfterAbnormalExit: ipcRenderer.sendSync("desktop:recovered-after-abnormal-exit-sync"),
sidecarStatus: () => ipcRenderer.invoke("desktop:sidecar-status"),
systemInfo: () => ipcRenderer.invoke("desktop:system-info"),
globalShortcut: () => ipcRenderer.invoke("desktop:global-shortcut"),
setGlobalShortcut: (binding) => ipcRenderer.invoke("desktop:set-global-shortcut", binding),
captureGlobalShortcut: (capturing) => ipcRenderer.invoke("desktop:capture-global-shortcut", capturing),
setAccountScope: (accountId) => ipcRenderer.invoke("desktop:set-account-scope", accountId),
apiBaseUrl: ipcRenderer.sendSync("desktop:api-base-url-sync"),
setApiBaseUrl: (value) => ipcRenderer.invoke("desktop:set-api-base-url", value),
@@ -6,6 +6,7 @@ import {
DEFAULT_SHORTCUT_SETTINGS,
formatShortcutBinding,
getShortcutActionOptions,
getShortcutActionForEvent,
shortcutBindingFromKeyboardEvent,
shortcutBindingsEqual,
} from "@/lib/app-helpers";
@@ -25,13 +26,112 @@ const getConflictAction = (
shortcutActionOptions: ReturnType<typeof getShortcutActionOptions>
) => shortcutActionOptions.find((item) => item.value !== action && shortcutBindingsEqual(settings[item.value], binding));
const globalBindingFromEvent = (event: KeyboardEvent): DesktopGlobalShortcutBinding | null => {
const key = /^[a-z]$/i.test(event.key) ? event.key.toLowerCase() : /^Key([A-Z])$/.exec(event.code)?.[1]?.toLowerCase()
?? /^Digit([0-9])$/.exec(event.code)?.[1]
?? (/^F(?:[1-9]|1[0-2])$/.test(event.key) ? event.key.toLowerCase() : null);
if (!key || [event.ctrlKey, event.metaKey, event.altKey, event.shiftKey].filter(Boolean).length < 2) return null;
return { key, ctrl: event.ctrlKey, meta: event.metaKey, alt: event.altKey, shift: event.shiftKey };
};
const formatGlobalBinding = (binding: DesktopGlobalShortcutBinding) => {
const isMac = /mac/i.test(window.navigator.platform);
const parts = [
binding.ctrl && (isMac ? "⌃" : "Ctrl"),
binding.meta && (isMac ? "⌘" : "Meta"),
binding.alt && (isMac ? "⌥" : "Alt"),
binding.shift && (isMac ? "⇧" : "Shift"),
binding.key.toUpperCase(),
].filter(Boolean);
return parts.join(isMac ? "" : " + ");
};
const globalBindingMatchesLocal = (global: DesktopGlobalShortcutBinding, local: ShortcutBinding) =>
global.key === local.key
&& (global.ctrl || global.meta) === local.ctrlOrMeta
&& global.alt === local.alt
&& global.shift === local.shift;
export const ShortcutSettingsItem = ({ shortcutSettings, onShortcutSettingsChange }: ShortcutSettingsItemProps) => {
const { t } = useTranslation();
const [recordingAction, setRecordingAction] = useState<ShortcutAction | null>(null);
const [captureMessage, setCaptureMessage] = useState("");
const [globalShortcut, setGlobalShortcut] = useState<DesktopGlobalShortcutState | null>(null);
const [recordingGlobal, setRecordingGlobal] = useState(false);
const [globalBusy, setGlobalBusy] = useState(false);
const [globalMessage, setGlobalMessage] = useState("");
const captureButtonRef = useRef<HTMLButtonElement | null>(null);
const shortcutActionOptions = useMemo(() => getShortcutActionOptions(t), [t]);
useEffect(() => {
let active = true;
void window.edgeeverDesktop?.globalShortcut().then((state) => {
if (active) setGlobalShortcut(state);
}).catch(() => {
if (active) setGlobalMessage(t("shortcuts.global.saveFailed"));
});
return () => { active = false; };
}, [t]);
useEffect(() => () => { void window.edgeeverDesktop?.captureGlobalShortcut(false); }, []);
const beginRecording = async (action: ShortcutAction | "global") => {
try {
await window.edgeeverDesktop?.captureGlobalShortcut(true);
if (action === "global") {
setRecordingGlobal(true);
setGlobalMessage("");
} else {
setRecordingAction(action);
setCaptureMessage("");
}
} catch {
setGlobalMessage(t("shortcuts.global.unavailable"));
}
};
const finishRecording = () => {
setRecordingAction(null);
setRecordingGlobal(false);
void window.edgeeverDesktop?.captureGlobalShortcut(false);
};
useEffect(() => {
if (!recordingGlobal) return;
const handleGlobalKeyDown = (event: KeyboardEvent) => {
event.preventDefault();
event.stopImmediatePropagation();
if (event.key === "Escape") {
finishRecording();
setGlobalMessage("");
return;
}
const binding = globalBindingFromEvent(event);
if (!binding) {
setGlobalMessage(t("shortcuts.global.requireModifiers"));
return;
}
const conflict = getShortcutActionForEvent(event, shortcutSettings);
if (conflict) {
const label = shortcutActionOptions.find((item) => item.value === conflict)?.label ?? conflict;
setGlobalMessage(t("shortcuts.conflict", { label }));
return;
}
finishRecording();
setGlobalBusy(true);
void (async () => {
await window.edgeeverDesktop?.captureGlobalShortcut(false);
const state = await window.edgeeverDesktop?.setGlobalShortcut(binding);
if (state) {
setGlobalShortcut(state);
setGlobalMessage(state.error ? t(`shortcuts.global.${state.error}`) : "");
}
})().catch(() => setGlobalMessage(t("shortcuts.global.saveFailed"))).finally(() => setGlobalBusy(false));
};
window.addEventListener("keydown", handleGlobalKeyDown, { capture: true });
return () => window.removeEventListener("keydown", handleGlobalKeyDown, { capture: true });
}, [recordingGlobal, shortcutActionOptions, shortcutSettings, t]);
useEffect(() => {
if (!recordingAction) {
return;
@@ -50,7 +150,7 @@ export const ShortcutSettingsItem = ({ shortcutSettings, onShortcutSettingsChang
event.stopPropagation();
if (event.key === "Escape") {
setRecordingAction(null);
finishRecording();
setCaptureMessage("");
return;
}
@@ -66,67 +166,101 @@ export const ShortcutSettingsItem = ({ shortcutSettings, onShortcutSettingsChang
setCaptureMessage(t("shortcuts.conflict", { label: conflictAction.label }));
return;
}
if (globalShortcut?.binding && globalBindingMatchesLocal(globalShortcut.binding, nextBinding)) {
setCaptureMessage(t("shortcuts.conflict", { label: t("shortcuts.global.label") }));
return;
}
onShortcutSettingsChange({
...shortcutSettings,
[recordingAction]: nextBinding,
});
setRecordingAction(null);
finishRecording();
setCaptureMessage("");
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
return () => window.removeEventListener("keydown", handleKeyDown, { capture: true });
}, [onShortcutSettingsChange, recordingAction, shortcutActionOptions, shortcutSettings, t]);
}, [globalShortcut, onShortcutSettingsChange, recordingAction, shortcutActionOptions, shortcutSettings, t]);
const handleResetShortcuts = () => {
onShortcutSettingsChange(DEFAULT_SHORTCUT_SETTINGS);
setRecordingAction(null);
finishRecording();
setCaptureMessage("");
};
return (
<div className="overflow-hidden rounded-xl border border-slate-200 bg-card">
<div className="grid grid-cols-1 gap-px bg-slate-100 sm:grid-cols-2">
{shortcutActionOptions.map((item, index) => {
const recording = recordingAction === item.value;
const lastAlone = index === shortcutActionOptions.length - 1 && shortcutActionOptions.length % 2 === 1;
return (
<div
key={item.value}
className={cn(
"flex h-12 items-center justify-between gap-3 bg-card px-4",
lastAlone && "sm:col-span-2",
)}
>
<div className={cn("min-w-0 truncate", SETTINGS_ITEM_TITLE_CLASSNAME)}>{item.label}</div>
<Button
ref={recording ? captureButtonRef : null}
type="button"
variant={recording ? "solid" : "outline"}
className={cn("h-8 min-w-24 shrink-0 px-3 font-mono text-xs font-normal", !recording && "bg-card")}
onClick={() => {
setRecordingAction(item.value);
setCaptureMessage("");
}}
>
{recording ? t("shortcuts.recording") : formatShortcutBinding(shortcutSettings[item.value])}
</Button>
<div className="grid gap-4">
{window.edgeeverDesktop?.isAvailable ? (
<div className="overflow-hidden rounded-xl border border-slate-200 bg-card">
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div>
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("shortcuts.global.label")}</div>
<p className="mt-1 text-xs text-slate-500">{t("shortcuts.global.description")}</p>
</div>
);
})}
</div>
{captureMessage ? (
<div className="border-t border-amber-200 bg-amber-50 px-4 py-2 text-xs font-medium text-amber-700">
{captureMessage}
<div className="flex items-center gap-2">
<Button type="button" variant="outline" className="h-8 min-w-24 px-3 font-mono text-xs font-normal" disabled={!globalShortcut || globalBusy || Boolean(recordingAction)} onClick={() => { void beginRecording("global"); }}>
{recordingGlobal ? t("shortcuts.recording") : globalShortcut?.binding ? formatGlobalBinding(globalShortcut.binding) : t("shortcuts.global.disabled")}
</Button>
{globalShortcut?.binding ? (
<Button type="button" variant="outline" className="h-8 px-3 text-xs font-normal" disabled={globalBusy || recordingGlobal || Boolean(recordingAction)} onClick={() => {
setGlobalBusy(true);
void window.edgeeverDesktop?.setGlobalShortcut(null).then((state) => {
if (state) {
setGlobalShortcut(state);
setGlobalMessage(state.error ? t(`shortcuts.global.${state.error}`) : "");
}
}).catch(() => setGlobalMessage(t("shortcuts.global.saveFailed"))).finally(() => setGlobalBusy(false));
}}>{t("shortcuts.global.disable")}</Button>
) : null}
</div>
</div>
{globalShortcut?.binding && !globalShortcut.registered && !globalMessage ? (
<div className="border-t border-amber-200 bg-amber-50 px-4 py-2 text-xs text-amber-700">{t("shortcuts.global.unavailable")}</div>
) : null}
{globalMessage ? <div className="border-t border-amber-200 bg-amber-50 px-4 py-2 text-xs text-amber-700">{globalMessage}</div> : null}
</div>
) : null}
<div className="flex justify-end border-t border-slate-100 px-4 py-3">
<Button type="button" variant="outline" className="h-9 px-3 text-xs font-normal" onClick={handleResetShortcuts}>
<RotateCcw className="h-4 w-4" />
{t("shortcuts.reset")}
</Button>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-card">
<div className="grid grid-cols-1 gap-px bg-slate-100 sm:grid-cols-2">
{shortcutActionOptions.map((item, index) => {
const recording = recordingAction === item.value;
const lastAlone = index === shortcutActionOptions.length - 1 && shortcutActionOptions.length % 2 === 1;
return (
<div
key={item.value}
className={cn(
"flex h-12 items-center justify-between gap-3 bg-card px-4",
lastAlone && "sm:col-span-2",
)}
>
<div className={cn("min-w-0 truncate", SETTINGS_ITEM_TITLE_CLASSNAME)}>{item.label}</div>
<Button
ref={recording ? captureButtonRef : null}
type="button"
variant={recording ? "solid" : "outline"}
className={cn("h-8 min-w-24 shrink-0 px-3 font-mono text-xs font-normal", !recording && "bg-card")}
onClick={() => { void beginRecording(item.value); }}
disabled={recordingGlobal || globalBusy}
>
{recording ? t("shortcuts.recording") : formatShortcutBinding(shortcutSettings[item.value])}
</Button>
</div>
);
})}
</div>
{captureMessage ? (
<div className="border-t border-amber-200 bg-amber-50 px-4 py-2 text-xs font-medium text-amber-700">
{captureMessage}
</div>
) : null}
<div className="flex justify-end border-t border-slate-100 px-4 py-3">
<Button type="button" variant="outline" className="h-9 px-3 text-xs font-normal" onClick={handleResetShortcuts}>
<RotateCcw className="h-4 w-4" />
{t("shortcuts.reset")}
</Button>
</div>
</div>
</div>
);
+17
View File
@@ -14,6 +14,20 @@ declare const __EDGEEVER_DEPLOYMENT_METHOD__: string;
declare const __EDGEEVER_DEVELOPMENT_PROFILE__: "" | "local" | "demo";
declare const __EDGEEVER_DESKTOP_BUILD__: boolean;
interface DesktopGlobalShortcutBinding {
key: string;
ctrl: boolean;
meta: boolean;
alt: boolean;
shift: boolean;
}
interface DesktopGlobalShortcutState {
binding: DesktopGlobalShortcutBinding | null;
registered: boolean;
error?: "invalid" | "unavailable" | "saveFailed";
}
interface EdgeEverDesktopBridge {
isAvailable: boolean;
canClearLocalData: boolean;
@@ -61,6 +75,9 @@ interface EdgeEverDesktopBridge {
chrome: string;
dataDir: string;
}>;
globalShortcut(): Promise<DesktopGlobalShortcutState>;
setGlobalShortcut(binding: DesktopGlobalShortcutBinding | null): Promise<DesktopGlobalShortcutState>;
captureGlobalShortcut(capturing: boolean): Promise<boolean>;
setAccountScope(accountId: string | null): Promise<{ ready: true; scope: string }>;
updateStatus(): Promise<DesktopUpdateStatus>;
checkUpdate(): Promise<DesktopUpdateStatus>;
+1 -1
View File
@@ -64,7 +64,7 @@
"publish:stores": "bun scripts/store-delivery.mjs",
"publish:extension": "bun scripts/publish-web-clipper.mjs",
"test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs",
"test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/app-protocol.test.mjs apps/desktop/src/main/child-process-start.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-menu.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/wechat-chat-archive.test.mjs apps/desktop/src/main/wechat-share-import.test.mjs apps/desktop/src/main/share-extension-registration.test.mjs apps/desktop/src/main/windows-share-menu.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/ai-direct.test.mjs apps/desktop/src/main/plugin-public-network.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/renderer-hibernate.test.mjs apps/desktop/src/main/renderer-startup-guard.test.mjs apps/desktop/src/main/renderer-storage-migration.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/scheduled-task-scheduler.test.mjs apps/desktop/src/main/screenshot-capture.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/staged-resource-alias.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-renderer-ready.test.mjs apps/web/src/lib/desktop-workspace-restore.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-acp.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/screenshot-import.test.mjs apps/web/src/lib/wechat-chat-import.test.mjs apps/web/src/lib/shared-file-import.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun test --cwd apps/desktop src/main/acp-adapter-manager.test.mjs src/main/acp-host.test.mjs src/main/antigravity-proxy.test.mjs src/main/acp-mcp-bridge.test.mjs && bun run test:desktop:sidecar",
"test:desktop": "bun test apps/desktop/src/main/global-shortcut.test.mjs apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/app-protocol.test.mjs apps/desktop/src/main/child-process-start.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-menu.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/wechat-chat-archive.test.mjs apps/desktop/src/main/wechat-share-import.test.mjs apps/desktop/src/main/share-extension-registration.test.mjs apps/desktop/src/main/windows-share-menu.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/ai-direct.test.mjs apps/desktop/src/main/plugin-public-network.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/renderer-hibernate.test.mjs apps/desktop/src/main/renderer-startup-guard.test.mjs apps/desktop/src/main/renderer-storage-migration.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/scheduled-task-scheduler.test.mjs apps/desktop/src/main/screenshot-capture.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/staged-resource-alias.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-renderer-ready.test.mjs apps/web/src/lib/desktop-workspace-restore.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-acp.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/screenshot-import.test.mjs apps/web/src/lib/wechat-chat-import.test.mjs apps/web/src/lib/shared-file-import.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun test --cwd apps/desktop src/main/acp-adapter-manager.test.mjs src/main/acp-host.test.mjs src/main/antigravity-proxy.test.mjs src/main/acp-mcp-bridge.test.mjs && bun run test:desktop:sidecar",
"verify:desktop-renderer": "bun scripts/verify-desktop-renderer.mjs",
"verify:renderer-origin-migration": "bun run --cwd apps/desktop verify-origin-migration",
"verify:desktop-cross-version-startup": "bun scripts/verify-desktop-cross-version-startup.mjs",
+10
View File
@@ -1746,6 +1746,16 @@ export const enUS = {
confirmImport: "Import",
},
shortcuts: {
global: {
label: "Show or hide desktop window",
description: "Works from other apps. This shortcut is saved only on this device.",
disabled: "Not set",
disable: "Disable",
requireModifiers: "Use a letter, number, or F1–F12 with at least two modifier keys.",
invalid: "This key combination is not supported.",
unavailable: "The system could not register this shortcut. Choose another combination or check system shortcut settings.",
saveFailed: "The shortcut could not be saved. Try again.",
},
title: "Keyboard shortcuts",
manage: "Manage",
description: "Set key combinations for common note actions. Press Esc to cancel recording.",
+10
View File
@@ -1746,6 +1746,16 @@ export const ja = {
confirmImport: "取り込む",
},
shortcuts: {
global: {
label: "デスクトップウィンドウの表示・非表示",
description: "他のアプリからも使用できます。このショートカットはこの端末にのみ保存されます。",
disabled: "未設定",
disable: "無効にする",
requireModifiers: "文字、数字、または F1~F12 と修飾キーを2つ以上押してください。",
invalid: "このキーの組み合わせには対応していません。",
unavailable: "システムに登録できませんでした。別の組み合わせを選ぶか、システム設定を確認してください。",
saveFailed: "ショートカットを保存できませんでした。再試行してください。",
},
title: "キーボードショートカット",
manage: "管理",
description: "よく使うノート操作のキー組み合わせを設定します。Esc で記録をキャンセルします。",
+10
View File
@@ -1746,6 +1746,16 @@ export const pl = {
confirmImport: "Importuj",
},
shortcuts: {
global: {
label: "Pokaż lub ukryj okno aplikacji",
description: "Działa także w innych aplikacjach. Skrót jest zapisany tylko na tym urządzeniu.",
disabled: "Nie ustawiono",
disable: "Wyłącz",
requireModifiers: "Użyj litery, cyfry lub F1–F12 z co najmniej dwoma klawiszami modyfikującymi.",
invalid: "Ta kombinacja klawiszy nie jest obsługiwana.",
unavailable: "System nie mógł zarejestrować skrótu. Wybierz inną kombinację lub sprawdź ustawienia systemowe.",
saveFailed: "Nie udało się zapisać skrótu. Spróbuj ponownie.",
},
title: "Skróty klawiszowe",
manage: "Zarządzaj",
description: "Ustaw kombinacje klawiszy dla częstych działań na notatkach. Naciśnij Esc, aby przerwać nagrywanie.",
+10
View File
@@ -1746,6 +1746,16 @@ export const zhCN = {
confirmImport: "确认导入",
},
shortcuts: {
global: {
label: "显示或隐藏桌面窗口",
description: "在其他应用中也可使用。此快捷键只保存在当前设备。",
disabled: "未设置",
disable: "停用",
requireModifiers: "请使用字母、数字或 F1–F12,并同时按下至少两个修饰键。",
invalid: "不支持这个组合键。",
unavailable: "系统未能注册这个快捷键。请换一个组合键,或检查系统快捷键设置。",
saveFailed: "无法保存快捷键,请重试。",
},
title: "绑定快捷键",
manage: "管理",
description: "为常用笔记动作设置组合键。按 Esc 可取消当前录制。",