diff --git a/apps/desktop/src/main/global-shortcut.mjs b/apps/desktop/src/main/global-shortcut.mjs new file mode 100644 index 00000000..43e41eee --- /dev/null +++ b/apps/desktop/src/main/global-shortcut.mjs @@ -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 }; +}; diff --git a/apps/desktop/src/main/global-shortcut.test.mjs b/apps/desktop/src/main/global-shortcut.test.mjs new file mode 100644 index 00000000..506a51f6 --- /dev/null +++ b/apps/desktop/src/main/global-shortcut.test.mjs @@ -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"]); + }); +}); diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index 7b9d7e9b..b22cece3 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -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(); diff --git a/apps/desktop/src/preload/index.cjs b/apps/desktop/src/preload/index.cjs index 1bdd05c4..9dd1d587 100644 --- a/apps/desktop/src/preload/index.cjs +++ b/apps/desktop/src/preload/index.cjs @@ -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), diff --git a/apps/web/src/components/settings/ShortcutSettingsItem.tsx b/apps/web/src/components/settings/ShortcutSettingsItem.tsx index 31fde932..921b73c4 100644 --- a/apps/web/src/components/settings/ShortcutSettingsItem.tsx +++ b/apps/web/src/components/settings/ShortcutSettingsItem.tsx @@ -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 ) => 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(null); const [captureMessage, setCaptureMessage] = useState(""); + const [globalShortcut, setGlobalShortcut] = useState(null); + const [recordingGlobal, setRecordingGlobal] = useState(false); + const [globalBusy, setGlobalBusy] = useState(false); + const [globalMessage, setGlobalMessage] = useState(""); const captureButtonRef = useRef(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 ( -
-
- {shortcutActionOptions.map((item, index) => { - const recording = recordingAction === item.value; - const lastAlone = index === shortcutActionOptions.length - 1 && shortcutActionOptions.length % 2 === 1; - - return ( -
-
{item.label}
- +
+ {window.edgeeverDesktop?.isAvailable ? ( +
+
+
+
{t("shortcuts.global.label")}
+

{t("shortcuts.global.description")}

- ); - })} -
- {captureMessage ? ( -
- {captureMessage} +
+ + {globalShortcut?.binding ? ( + + ) : null} +
+
+ {globalShortcut?.binding && !globalShortcut.registered && !globalMessage ? ( +
{t("shortcuts.global.unavailable")}
+ ) : null} + {globalMessage ?
{globalMessage}
: null}
) : null} -
- +
+
+ {shortcutActionOptions.map((item, index) => { + const recording = recordingAction === item.value; + const lastAlone = index === shortcutActionOptions.length - 1 && shortcutActionOptions.length % 2 === 1; + + return ( +
+
{item.label}
+ +
+ ); + })} +
+ {captureMessage ? ( +
+ {captureMessage} +
+ ) : null} +
+ +
); diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 7d3e3c08..9d35ef70 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -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; + setGlobalShortcut(binding: DesktopGlobalShortcutBinding | null): Promise; + captureGlobalShortcut(capturing: boolean): Promise; setAccountScope(accountId: string | null): Promise<{ ready: true; scope: string }>; updateStatus(): Promise; checkUpdate(): Promise; diff --git a/package.json b/package.json index bbef96e8..3314e5af 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index 72963a39..e1bc87ff 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -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.", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index f79c74f9..90fad7e3 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -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 で記録をキャンセルします。", diff --git a/packages/shared/src/i18n/pl.ts b/packages/shared/src/i18n/pl.ts index 504d06d7..1ebab822 100644 --- a/packages/shared/src/i18n/pl.ts +++ b/packages/shared/src/i18n/pl.ts @@ -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.", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 55694d4d..1ccd3f96 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -1746,6 +1746,16 @@ export const zhCN = { confirmImport: "确认导入", }, shortcuts: { + global: { + label: "显示或隐藏桌面窗口", + description: "在其他应用中也可使用。此快捷键只保存在当前设备。", + disabled: "未设置", + disable: "停用", + requireModifiers: "请使用字母、数字或 F1–F12,并同时按下至少两个修饰键。", + invalid: "不支持这个组合键。", + unavailable: "系统未能注册这个快捷键。请换一个组合键,或检查系统快捷键设置。", + saveFailed: "无法保存快捷键,请重试。", + }, title: "绑定快捷键", manage: "管理", description: "为常用笔记动作设置组合键。按 Esc 可取消当前录制。",