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).
This commit is contained in:
Evandro Fonseca Junior committed 2026-11-02 01:24:50 -03:00
1 parent a3a9149184
commit 34b61e2509
7 files changed
+351 -18

No files matched your search

+2 -2
View File
@@ -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**.
+86
View File
@@ -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 (
<svg className="vc-wk-status-icon" viewBox="0 0 20 20" width="20" height="20" aria-hidden="true">
<circle cx="10" cy="10" r="8.25" fill="none" stroke="currentColor" strokeWidth="1.5" />
{active
? <path d="m6.5 10.25 2.4 2.4 4.6-5" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
: <path d="M10 9v4.5M10 6.25v.01" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />}
</svg>
);
}
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 (
<p className="vc-wk-status">
<StatusIcon active={false} />
{copy.inactive}
</p>
);
}
return (
<section className="vc-wk-panel" aria-labelledby="vc-wk-title">
<p className="vc-wk-status vc-wk-status-on">
<StatusIcon active />
{copy.active(registered)}
</p>
<h3 id="vc-wk-title" className="vc-wk-title">{copy.title}</h3>
<p className="vc-wk-lead">{copy.lead}</p>
{/* list-style is removed for the numbered badges, so keep the list role for screen readers */}
<ol className="vc-wk-steps" role="list">
{copy.steps.map(([before, bold, after], i) => <li key={i}><span>{before}{bold && <b>{bold}</b>}{after}</span></li>)}
</ol>
<div className="vc-wk-actions">
<Button onClick={() => openKeybinds(modalKey)}>{copy.open}</Button>
<p className="vc-wk-tip">{copy.tip}</p>
</div>
</section>
);
}
+79
View File
@@ -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;
}
+24
View File
@@ -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])) ?? "";
}
+8 -16
View File
@@ -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, () => {
+48
View File
@@ -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");
});
});
+104
View File
@@ -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);
}