Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b16d313a91 | ||
|
|
912d0bce79 | ||
|
|
b08091d55c | ||
|
|
49f466a569 | ||
|
|
797c02bdd0 | ||
|
|
02f1e71e58 | ||
|
|
e6954304f7 | ||
|
|
876fbe5fba | ||
|
|
9d7ca67606 | ||
|
|
cf3667c151 | ||
|
|
f3e01c2ba4 | ||
|
|
d179b621a7 |
+7
-1
@@ -43,6 +43,12 @@ const options = {
|
|||||||
description: "Set User-Agent to a specific operating system. May trigger anti-spam or break voice chat",
|
description: "Set User-Agent to a specific operating system. May trigger anti-spam or break voice chat",
|
||||||
options: ["windows", "linux", "darwin"]
|
options: ["windows", "linux", "darwin"]
|
||||||
},
|
},
|
||||||
|
"run-shortcut": {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Run a predefined shortcut action (for custom key binds). Vesktop has to be open for this to have any effect",
|
||||||
|
argumentName: "action"
|
||||||
|
},
|
||||||
repair: {
|
repair: {
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
description: "Repair the application by re-downloading the latest Vencord files"
|
description: "Repair the application by re-downloading the latest Vencord files"
|
||||||
@@ -139,7 +145,7 @@ export function checkCommandLineForHelpOrVersion() {
|
|||||||
app.exit(1);
|
app.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("options" in def && !def.options?.includes(value as string)) {
|
if ("options" in def && !def.options?.includes(value as any)) {
|
||||||
console.error(`Invalid value for --${name}: ${value}\nExpected one of: ${def.options.join(", ")}`);
|
console.error(`Invalid value for --${name}: ${value}\nExpected one of: ${def.options.join(", ")}`);
|
||||||
app.exit(1);
|
app.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,3 +13,5 @@ export const AppEvents = new EventEmitter<{
|
|||||||
userAssetChanged: [UserAssetType];
|
userAssetChanged: [UserAssetType];
|
||||||
setTrayVariant: ["tray" | "trayUnread"];
|
setTrayVariant: ["tray" | "trayUnread"];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
export const appReady = new Promise<void>(resolve => AppEvents.once("appLoaded", () => resolve()));
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { randomUUID } from "crypto";
|
|||||||
import { ipcMain } from "electron";
|
import { ipcMain } from "electron";
|
||||||
import { IpcEvents } from "shared/IpcEvents";
|
import { IpcEvents } from "shared/IpcEvents";
|
||||||
|
|
||||||
|
import { appReady } from "./events";
|
||||||
import { mainWin } from "./mainWindow";
|
import { mainWin } from "./mainWindow";
|
||||||
|
|
||||||
const resolvers = new Map<string, Record<"resolve" | "reject", (data: any) => void>>();
|
const resolvers = new Map<string, Record<"resolve" | "reject", (data: any) => void>>();
|
||||||
@@ -30,7 +31,9 @@ export interface IpcResponse {
|
|||||||
*
|
*
|
||||||
* You must add a handler for the message in the renderer process.
|
* You must add a handler for the message in the renderer process.
|
||||||
*/
|
*/
|
||||||
export function sendRendererCommand<T = any>(message: string, data?: any) {
|
export async function sendRendererCommand<T = any>(message: string, data?: any) {
|
||||||
|
await appReady;
|
||||||
|
|
||||||
if (mainWin.isDestroyed()) {
|
if (mainWin.isDestroyed()) {
|
||||||
console.warn("Main window is destroyed, cannot send IPC command:", message);
|
console.warn("Main window is destroyed, cannot send IPC command:", message);
|
||||||
return Promise.reject(new Error("Main window is destroyed"));
|
return Promise.reject(new Error("Main window is destroyed"));
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* 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 } from "electron";
|
||||||
|
import { rmSync } from "fs";
|
||||||
|
import { rm } from "fs/promises";
|
||||||
|
import { createServer, Server } from "net";
|
||||||
|
import { join } from "path";
|
||||||
|
import { IpcCommands } from "shared/IpcEvents";
|
||||||
|
|
||||||
|
import { sendRendererCommand } from "./ipcCommands";
|
||||||
|
|
||||||
|
const { env, platform } = process;
|
||||||
|
|
||||||
|
let server: Server;
|
||||||
|
|
||||||
|
const SOCKET_PATH =
|
||||||
|
platform === "win32"
|
||||||
|
? "\\\\?\\pipe\\vesktop.sock"
|
||||||
|
: join(env.XDG_RUNTIME_DIR || env.TMPDIR || env.TMP || env.TEMP || "/tmp", "vesktop.sock");
|
||||||
|
|
||||||
|
export async function initSocket() {
|
||||||
|
await rm(SOCKET_PATH, { force: true });
|
||||||
|
|
||||||
|
server = createServer(socket => {
|
||||||
|
socket.setEncoding("utf-8");
|
||||||
|
|
||||||
|
const reply = (message: string) => {
|
||||||
|
socket.write(message);
|
||||||
|
socket.end();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on("data", async (data: string) => {
|
||||||
|
try {
|
||||||
|
const [command, ...args] = data.trim().split(":");
|
||||||
|
|
||||||
|
if (command === "run-shortcut") {
|
||||||
|
await sendRendererCommand(IpcCommands.KEY_BINDS_HANDLE, args[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply("Unknown Command");
|
||||||
|
} catch (e) {
|
||||||
|
reply("Unknown Error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(SOCKET_PATH);
|
||||||
|
server.on("error", err => {
|
||||||
|
console.error("Vesktop IPC Socket Error:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on("will-quit", () => {
|
||||||
|
server?.close();
|
||||||
|
rmSync(SOCKET_PATH, { force: true });
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/*
|
||||||
|
* 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() {
|
||||||
|
if (!app.isReady()) return;
|
||||||
|
globalShortcut.unregisterAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerKeyBinds() {
|
||||||
|
await app.whenReady();
|
||||||
|
|
||||||
|
unregisterKeyBinds();
|
||||||
|
|
||||||
|
let success = true;
|
||||||
|
|
||||||
|
for (const { action, enabled, key } of Settings.store.keyBinds || []) {
|
||||||
|
if (!enabled || !key || action === "unassigned") continue;
|
||||||
|
|
||||||
|
const registeredSuccessfully = globalShortcut.register(key, () =>
|
||||||
|
sendRendererCommand(IpcCommands.KEY_BINDS_HANDLE, action)
|
||||||
|
);
|
||||||
|
success &&= registeredSuccessfully;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendRendererCommand(IpcCommands.KEY_BINDS_SET_STATUS, success);
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings.addChangeListener("keyBinds", registerKeyBinds);
|
||||||
|
|
||||||
|
app.on("will-quit", unregisterKeyBinds);
|
||||||
+19
-1
@@ -11,9 +11,15 @@ import "./userAssets";
|
|||||||
import "./vesktopProtocol";
|
import "./vesktopProtocol";
|
||||||
|
|
||||||
import { app, BrowserWindow, nativeTheme } from "electron";
|
import { app, BrowserWindow, nativeTheme } from "electron";
|
||||||
|
import { IpcCommands } from "shared/IpcEvents";
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-duplicate-imports
|
||||||
|
import { CommandLine } from "./cli";
|
||||||
import { DATA_DIR } from "./constants";
|
import { DATA_DIR } from "./constants";
|
||||||
import { createFirstLaunchTour } from "./firstLaunch";
|
import { createFirstLaunchTour } from "./firstLaunch";
|
||||||
|
import { sendRendererCommand } from "./ipcCommands";
|
||||||
|
import { initSocket } from "./ipcSocket";
|
||||||
|
import { registerKeyBinds } from "./keyBinds";
|
||||||
import { createWindows, mainWin } from "./mainWindow";
|
import { createWindows, mainWin } from "./mainWindow";
|
||||||
import { registerMediaPermissionsHandler } from "./mediaPermissions";
|
import { registerMediaPermissionsHandler } from "./mediaPermissions";
|
||||||
import { registerScreenShareHandler } from "./screenShare";
|
import { registerScreenShareHandler } from "./screenShare";
|
||||||
@@ -71,6 +77,9 @@ function init() {
|
|||||||
// Supposed to be fixed already according to comments there, but it's just not lol, I can repro on Electron 43.0.0
|
// Supposed to be fixed already according to comments there, but it's just not lol, I can repro on Electron 43.0.0
|
||||||
// when moving the window from my main monitor (HDR - not sure if this is relevant lol) to second monitor (SDR) and back
|
// when moving the window from my main monitor (HDR - not sure if this is relevant lol) to second monitor (SDR) and back
|
||||||
disabledFeatures.add("WaylandWpColorManagerV1");
|
disabledFeatures.add("WaylandWpColorManagerV1");
|
||||||
|
|
||||||
|
// Enable Wayland Portal for global shortcuts
|
||||||
|
enabledFeatures.add("GlobalShortcutsPortal");
|
||||||
}
|
}
|
||||||
|
|
||||||
disabledFeatures.forEach(feat => enabledFeatures.delete(feat));
|
disabledFeatures.forEach(feat => enabledFeatures.delete(feat));
|
||||||
@@ -105,6 +114,8 @@ function init() {
|
|||||||
|
|
||||||
registerScreenShareHandler();
|
registerScreenShareHandler();
|
||||||
registerMediaPermissionsHandler();
|
registerMediaPermissionsHandler();
|
||||||
|
registerKeyBinds();
|
||||||
|
initSocket();
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|
||||||
@@ -114,7 +125,7 @@ function init() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!app.requestSingleInstanceLock({ IS_DEV })) {
|
if (!app.requestSingleInstanceLock({ IS_DEV, args: CommandLine })) {
|
||||||
if (IS_DEV) {
|
if (IS_DEV) {
|
||||||
console.log("Vesktop is already running. Quitting previous instance...");
|
console.log("Vesktop is already running. Quitting previous instance...");
|
||||||
init();
|
init();
|
||||||
@@ -144,6 +155,13 @@ app.on("window-all-closed", () => {
|
|||||||
if (process.platform !== "darwin") app.quit();
|
if (process.platform !== "darwin") app.quit();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.on("second-instance", (_e, _argv, _cwd, data) => {
|
||||||
|
const { args } = data as { args: typeof CommandLine; IS_DEV: boolean };
|
||||||
|
const { "run-shortcut": shortcut } = args.values;
|
||||||
|
|
||||||
|
if (shortcut) sendRendererCommand(IpcCommands.KEY_BINDS_HANDLE, shortcut);
|
||||||
|
});
|
||||||
|
|
||||||
// Sets the WebRTC IP handling policy for all current and future windows.
|
// Sets the WebRTC IP handling policy for all current and future windows.
|
||||||
// Switching to "default_public_and_private_interfaces" may fix calls stuck at "DTLS Connecting" when using VPNs, Tailscale, etc.
|
// Switching to "default_public_and_private_interfaces" may fix calls stuck at "DTLS Connecting" when using VPNs, Tailscale, etc.
|
||||||
// https://github.com/Vencord/Vesktop/issues/876
|
// https://github.com/Vencord/Vesktop/issues/876
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { classNameFactory } from "@vencord/types/api/Styles";
|
|||||||
import { BaseText, Divider, ErrorBoundary } from "@vencord/types/components";
|
import { BaseText, Divider, ErrorBoundary } from "@vencord/types/components";
|
||||||
import { ComponentType } from "react";
|
import { ComponentType } from "react";
|
||||||
import { WebRTCIPHandlingPolicyPicker } from "renderer/components/settings/WebRTCIPHandlingPolicyPicker";
|
import { WebRTCIPHandlingPolicyPicker } from "renderer/components/settings/WebRTCIPHandlingPolicyPicker";
|
||||||
|
import { KeybindsButton } from "renderer/globalShortcuts/ShortcutSettings";
|
||||||
import { getValueAndOnChange, Settings, useSettings } from "renderer/settings";
|
import { getValueAndOnChange, Settings, useSettings } from "renderer/settings";
|
||||||
import { isMac } from "renderer/utils";
|
import { isMac } from "renderer/utils";
|
||||||
|
|
||||||
@@ -122,7 +123,8 @@ const SettingsOptions: Record<string, Array<BooleanSetting | SettingsComponent>>
|
|||||||
key: "disableSmoothScroll",
|
key: "disableSmoothScroll",
|
||||||
title: "Disable smooth scrolling",
|
title: "Disable smooth scrolling",
|
||||||
description: "Disables smooth scrolling"
|
description: "Disables smooth scrolling"
|
||||||
}
|
},
|
||||||
|
KeybindsButton
|
||||||
],
|
],
|
||||||
Notifications: [
|
Notifications: [
|
||||||
NotificationBadgeToggle,
|
NotificationBadgeToggle,
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
.vcd-shortcuts-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1em;
|
||||||
|
padding-block: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vcd-shortcuts-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25em 0.5em;
|
||||||
|
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: 3px 4px 3px 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,183 @@
|
|||||||
|
/*
|
||||||
|
* 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, Card, DeleteIcon, Switch } from "@vencord/types/components";
|
||||||
|
import { classNameFactory, identity, openModal } from "@vencord/types/utils";
|
||||||
|
import { Modal, React, Select, useState } from "@vencord/types/webpack/common";
|
||||||
|
import { SettingsComponent } from "renderer/components/settings/Settings";
|
||||||
|
import { onIpcCommand } from "renderer/ipcCommands";
|
||||||
|
import { reactiveValue } from "renderer/reactiveState";
|
||||||
|
import { Settings } from "renderer/settings";
|
||||||
|
import { IpcCommands } from "shared/IpcEvents";
|
||||||
|
|
||||||
|
import { recordKeybind } from "./recordKeybind";
|
||||||
|
|
||||||
|
const cl = classNameFactory("vcd-shortcuts-");
|
||||||
|
|
||||||
|
const ShortcutActions = {
|
||||||
|
unassigned: "Unassigned",
|
||||||
|
mute: "Mute",
|
||||||
|
unmute: "Unmute",
|
||||||
|
toggleMute: "Toggle Mute",
|
||||||
|
toggleDeafen: "Toggle Deafen",
|
||||||
|
toggleStreamerMode: "Toggle Streamer Mode",
|
||||||
|
pushToTalkNormalToggle: "Push-to-Talk (Normal) Toggle",
|
||||||
|
pushToTalkNormalStart: "Push-to-Talk (Normal) Start",
|
||||||
|
pushToTalkNormalStop: "Push-to-Talk (Normal) Stop",
|
||||||
|
pushToTalkPriorityToggle: "Push-to-Talk (Priority) Toggle",
|
||||||
|
pushToTalkPriorityStart: "Push-to-Talk (Priority) Start",
|
||||||
|
pushToTalkPriorityStop: "Push-to-Talk (Priority) Stop",
|
||||||
|
disconnectFromVoiceChannel: "Disconnect from VC"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ShortcutAction = keyof typeof ShortcutActions;
|
||||||
|
|
||||||
|
const shortcutStatus = reactiveValue(true);
|
||||||
|
onIpcCommand(IpcCommands.KEY_BINDS_SET_STATUS, status => (shortcutStatus.value = status));
|
||||||
|
|
||||||
|
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 ?? []);
|
||||||
|
const shortcutsStatus = shortcutStatus.use();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cl("container")}>
|
||||||
|
{shortcutsStatus === false && (
|
||||||
|
<Card variant="danger">
|
||||||
|
<BaseText size="md" weight="medium">
|
||||||
|
Some of your keybinds failed to register.
|
||||||
|
</BaseText>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openKeybindsModal() {
|
||||||
|
openModal(props => (
|
||||||
|
<Modal {...props} size="lg" title="Keybinds">
|
||||||
|
<KeyBindSettings />
|
||||||
|
</Modal>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KeybindsButton: SettingsComponent = () => {
|
||||||
|
return <Button onClick={() => openKeybindsModal()}>Configure Keybinds</Button>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* 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 { waitFor } from "@vencord/types/webpack";
|
||||||
|
import { FluxDispatcher } from "@vencord/types/webpack/common";
|
||||||
|
|
||||||
|
import { ShortcutAction } from "./ShortcutSettings";
|
||||||
|
|
||||||
|
const logger = new Logger("VesktopKeyBindHandler");
|
||||||
|
|
||||||
|
interface KeyBindAction {
|
||||||
|
onTrigger(...args: any[]): void;
|
||||||
|
isPressed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let KeyBindActions: Record<string, KeyBindAction>;
|
||||||
|
|
||||||
|
waitFor(["dispatch", "subscribe"], FluxDispatcher => {
|
||||||
|
FluxDispatcher.subscribe("KEYBINDS_REGISTER_GLOBAL_KEYBIND_ACTIONS", event => {
|
||||||
|
KeyBindActions = event.keybinds;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export function handleKeyBind(keyBind: ShortcutAction) {
|
||||||
|
logger.debug(`Handling key bind action: ${keyBind}`);
|
||||||
|
|
||||||
|
if (!KeyBindActions) {
|
||||||
|
logger.error("Failed to retrieve KeyBindActions, cannot handle key bind");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (keyBind) {
|
||||||
|
case "unassigned":
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "mute":
|
||||||
|
case "unmute":
|
||||||
|
FluxDispatcher.dispatch({
|
||||||
|
type: "AUDIO_SET_SELF_MUTE",
|
||||||
|
context: "default",
|
||||||
|
syncRemote: true,
|
||||||
|
playSoundEffect: true,
|
||||||
|
mute: keyBind === "mute"
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "toggleMute":
|
||||||
|
KeyBindActions.TOGGLE_MUTE.onTrigger();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "toggleDeafen":
|
||||||
|
KeyBindActions.TOGGLE_DEAFEN.onTrigger();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "toggleStreamerMode":
|
||||||
|
KeyBindActions.TOGGLE_STREAMER_MODE.onTrigger();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "disconnectFromVoiceChannel":
|
||||||
|
KeyBindActions.DISCONNECT_FROM_VOICE_CHANNEL.onTrigger();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "pushToTalkNormalStart":
|
||||||
|
case "pushToTalkNormalStop":
|
||||||
|
case "pushToTalkNormalToggle": {
|
||||||
|
const isPressed = keyBind.includes("Toggle")
|
||||||
|
? !KeyBindActions.PUSH_TO_TALK.isPressed
|
||||||
|
: keyBind.includes("Start");
|
||||||
|
|
||||||
|
KeyBindActions.PUSH_TO_TALK.onTrigger(isPressed, "default");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "pushToTalkPriorityStart":
|
||||||
|
case "pushToTalkPriorityStop":
|
||||||
|
case "pushToTalkPriorityToggle": {
|
||||||
|
const isPressed = keyBind.includes("Toggle")
|
||||||
|
? !KeyBindActions.PUSH_TO_TALK_PRIORITY.isPressed
|
||||||
|
: keyBind.includes("Start");
|
||||||
|
|
||||||
|
KeyBindActions.PUSH_TO_TALK_PRIORITY.onTrigger(isPressed, "default");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
logger.warn(`Unknown key bind action: ${keyBind}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/*
|
||||||
|
* 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, { capture: true });
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAbort() {
|
||||||
|
cleanup();
|
||||||
|
reject(new DOMException("Recording aborted", "AbortError"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
|
||||||
|
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, { capture: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { SettingsRouter } from "@vencord/types/webpack/common";
|
|||||||
import { IpcCommands } from "shared/IpcEvents";
|
import { IpcCommands } from "shared/IpcEvents";
|
||||||
|
|
||||||
import { openScreenSharePicker } from "./components/ScreenSharePicker";
|
import { openScreenSharePicker } from "./components/ScreenSharePicker";
|
||||||
|
import { handleKeyBind } from "./globalShortcuts/keybindHandler";
|
||||||
|
|
||||||
type IpcCommandHandler = (data: any) => any;
|
type IpcCommandHandler = (data: any) => any;
|
||||||
|
|
||||||
@@ -52,3 +53,5 @@ onIpcCommand(IpcCommands.NAVIGATE_SETTINGS, () => {
|
|||||||
onIpcCommand(IpcCommands.GET_LANGUAGES, () => navigator.languages);
|
onIpcCommand(IpcCommands.GET_LANGUAGES, () => navigator.languages);
|
||||||
|
|
||||||
onIpcCommand(IpcCommands.SCREEN_SHARE_PICKER, data => openScreenSharePicker(data.screens, data.skipPicker));
|
onIpcCommand(IpcCommands.SCREEN_SHARE_PICKER, data => openScreenSharePicker(data.screens, data.skipPicker));
|
||||||
|
|
||||||
|
onIpcCommand(IpcCommands.KEY_BINDS_HANDLE, handleKeyBind);
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/*
|
||||||
|
* 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 { Button, ErrorBoundary } from "@vencord/types/components";
|
||||||
|
import { openKeybindsModal } from "renderer/globalShortcuts/ShortcutSettings";
|
||||||
|
|
||||||
|
import { addPatch } from "./shared";
|
||||||
|
|
||||||
|
addPatch({
|
||||||
|
patches: [
|
||||||
|
{
|
||||||
|
find: "#{intl::KEYBIND_IN_BROSWER_NOTICE}",
|
||||||
|
replacement: {
|
||||||
|
match: /:(.{0,10}jsx.{0,50}messageType:.{0,100}#{intl::KEYBIND_IN_BROSWER_NOTICE})/,
|
||||||
|
replace: ":true?$self.renderKeybindsButton():$1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
renderKeybindsButton: ErrorBoundary.wrap(
|
||||||
|
() => <Button onClick={() => openKeybindsModal()}>Configure Vesktop Keybinds</Button>,
|
||||||
|
{ noop: true }
|
||||||
|
)
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* 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 { useForceUpdater } from "@vencord/types/utils";
|
||||||
|
import { useEffect } from "@vencord/types/webpack/common";
|
||||||
|
|
||||||
|
interface ReactiveValue<T> {
|
||||||
|
value: T;
|
||||||
|
use(): T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reactiveValue<T>(initialValue: T): ReactiveValue<T>;
|
||||||
|
export function reactiveValue<T>(initialValue?: T): ReactiveValue<T | undefined>;
|
||||||
|
export function reactiveValue<T>(initialValue?: T) {
|
||||||
|
const changeListeners = new Set<() => void>();
|
||||||
|
let value = initialValue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
get value() {
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
set value(newValue) {
|
||||||
|
value = newValue;
|
||||||
|
changeListeners.forEach(cb => cb());
|
||||||
|
},
|
||||||
|
use() {
|
||||||
|
const update = useForceUpdater();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
changeListeners.add(update);
|
||||||
|
return () => void changeListeners.delete(update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -83,5 +83,8 @@ export const enum IpcCommands {
|
|||||||
|
|
||||||
GET_LANGUAGES = "navigator.languages",
|
GET_LANGUAGES = "navigator.languages",
|
||||||
|
|
||||||
SCREEN_SHARE_PICKER = "screenshare:picker"
|
SCREEN_SHARE_PICKER = "screenshare:picker",
|
||||||
|
|
||||||
|
KEY_BINDS_HANDLE = "keyBinds:handle",
|
||||||
|
KEY_BINDS_SET_STATUS = "keyBinds:setStatus"
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+3
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Rectangle } from "electron";
|
import type { Rectangle } from "electron";
|
||||||
|
import { KeyBind } from "renderer/globalShortcuts/ShortcutSettings";
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
discordBranch: "stable" | "canary" | "ptb";
|
discordBranch: "stable" | "canary" | "ptb";
|
||||||
@@ -54,6 +55,8 @@ export interface Settings {
|
|||||||
onlySpeakers?: boolean;
|
onlySpeakers?: boolean;
|
||||||
onlyDefaultSpeakers?: boolean;
|
onlyDefaultSpeakers?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
keyBinds?: KeyBind[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
|
|||||||
Reference in New Issue
Block a user