Files
vencord-wayland-keybinds/logic.ts
T
Evandro Fonseca Junior a3a9149184 WaylandKeybinds: reliable Discord global keybinds on GNOME Wayland
Vencord plugin for the Discord desktop app. Toggles fire on key press,
push-to-talk infers releases GNOME drops (re-registration probe), and
GNOME's trigger descriptions are matched correctly. Includes basis-path
tests, a patch checker against the live Discord bundle, the design spec
and the README assets.
2026-11-02 00:51:25 -03:00

127 lines
4.1 KiB
TypeScript

/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Evandro
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export interface KeyEventOptions {
keydown?: boolean;
keyup?: boolean;
portal?: boolean;
}
export interface PortalState {
bound: boolean;
matchesTrigger: boolean;
triggerDescription: string;
}
export type DispatchMode = "passthrough" | "pressAsRelease" | "hold";
export type Callback = (isDown: boolean) => void;
export interface Scheduler {
now(): number;
setTimeout(fn: () => void, ms: number): unknown;
clearTimeout(handle: unknown): void;
}
export interface Dispatcher {
dispatch: Callback;
cancel(): void;
}
// GNOME repeats a held shortcut after 500 ms and then every 30 ms (org.gnome.desktop.peripherals.keyboard)
export const FIRST_PROBE_MS = 1000;
export const PROBE_WINDOW_MS = 300;
export const ECHO_MS = 150;
const MODIFIERS: Record<string, string> = {
ctrl: "CTRL", control: "CTRL", primary: "CTRL",
shift: "SHIFT",
alt: "ALT", mod1: "ALT",
super: "LOGO", logo: "LOGO", meta: "LOGO", mod4: "LOGO"
};
export function planRegistration(options: KeyEventOptions, portalActive: boolean, representable: boolean): DispatchMode {
if (!portalActive || !representable || options.portal === false) return "passthrough";
if (options.keyup && options.keydown) return "hold";
// GNOME only delivers the release if the main key is let go before the modifiers, so act on the press
if (options.keyup) return "pressAsRelease";
return "passthrough";
}
export function adjustOptions<T extends KeyEventOptions>(options: T, mode: DispatchMode): T {
return mode === "pressAsRelease" ? { ...options, keydown: true, keyup: true } : options;
}
export function createDispatcher(callback: Callback, mode: DispatchMode, rearm: () => void, scheduler: Scheduler): Dispatcher {
if (mode === "passthrough") return { dispatch: callback, cancel() { } };
let held = false;
let repeated = false;
let rearmedAt = -Infinity;
let timer: unknown;
const end = () => {
held = false;
if (mode === "hold") callback(false);
};
// Discord's native module ignores presses until it sees a release, and GNOME drops the release when a
// modifier is let go first. Re-registering resets that state; a held combo then repeats within a few ms.
const probe = () => {
repeated = false;
rearm();
rearmedAt = scheduler.now();
timer = scheduler.setTimeout(() => repeated ? probe() : end(), PROBE_WINDOW_MS);
};
const start = () => {
held = true;
callback(mode === "hold");
timer = scheduler.setTimeout(probe, FIRST_PROBE_MS);
};
const dispatch: Callback = isDown => {
const echo = scheduler.now() - rearmedAt < ECHO_MS;
if (!held) {
if (isDown) start();
} else if (isDown) {
if (echo) repeated = true;
else { scheduler.clearTimeout(timer); start(); }
} else if (!echo) {
scheduler.clearTimeout(timer);
end();
}
};
return {
dispatch,
cancel() {
scheduler.clearTimeout(timer);
held = false;
}
};
}
export function normalizeTrigger(trigger: string): string {
const mods = Array.from(trigger.matchAll(/<([^>]+)>/g), m => m[1]);
let rest = trigger.slice(trigger.lastIndexOf(">") + 1);
if (mods.length === 0) {
const parts = rest.split("+");
rest = parts.pop()!;
mods.push(...parts);
}
const key = rest.trim().split(/\s+/).pop()!;
const canonical = mods.map(m => MODIFIERS[m.trim().toLowerCase()] ?? m.trim().toUpperCase());
return [...new Set(canonical)].sort().join("+") + "|" + key.toLowerCase();
}
export function fixPortalState<S extends PortalState>(state: S, expected: string | undefined): S {
if (!state.bound || state.matchesTrigger || !expected) return state;
// GNOME describes the trigger as "Press <Shift><Control>F12", which never equals "CTRL+SHIFT+F12" literally
return normalizeTrigger(expected) === normalizeTrigger(state.triggerDescription)
? { ...state, matchesTrigger: true }
: state;
}