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 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] > [!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. > 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 ## 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. 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**. 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 * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import { IS_LINUX } from "@utils/constants";
import { Logger } from "@utils/Logger"; import { Logger } from "@utils/Logger";
import definePlugin from "@utils/types"; 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 { adjustOptions, Callback, createDispatcher, Dispatcher, fixPortalState, KeyEventOptions, planRegistration, PortalState, Scheduler } from "./logic";
import { PATCHES } from "./patches"; import { PATCHES } from "./patches";
import managedStyle from "./style.css?managed";
type Shortcut = [number, number, ...unknown[]][];
// Discord's own native keybind bridge (the object the KeybindsStore registers through) // Discord's own native keybind bridge (the object the KeybindsStore registers through)
interface Bridge { interface Bridge {
@@ -28,28 +27,21 @@ const scheduler: Scheduler = {
}; };
const logger = new Logger("WaylandKeybinds"); 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({ export default definePlugin({
name: "WaylandKeybinds", 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 }], authors: [{ name: "Evandro", id: 281463271758888960n }],
// only changes behaviour when Discord registers keybinds through the Wayland GlobalShortcuts portal // only changes behaviour when Discord registers keybinds through the Wayland GlobalShortcuts portal
enabledByDefault: true, enabledByDefault: true,
patches: PATCHES, patches: PATCHES,
managedStyle,
settingsAboutComponent: AboutPanel,
wrapRegistration(bridge: Bridge, id: number, shortcut: Shortcut, callback: Callback, options: KeyEventOptions): [Callback, KeyEventOptions] { wrapRegistration(bridge: Bridge, id: number, shortcut: Shortcut, callback: Callback, options: KeyEventOptions): [Callback, KeyEventOptions] {
try { try {
const portalActive = IS_LINUX && discordUtils().isWaylandGlobalShortcutsAvailable?.() === true; const active = portalActive();
const mode = planRegistration(options, portalActive, portalActive && portalTrigger(shortcut) !== ""); const mode = planRegistration(options, active, active && portalTrigger(shortcut) !== "");
const adjusted = adjustOptions(options, mode); const adjusted = adjustOptions(options, mode);
dispatchers.get(id)?.cancel(); dispatchers.get(id)?.cancel();
const dispatcher = createDispatcher(callback, mode, () => { const dispatcher = createDispatcher(callback, mode, () => {
+48
View File
@@ -8,6 +8,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, test } from "node:test"; 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"; import { adjustOptions, createDispatcher, ECHO_MS, FIRST_PROBE_MS, fixPortalState, normalizeTrigger, planRegistration, PROBE_WINDOW_MS } from "./logic";
function recorder() { function recorder() {
@@ -241,3 +242,50 @@ describe("fixPortalState (V(G)=5)", () => {
assert.equal(fixPortalState(gnome, "CTRL+ALT+F12"), gnome); 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);
}