From 34b61e2509f8972e8d93413a46759caeb93d27c3 Mon Sep 17 00:00:00 2001
From: Evandro Fonseca Junior <345073078+edevbr@users.noreply.github.com>
Date: Mon, 2 Nov 2026 01:24:50 -0300
Subject: [PATCH] WaylandKeybinds: localized info panel with setup steps
- Info panel in the plugin modal: live status with the number of keybinds
GNOME registered, the three setup steps and a button that opens
Discord's Keybinds page (closes only the plugin modal, which otherwise
blocks settings navigation).
- Follows Discord's language: English, Portuguese (pt-BR) and Spanish
(es-ES and es-419 use each region's action names); bold labels quote
Discord's own strings.
- Shared helpers moved to discord.ts; copy and locale choice in copy.ts,
covered by basis-path tests (41 tests).
- README: current settings path (User Settings > System > Keybinds).
---
README.md | 4 +-
about.tsx | 86 +++++++++++++++++++++++++++++++++++++++++
copy.ts | 79 ++++++++++++++++++++++++++++++++++++++
discord.ts | 24 ++++++++++++
index.ts | 24 ++++--------
logic.test.ts | 48 +++++++++++++++++++++++
style.css | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 351 insertions(+), 18 deletions(-)
create mode 100644 about.tsx
create mode 100644 copy.ts
create mode 100644 discord.ts
create mode 100644 style.css
diff --git a/README.md b/README.md
index ad22d44..caaaa2b 100644
--- a/README.md
+++ b/README.md
@@ -39,14 +39,14 @@ pnpm build
pnpm inject
```
-Restart Discord. The plugin is on by default; you can find it as **WaylandKeybinds** in Vencord's plugin list.
+Restart Discord. The plugin is on by default; you can find it as **WaylandKeybinds** in Vencord's plugin list. Its info panel shows whether the fix is active, how many keybinds GNOME has registered, and a button that opens Discord's Keybinds page.
> [!IMPORTANT]
> Keep the folder name `waylandKeybinds.discordDesktop`. The suffix limits the plugin to the Discord desktop app, the only place where Discord's native keybind module exists.
## Use
-1. Open Discord **User Settings → Keybinds → Add a Keybind**.
+1. Open Discord **User Settings → System → Keybinds** and click **Add a Keybind**.
2. Pick an action (Toggle Mute, Toggle Deafen, Push to Talk…) and press your combination.
3. GNOME asks once to allow the shortcut. Click **Add**.
diff --git a/about.tsx b/about.tsx
new file mode 100644
index 0000000..633045e
--- /dev/null
+++ b/about.tsx
@@ -0,0 +1,86 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2026 Evandro
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { Button } from "@components/Button";
+import { findByPropsLazy } from "@webpack";
+import { i18n, SettingsRouter, useState, useStateFromStores } from "@webpack/common";
+
+import { pickCopy } from "./copy";
+import { KeybindsStore, portalActive } from "./discord";
+
+function locale(): string {
+ try {
+ return i18n.intl.currentLocale ?? document.documentElement.lang;
+ } catch {
+ return document.documentElement.lang;
+ }
+}
+
+// the panel renders inside the plugin modal, so the top of Discord's modal stack at mount is that modal
+const ModalStack = findByPropsLazy("getOpenModalKeys", "closeModal");
+
+function topModalKey(): string | undefined {
+ try {
+ return [...ModalStack.getOpenModalKeys()].at(-1);
+ } catch {
+ return undefined;
+ }
+}
+
+function openKeybinds(pluginModalKey: string | undefined) {
+ // settings ignore navigation while another modal sits on top of them
+ if (pluginModalKey) ModalStack.closeModal(pluginModalKey);
+ SettingsRouter.openUserSettings("system_custom_keybinds_category");
+}
+
+function StatusIcon({ active }: { active: boolean; }) {
+ return (
+
+ );
+}
+
+export function AboutPanel() {
+ const copy = pickCopy(locale());
+ const active = portalActive();
+ const [modalKey] = useState(topModalKey);
+ const registered = useStateFromStores([KeybindsStore], () =>
+ Object.keys(KeybindsStore.getUserAgnosticState() ?? {})
+ .filter(id => KeybindsStore.getWaylandPortalShortcutState(id)?.bound).length
+ );
+
+ if (!active) {
+ return (
+
+
+ {copy.inactive}
+
+ );
+ }
+
+ return (
+
+
+
+ {copy.active(registered)}
+
+
{copy.title}
+
{copy.lead}
+ {/* list-style is removed for the numbered badges, so keep the list role for screen readers */}
+
+ {copy.steps.map(([before, bold, after], i) =>
{before}{bold && {bold}}{after}
)}
+
+
+
+
{copy.tip}
+
+
+ );
+}
diff --git a/copy.ts b/copy.ts
new file mode 100644
index 0000000..e0fe557
--- /dev/null
+++ b/copy.ts
@@ -0,0 +1,79 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2026 Evandro
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// Labels in bold quote Discord's own strings for each locale, so users find them on screen
+export interface Copy {
+ active(registered: number): string;
+ inactive: string;
+ title: string;
+ lead: string;
+ // [before, bold, after]: plain data, because JSX can't run before Vencord has loaded React
+ steps: [string, string, string][];
+ open: string;
+ tip: string;
+}
+
+export const EN: Copy = {
+ active: n => n === 0
+ ? "Active on this system · no keybinds yet"
+ : `Active on this system · ${n} ${n === 1 ? "keybind" : "keybinds"} registered`,
+ inactive: "Not needed on this system: the fix only acts on Linux with Wayland.",
+ title: "Your Discord keybinds now work in every app",
+ lead: "Set them up the same way as on Windows, on Discord's own Keybinds page.",
+ steps: [
+ ["Open ", "Keybinds", " under System in Discord's User Settings."],
+ ["Click ", "Add a Keybind", ", choose an action such as Toggle Mute, Toggle Deafen or Push to Talk, and record your keys."],
+ ["When your system asks to add the shortcut, ", "allow it", "."]
+ ],
+ open: "Open Keybinds",
+ tip: "Push to Talk is most reliable on a single key without modifiers, such as Pause or F13."
+};
+
+export const PT: Copy = {
+ active: n => n === 0
+ ? "Ativo neste sistema · nenhum atalho ainda"
+ : `Ativo neste sistema · ${n} ${n === 1 ? "atalho registrado" : "atalhos registrados"}`,
+ inactive: "Não é necessário neste sistema: a correção só atua no Linux com Wayland.",
+ title: "Seus atalhos do Discord agora funcionam em qualquer aplicativo",
+ lead: "Configure do mesmo jeito que no Windows, na própria página de atalhos do Discord.",
+ steps: [
+ ["Abra ", "Atalhos do teclado", " em Sistema, nas Configurações de usuário."],
+ ["Clique em ", "Adicionar atalho", ", escolha a ação (Ativar/desativar microfone, Ativar/desativar áudio, Aperte para Falar…) e grave as teclas."],
+ ["Quando o sistema pedir para adicionar o atalho, ", "permita", "."]
+ ],
+ open: "Abrir Atalhos do teclado",
+ tip: "No Aperte para Falar, prefira uma tecla única sem modificadores, como Pause ou F13."
+};
+
+// Spain and Latin American Spanish name the voice actions differently in Discord
+function spanish(mute: string, deafen: string): Copy {
+ return {
+ active: n => n === 0
+ ? "Activo en este sistema · todavía no hay atajos"
+ : `Activo en este sistema · ${n} ${n === 1 ? "atajo registrado" : "atajos registrados"}`,
+ inactive: "No hace falta en este sistema: la corrección solo actúa en Linux con Wayland.",
+ title: "Tus atajos de Discord ahora funcionan en cualquier aplicación",
+ lead: "Configúralos igual que en Windows, en la propia página de atajos de Discord.",
+ steps: [
+ ["Abre ", "Atajos de teclado", " en Sistema, dentro de los Ajustes de usuario."],
+ ["Haz clic en ", "Añadir un atajo de teclado", `, elige la acción (${mute}, ${deafen}, Pulsar para hablar…) y graba las teclas.`],
+ ["Cuando el sistema pida añadir el atajo, ", "permítelo", "."]
+ ],
+ open: "Abrir Atajos de teclado",
+ tip: "Para Pulsar para hablar, mejor usa una sola tecla sin modificadores, como Pause o F13."
+ };
+}
+
+export const ES_ES = spanish("Alternar silencio", "Alternar ensordecimiento");
+export const ES_419 = spanish("Activar o desactivar silencio", "Activar o desactivar ensordecer");
+
+export function pickCopy(locale: string): Copy {
+ const lang = locale.toLowerCase();
+ if (lang.startsWith("pt")) return PT;
+ if (lang === "es-419") return ES_419;
+ if (lang.startsWith("es")) return ES_ES;
+ return EN;
+}
diff --git a/discord.ts b/discord.ts
new file mode 100644
index 0000000..94be29f
--- /dev/null
+++ b/discord.ts
@@ -0,0 +1,24 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2026 Evandro
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import { IS_LINUX } from "@utils/constants";
+import { findStoreLazy } from "@webpack";
+
+export type Shortcut = [number, number, ...unknown[]][];
+
+export const KeybindsStore = findStoreLazy("KeybindsStore");
+
+export function discordUtils() {
+ return DiscordNative.nativeModules.requireModule("discord_utils");
+}
+
+export function portalActive(): boolean {
+ return IS_LINUX && discordUtils().isWaylandGlobalShortcutsAvailable?.() === true;
+}
+
+export function portalTrigger(shortcut: Shortcut): string {
+ return discordUtils().getWaylandPortalTrigger?.(shortcut.map(([type, code]) => [type, code])) ?? "";
+}
diff --git a/index.ts b/index.ts
index 76747da..4926016 100644
--- a/index.ts
+++ b/index.ts
@@ -4,15 +4,14 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
-import { IS_LINUX } from "@utils/constants";
import { Logger } from "@utils/Logger";
import definePlugin from "@utils/types";
-import { findStoreLazy } from "@webpack";
+import { AboutPanel } from "./about";
+import { KeybindsStore, portalActive, portalTrigger, Shortcut } from "./discord";
import { adjustOptions, Callback, createDispatcher, Dispatcher, fixPortalState, KeyEventOptions, planRegistration, PortalState, Scheduler } from "./logic";
import { PATCHES } from "./patches";
-
-type Shortcut = [number, number, ...unknown[]][];
+import managedStyle from "./style.css?managed";
// Discord's own native keybind bridge (the object the KeybindsStore registers through)
interface Bridge {
@@ -28,28 +27,21 @@ const scheduler: Scheduler = {
};
const logger = new Logger("WaylandKeybinds");
-const KeybindsStore = findStoreLazy("KeybindsStore");
-
-function discordUtils() {
- return DiscordNative.nativeModules.requireModule("discord_utils");
-}
-
-function portalTrigger(shortcut: Shortcut): string {
- return discordUtils().getWaylandPortalTrigger?.(shortcut.map(([type, code]) => [type, code])) ?? "";
-}
export default definePlugin({
name: "WaylandKeybinds",
- description: "Makes Discord's global keybinds reliable on GNOME Wayland: toggles (mute, deafen) fire on key press and push-to-talk recovers from key releases GNOME drops",
+ description: "Makes Discord's keybinds work in every app on Linux Wayland: set them up in Discord's Keybinds page as usual and allow the system prompt",
authors: [{ name: "Evandro", id: 281463271758888960n }],
// only changes behaviour when Discord registers keybinds through the Wayland GlobalShortcuts portal
enabledByDefault: true,
patches: PATCHES,
+ managedStyle,
+ settingsAboutComponent: AboutPanel,
wrapRegistration(bridge: Bridge, id: number, shortcut: Shortcut, callback: Callback, options: KeyEventOptions): [Callback, KeyEventOptions] {
try {
- const portalActive = IS_LINUX && discordUtils().isWaylandGlobalShortcutsAvailable?.() === true;
- const mode = planRegistration(options, portalActive, portalActive && portalTrigger(shortcut) !== "");
+ const active = portalActive();
+ const mode = planRegistration(options, active, active && portalTrigger(shortcut) !== "");
const adjusted = adjustOptions(options, mode);
dispatchers.get(id)?.cancel();
const dispatcher = createDispatcher(callback, mode, () => {
diff --git a/logic.test.ts b/logic.test.ts
index 4770d66..8618a51 100644
--- a/logic.test.ts
+++ b/logic.test.ts
@@ -8,6 +8,7 @@
import assert from "node:assert/strict";
import { describe, test } from "node:test";
+import { EN, ES_419, ES_ES, pickCopy, PT } from "./copy";
import { adjustOptions, createDispatcher, ECHO_MS, FIRST_PROBE_MS, fixPortalState, normalizeTrigger, planRegistration, PROBE_WINDOW_MS } from "./logic";
function recorder() {
@@ -241,3 +242,50 @@ describe("fixPortalState (V(G)=5)", () => {
assert.equal(fixPortalState(gnome, "CTRL+ALT+F12"), gnome);
});
});
+
+describe("pickCopy (V(G)=4)", () => {
+ test("P1 português → PT", () => {
+ assert.equal(pickCopy("pt-BR"), PT);
+ });
+ test("P2 espanhol da América Latina → ES_419", () => {
+ assert.equal(pickCopy("es-419"), ES_419);
+ });
+ test("P3 espanhol da Espanha → ES_ES", () => {
+ assert.equal(pickCopy("es-ES"), ES_ES);
+ });
+ test("P4 inglês → EN", () => {
+ assert.equal(pickCopy("en-US"), EN);
+ });
+ test("borda: idioma fora dos três → EN", () => {
+ assert.equal(pickCopy("fr"), EN);
+ assert.equal(pickCopy("ja"), EN);
+ });
+ test("borda: idioma vazio → EN", () => {
+ assert.equal(pickCopy(""), EN);
+ });
+ test("borda: maiúsculas e minúsculas não importam", () => {
+ assert.equal(pickCopy("PT-br"), PT);
+ assert.equal(pickCopy("ES-419"), ES_419);
+ });
+});
+
+describe("textos do painel", () => {
+ test("botão citado no passo 2 é o rótulo oficial do Discord em cada idioma", () => {
+ assert.equal(EN.steps[1][1], "Add a Keybind");
+ assert.equal(PT.steps[1][1], "Adicionar atalho");
+ assert.equal(ES_ES.steps[1][1], "Añadir un atajo de teclado");
+ assert.equal(ES_419.steps[1][1], "Añadir un atajo de teclado");
+ });
+ test("as variantes de espanhol usam os nomes de ação de cada região", () => {
+ assert.match(ES_ES.steps[1][2], /Alternar silencio, Alternar ensordecimiento/);
+ assert.match(ES_419.steps[1][2], /Activar o desactivar silencio, Activar o desactivar ensordecer/);
+ });
+ test("plural do status", () => {
+ for (const copy of [EN, PT, ES_ES]) {
+ assert.notEqual(copy.active(0), copy.active(1));
+ assert.notEqual(copy.active(1).replace("1", "2"), copy.active(2));
+ }
+ assert.equal(ES_ES.active(1), "Activo en este sistema · 1 atajo registrado");
+ assert.equal(ES_ES.active(2), "Activo en este sistema · 2 atajos registrados");
+ });
+});
diff --git a/style.css b/style.css
new file mode 100644
index 0000000..2950a34
--- /dev/null
+++ b/style.css
@@ -0,0 +1,104 @@
+.vc-wk-panel {
+ display: grid;
+ gap: 12px;
+ padding: 18px 20px 20px;
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md, 12px);
+ background: var(--background-mod-subtle);
+ color: var(--text-default);
+}
+
+.vc-wk-status {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0;
+ font-size: 14px;
+ line-height: 20px;
+ color: var(--text-muted);
+}
+
+.vc-wk-status-icon {
+ flex: none;
+ color: var(--interactive-icon-default);
+}
+
+.vc-wk-status-on {
+ font-weight: 600;
+ color: var(--text-default);
+}
+
+.vc-wk-status-on .vc-wk-status-icon {
+ color: var(--status-positive);
+}
+
+.vc-wk-title {
+ margin: 2px 0 0;
+ font-family: var(--font-display);
+ font-size: 20px;
+ font-weight: 700;
+ line-height: 26px;
+ color: var(--text-strong);
+ text-wrap: balance;
+}
+
+.vc-wk-lead {
+ margin: -4px 0 0;
+ max-width: 62ch;
+ font-size: 15px;
+ line-height: 22px;
+}
+
+.vc-wk-steps {
+ display: grid;
+ gap: 10px;
+ margin: 6px 0 0;
+ padding: 0;
+ list-style: none;
+ counter-reset: vc-wk-step;
+}
+
+.vc-wk-steps li {
+ display: grid;
+ grid-template-columns: 24px 1fr;
+ gap: 12px;
+ max-width: 66ch;
+ font-size: 15px;
+ line-height: 22px;
+ counter-increment: vc-wk-step;
+}
+
+.vc-wk-steps li::before {
+ content: counter(vc-wk-step);
+ display: grid;
+ place-items: center;
+ width: 24px;
+ height: 24px;
+ margin-top: -1px;
+ border-radius: 50%;
+ background: var(--background-mod-strong);
+ color: var(--text-strong);
+ font-size: 13px;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+}
+
+.vc-wk-steps b {
+ color: var(--text-strong);
+}
+
+.vc-wk-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 12px 16px;
+ margin-top: 8px;
+}
+
+.vc-wk-tip {
+ flex: 1 1 240px;
+ margin: 0;
+ font-size: 13px;
+ line-height: 18px;
+ color: var(--text-muted);
+}