implement global shortcuts with electron api
This commit is contained in:
@@ -14,6 +14,7 @@ import { app, BrowserWindow, nativeTheme } from "electron";
|
||||
|
||||
import { DATA_DIR } from "./constants";
|
||||
import { createFirstLaunchTour } from "./firstLaunch";
|
||||
import { registerKeyBinds } from "./keyBinds";
|
||||
import { createWindows, mainWin } from "./mainWindow";
|
||||
import { registerMediaPermissionsHandler } from "./mediaPermissions";
|
||||
import { registerScreenShareHandler } from "./screenShare";
|
||||
@@ -59,6 +60,9 @@ function init() {
|
||||
app.commandLine.appendSwitch("disable-smooth-scrolling");
|
||||
}
|
||||
|
||||
// Enable Wayland Portal for global shortcuts
|
||||
enabledFeatures.add("GlobalShortcutsPortal");
|
||||
|
||||
// disable renderer backgrounding to prevent the app from unloading when in the background
|
||||
// https://github.com/electron/electron/issues/2822
|
||||
// https://github.com/GoogleChrome/chrome-launcher/blob/5a27dd574d47a75fec0fb50f7b774ebf8a9791ba/docs/chrome-flags-for-tools.md#task-throttling
|
||||
@@ -115,6 +119,7 @@ function init() {
|
||||
|
||||
registerScreenShareHandler();
|
||||
registerMediaPermissionsHandler();
|
||||
registerKeyBinds();
|
||||
|
||||
bootstrap();
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Vesktop, a desktop app aiming to give you a snappier Discord Experience
|
||||
* Copyright (c) 2026 Vendicated and Vesktop contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { app, globalShortcut } from "electron";
|
||||
import { IpcCommands } from "shared/IpcEvents";
|
||||
|
||||
import { sendRendererCommand } from "./ipcCommands";
|
||||
import { Settings } from "./settings";
|
||||
|
||||
function unregisterKeyBinds() {
|
||||
globalShortcut.unregisterAll();
|
||||
}
|
||||
|
||||
export async function registerKeyBinds() {
|
||||
await app.whenReady();
|
||||
|
||||
unregisterKeyBinds();
|
||||
|
||||
for (const { action, enabled, key } of Settings.store.keyBinds || []) {
|
||||
if (!enabled || !key || action === "unassigned") continue;
|
||||
|
||||
console.log(
|
||||
key,
|
||||
"SUCCESS",
|
||||
globalShortcut.register(key, () => sendRendererCommand(IpcCommands.HANDLE_KEY_BIND, action))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Settings.addChangeListener("keyBinds", registerKeyBinds);
|
||||
|
||||
app.on("will-quit", unregisterKeyBinds);
|
||||
@@ -9,6 +9,7 @@ import "./settings.css";
|
||||
import { classNameFactory } from "@vencord/types/api/Styles";
|
||||
import { BaseText, Divider, ErrorBoundary } from "@vencord/types/components";
|
||||
import { ComponentType } from "react";
|
||||
import { KeyBindSettings } from "renderer/globalShortcuts/ShortcutSettings";
|
||||
import { Settings, useSettings } from "renderer/settings";
|
||||
import { isMac, isWindows } from "renderer/utils";
|
||||
|
||||
@@ -148,6 +149,7 @@ const SettingsOptions: Record<string, Array<BooleanSetting | SettingsComponent>>
|
||||
defaultValue: false
|
||||
}
|
||||
],
|
||||
KeyBinds: [KeyBindSettings],
|
||||
"Developer Options": [DeveloperOptionsButton]
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
.vcd-shortcuts-group {
|
||||
display: grid;
|
||||
gap: 0.25em 1em;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vcd-shortcuts-label:first-child {
|
||||
grid-column: 1 / 2;
|
||||
}
|
||||
|
||||
.vcd-shortcuts-label:nth-child(2) {
|
||||
grid-column: 2 / 5;
|
||||
}
|
||||
|
||||
.vcd-shortcuts-recorder {
|
||||
display: flex;
|
||||
background-color: var(--input-background-default);
|
||||
border: 1px solid;
|
||||
border-color: var(--input-border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
width: 100%;
|
||||
min-height: calc(var(--select-option-height) - 2px);
|
||||
padding: 0.25em;
|
||||
}
|
||||
|
||||
.vcd-shortcuts-record-input {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-strong);
|
||||
font-weight: 600;
|
||||
|
||||
&:disabled {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.vcd-shortcuts-record-button {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
min-width: max-content;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Vesktop, a desktop app aiming to give you a snappier Discord Experience
|
||||
* Copyright (c) 2026 Vendicated and Vesktop contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./ShortcutSettings.css";
|
||||
|
||||
import { BaseText, Button, DeleteIcon, Switch } from "@vencord/types/components";
|
||||
import { classNameFactory, identity } from "@vencord/types/utils";
|
||||
import { React, Select, useState } from "@vencord/types/webpack/common";
|
||||
import { Settings } from "renderer/settings";
|
||||
|
||||
import { recordKeybind } from "./recordKeybind";
|
||||
|
||||
const cl = classNameFactory("vcd-shortcuts-");
|
||||
|
||||
const ShortcutActions = {
|
||||
unassigned: "Unassigned",
|
||||
toggleMute: "Toggle Mute",
|
||||
toggleDeafen: "Toggle Deafen",
|
||||
toggleStreamerMode: "Toggle Streamer Mode",
|
||||
toggleCamera: "Toggle Camera",
|
||||
toggleScreenShare: "Toggle Screen Share",
|
||||
disconnectFromVoiceChannel: "Disconnect from Voice Channel"
|
||||
} as const;
|
||||
|
||||
export type ShortcutAction = keyof typeof ShortcutActions;
|
||||
|
||||
export interface KeyBind {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
action: ShortcutAction;
|
||||
key: string;
|
||||
}
|
||||
|
||||
function KeyBindGroup({
|
||||
keyBind,
|
||||
setKeyBind,
|
||||
deleteKeyBind
|
||||
}: {
|
||||
keyBind: KeyBind;
|
||||
setKeyBind(keyBind: KeyBind): void;
|
||||
deleteKeyBind(): void;
|
||||
}) {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [abortController, setAbortController] = useState<AbortController | null>(null);
|
||||
|
||||
const actionId = React.useId();
|
||||
const keyId = React.useId();
|
||||
|
||||
return (
|
||||
<div className={cl("group")}>
|
||||
<BaseText size="md" weight="medium" className={cl("label")} id={actionId}>
|
||||
Action
|
||||
</BaseText>
|
||||
<BaseText size="md" weight="medium" className={cl("label")} id={keyId}>
|
||||
Keybind
|
||||
</BaseText>
|
||||
|
||||
{/* @ts-expect-error incorrect type for aria-labelledby */}
|
||||
<Select
|
||||
aria-labelledby={actionId}
|
||||
options={Object.entries(ShortcutActions).map(([action, label]) => ({ label, value: action }))}
|
||||
isSelected={o => o === keyBind.action}
|
||||
select={o => setKeyBind({ ...keyBind, action: o })}
|
||||
serialize={identity}
|
||||
/>
|
||||
<div className={cl("recorder")} aria-labelledby={keyId}>
|
||||
<input
|
||||
className={cl("record-input")}
|
||||
type="text"
|
||||
value={keyBind.key || "No Keybind Set"}
|
||||
readOnly
|
||||
disabled={!keyBind.key}
|
||||
/>
|
||||
<Button
|
||||
className="record-button"
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
if (isRecording) {
|
||||
abortController?.abort();
|
||||
setIsRecording(false);
|
||||
setAbortController(null);
|
||||
} else {
|
||||
const controller = new AbortController();
|
||||
setAbortController(controller);
|
||||
setIsRecording(true);
|
||||
const key = await recordKeybind(controller.signal).catch(() => null);
|
||||
setIsRecording(false);
|
||||
setAbortController(null);
|
||||
if (key) setKeyBind({ ...keyBind, key });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isRecording ? "Cancel Recording" : "Record"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button size="iconOnly" variant="secondary" aria-label="Delete Keybind" onClick={deleteKeyBind}>
|
||||
<DeleteIcon />
|
||||
</Button>
|
||||
|
||||
<Switch checked={keyBind.enabled} onChange={v => setKeyBind({ ...keyBind, enabled: v })} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyBindSettings() {
|
||||
const [keyBinds, setKeyBinds] = useState<KeyBind[]>(Settings.store.keyBinds ?? []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{keyBinds.map((keyBind, index) => (
|
||||
<KeyBindGroup
|
||||
key={keyBind.id}
|
||||
keyBind={keyBind}
|
||||
setKeyBind={newKeyBind => {
|
||||
const newKeyBinds = [...keyBinds];
|
||||
newKeyBinds[index] = newKeyBind;
|
||||
setKeyBinds(newKeyBinds);
|
||||
Settings.store.keyBinds = newKeyBinds;
|
||||
}}
|
||||
deleteKeyBind={() => {
|
||||
const newKeyBinds = keyBinds.filter((_, i) => i !== index);
|
||||
setKeyBinds(newKeyBinds);
|
||||
Settings.store.keyBinds = newKeyBinds;
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setKeyBinds(binds => [
|
||||
...binds,
|
||||
{
|
||||
action: "unassigned",
|
||||
key: "",
|
||||
enabled: true,
|
||||
id: crypto.randomUUID()
|
||||
}
|
||||
]);
|
||||
}}
|
||||
>
|
||||
Add a Keybind
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Vesktop, a desktop app aiming to give you a snappier Discord Experience
|
||||
* Copyright (c) 2026 Vendicated and Vesktop contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Logger } from "@vencord/types/utils";
|
||||
|
||||
import { ShortcutAction } from "./ShortcutSettings";
|
||||
|
||||
const logger = new Logger("VesktopKeyBindHandler");
|
||||
|
||||
export function handleKeyBind(keyBind: ShortcutAction) {
|
||||
logger.debug(`Handling key bind action: ${keyBind}`);
|
||||
|
||||
switch (keyBind) {
|
||||
case "toggleMute":
|
||||
break;
|
||||
case "toggleDeafen":
|
||||
break;
|
||||
case "toggleStreamerMode":
|
||||
break;
|
||||
case "toggleCamera":
|
||||
break;
|
||||
case "toggleScreenShare":
|
||||
break;
|
||||
case "disconnectFromVoiceChannel":
|
||||
break;
|
||||
case "unassigned":
|
||||
break;
|
||||
default:
|
||||
logger.warn(`Unknown key bind action: ${keyBind}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Vesktop, a desktop app aiming to give you a snappier Discord Experience
|
||||
* Copyright (c) 2026 Vendicated and Vesktop contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
const MODIFIER_KEYS = new Set(["Control", "Shift", "Alt", "Meta", "Dead"]);
|
||||
|
||||
const VALID_PUNCTUATION = new Set(')!@#$%^&*(;:=<,_-.>?/~`{][|\\}"');
|
||||
|
||||
// Codes whose accelerator name matches the code value exactly
|
||||
const PASSTHROUGH_CODES = new Set([
|
||||
"Space",
|
||||
"Tab",
|
||||
"Backspace",
|
||||
"Delete",
|
||||
"Insert",
|
||||
"Home",
|
||||
"End",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
"PrintScreen",
|
||||
"Escape"
|
||||
]);
|
||||
|
||||
// Codes that need an explicit rename
|
||||
const CODE_MAP: Record<string, string> = {
|
||||
Enter: "Return",
|
||||
NumpadEnter: "Return",
|
||||
NumpadDecimal: "numdec",
|
||||
NumpadAdd: "numadd",
|
||||
NumpadSubtract: "numsub",
|
||||
NumpadMultiply: "nummult",
|
||||
NumpadDivide: "numdiv",
|
||||
CapsLock: "Capslock",
|
||||
NumLock: "Numlock",
|
||||
ScrollLock: "Scrolllock",
|
||||
ArrowUp: "Up",
|
||||
ArrowDown: "Down",
|
||||
ArrowLeft: "Left",
|
||||
ArrowRight: "Right"
|
||||
};
|
||||
|
||||
const MEDIA_KEY_MAP: Record<string, string> = {
|
||||
AudioVolumeUp: "VolumeUp",
|
||||
AudioVolumeDown: "VolumeDown",
|
||||
AudioVolumeMute: "VolumeMute",
|
||||
MediaTrackNext: "MediaNextTrack",
|
||||
MediaTrackPrevious: "MediaPreviousTrack",
|
||||
MediaStop: "MediaStop",
|
||||
MediaPlayPause: "MediaPlayPause"
|
||||
};
|
||||
|
||||
function codeToAcceleratorKey(event: KeyboardEvent): string | null {
|
||||
const { code, key } = event;
|
||||
|
||||
// Letters: KeyA -> A
|
||||
const letterMatch = /^Key([A-Z])$/.exec(code);
|
||||
if (letterMatch) return letterMatch[1];
|
||||
|
||||
// Digits: Digit0 -> 0
|
||||
const digitMatch = /^Digit(\d)$/.exec(code);
|
||||
if (digitMatch) return digitMatch[1];
|
||||
|
||||
// Function keys: F1-F24
|
||||
if (/^F([1-9]|1\d|2[0-4])$/.test(code)) return code;
|
||||
|
||||
// Numpad digits: Numpad0 -> num0
|
||||
const numpadDigitMatch = /^Numpad(\d)$/.exec(code);
|
||||
if (numpadDigitMatch) return "num" + numpadDigitMatch[1];
|
||||
|
||||
if (PASSTHROUGH_CODES.has(code)) return code;
|
||||
if (code in CODE_MAP) return CODE_MAP[code];
|
||||
|
||||
// Media / audio keys (identified by event.key)
|
||||
if (key in MEDIA_KEY_MAP) return MEDIA_KEY_MAP[key];
|
||||
|
||||
// Punctuation keys
|
||||
if (key === "+") return "Plus";
|
||||
if (VALID_PUNCTUATION.has(key)) return key;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function recordKeybind(signal?: AbortSignal): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException("Recording aborted", "AbortError"));
|
||||
return;
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
function onAbort() {
|
||||
cleanup();
|
||||
reject(new DOMException("Recording aborted", "AbortError"));
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (MODIFIER_KEYS.has(event.key)) return;
|
||||
|
||||
const key = codeToAcceleratorKey(event);
|
||||
if (key === null) return;
|
||||
|
||||
cleanup();
|
||||
|
||||
const parts: string[] = [];
|
||||
if (event.ctrlKey) parts.push("Ctrl");
|
||||
if (event.shiftKey) parts.push("Shift");
|
||||
if (event.altKey) parts.push("Alt");
|
||||
if (event.metaKey) parts.push("Super");
|
||||
parts.push(key);
|
||||
|
||||
resolve(parts.join("+"));
|
||||
}
|
||||
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { SettingsRouter } from "@vencord/types/webpack/common";
|
||||
import { IpcCommands } from "shared/IpcEvents";
|
||||
|
||||
import { openScreenSharePicker } from "./components/ScreenSharePicker";
|
||||
import { handleKeyBind } from "./globalShortcuts/keybindHandler";
|
||||
|
||||
type IpcCommandHandler = (data: any) => any;
|
||||
|
||||
@@ -52,3 +53,5 @@ onIpcCommand(IpcCommands.NAVIGATE_SETTINGS, () => {
|
||||
onIpcCommand(IpcCommands.GET_LANGUAGES, () => navigator.languages);
|
||||
|
||||
onIpcCommand(IpcCommands.SCREEN_SHARE_PICKER, data => openScreenSharePicker(data.screens, data.skipPicker));
|
||||
|
||||
onIpcCommand(IpcCommands.HANDLE_KEY_BIND, handleKeyBind);
|
||||
|
||||
@@ -83,5 +83,7 @@ export const enum IpcCommands {
|
||||
|
||||
GET_LANGUAGES = "navigator.languages",
|
||||
|
||||
SCREEN_SHARE_PICKER = "screenshare:picker"
|
||||
SCREEN_SHARE_PICKER = "screenshare:picker",
|
||||
|
||||
HANDLE_KEY_BIND = "keyBinds:handle"
|
||||
}
|
||||
|
||||
Vendored
+3
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { Rectangle } from "electron";
|
||||
import { KeyBind } from "renderer/globalShortcuts/ShortcutSettings";
|
||||
|
||||
export interface Settings {
|
||||
discordBranch?: "stable" | "canary" | "ptb";
|
||||
@@ -46,6 +47,8 @@ export interface Settings {
|
||||
onlySpeakers?: boolean;
|
||||
onlyDefaultSpeakers?: boolean;
|
||||
};
|
||||
|
||||
keyBinds?: KeyBind[];
|
||||
}
|
||||
|
||||
export interface State {
|
||||
|
||||
Reference in New Issue
Block a user