1 parent
4481ca22f9
commit
6cb09f4007
11 files changed
+452
-45
No files matched your search
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Vendored
+17
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 で記録をキャンセルします。",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1746,6 +1746,16 @@ export const zhCN = {
|
||||
confirmImport: "确认导入",
|
||||
},
|
||||
shortcuts: {
|
||||
global: {
|
||||
label: "显示或隐藏桌面窗口",
|
||||
description: "在其他应用中也可使用。此快捷键只保存在当前设备。",
|
||||
disabled: "未设置",
|
||||
disable: "停用",
|
||||
requireModifiers: "请使用字母、数字或 F1–F12,并同时按下至少两个修饰键。",
|
||||
invalid: "不支持这个组合键。",
|
||||
unavailable: "系统未能注册这个快捷键。请换一个组合键,或检查系统快捷键设置。",
|
||||
saveFailed: "无法保存快捷键,请重试。",
|
||||
},
|
||||
title: "绑定快捷键",
|
||||
manage: "管理",
|
||||
description: "为常用笔记动作设置组合键。按 Esc 可取消当前录制。",
|
||||
|
||||
Reference in new issue
Block a user