# WaylandKeybinds: atalhos globais confiáveis do Discord no GNOME (Wayland) **Autor:** Evandro · **Data:** 2026-11-01 · **Porte:** médio · **Status:** concluída ## 1. Objetivo Fazer os atalhos globais do Discord (alternar mudo/ensurdecer, push-to-talk) funcionarem de forma confiável no GNOME Wayland para quem usa Vencord. O Discord 1.0.168 já registra atalhos pelo portal `GlobalShortcuts`, mas no GNOME o evento de soltar falha e o estado do portal é lido errado. ## 2. Escopo - Dentro: plugin de renderer `src/plugins/waylandKeybinds.discordDesktop/` (sem `native.ts`, sem dependência nova, sem DOM), com lógica pura testável e dois patches no módulo do `KeybindsStore`. - Fora: push-to-talk perfeito com modificadores (o mutter não entrega a soltura; ver §6), botões de mouse, `SOUNDBOARD_HOLD` (o Discord desliga o portal para ele no Linux), atalhos do VoiceGuard, Vesktop, PR upstream. ## 3. Critérios de aceite - CA1: Com portal ativo e atalho representável, uma ação só-soltar (`TOGGLE_MUTE`, `TOGGLE_DEAFEN` etc.) dispara no pressionar, também quando o GNOME perde a soltura, e o gesto seguinte não é engolido. → `wk:test` + teste ao vivo T8. - CA2: Ação de segurar (push-to-talk): termina na soltura real ou, se ela se perder, em até ~1,3 s depois de a tecla ser solta (sem repetição após re-registro); continua enquanto a repetição chegar. → `wk:test` (relógio falso). - CA9: Quando o Discord desregistra o atalho (apagado/alterado), a sondagem para e nada é re-registrado. → `wk:test` (`cancel`) + patch do `inputEventUnregister` no CA5. - CA3: Sem portal (inativo, combinação não representável ou `portal:false`) o registro passa intacto: mesmo callback, mesmas opções. → `pnpm wk:test`. - CA4: A descrição do GNOME `Press F12` é reconhecida como igual a `CTRL+SHIFT+F12`; o estado passa a `matchesTrigger: true`. Estados diferentes, não vinculados ou sem gatilho esperado ficam como estão. → `pnpm wk:test`. - CA5: Os patches aplicam no Discord atual: cada `find` ocorre uma vez, cada `match` casa uma vez e o bundle corrigido continua válido. → `node check-patches.mjs` contra `discord.com/app` baixado. - CA6: `pnpm build --dev`, `tsc --noEmit` e `eslint` do plugin sem erros. → comandos. - CA7: Toda função de `logic.ts` com CC ≤ 10 e todos os caminhos-base cobertos. → `uvx lizard` + tabela na seção 8. - CA8: No Discord real, alternar mudo por atalho global funciona soltando os modificadores antes da tecla. → teste ao vivo com o usuário (exige reiniciar o Discord). ## 4. Contexto verificado - `modules/discord_utils-1/discord_utils/discord_utils.node`: `WaylandGlobalShortcuts` (sd-bus), log `Wayland GlobalShortcuts initialized; appId='discord'` em toda inicialização (journal); filtra eventos por `keydown`/`keyup` (medido: atalho só-soltar não entrega o pressionar). - Renderer `web.f86c748842ee176f.js`, módulo do `KeybindsStore`: `inputEventRegister(e,t,n,i)` com `{focused:!0,blurred:!0,keydown:!1,keyup:!1,...keyEvents,description,shortcutId}`; `setWaylandPortalShortcutsCallback(e=>{...t[String(n.eventId)]=n;...})`. - Tabela de ações: `TOGGLE_MUTE`/`TOGGLE_DEAFEN` = `keyup:!0,keydown:!1`; `PUSH_TO_TALK` = `keyup:!0,keydown:!0`; `SOUNDBOARD_HOLD` = `portal:isWindows()`. - Tela de atalhos (`82738a067c897563.js`, componente `lB`): `bound && matchesTrigger===false` mostra aviso com `triggerDescription`; regravar a mesma combinação chama `reofferWaylandPortalShortcut` (reabre o diálogo do GNOME). - mutter `src/core/keybindings.c` `process_event`: o soltar procura o atalho pelo código da tecla + modificadores ainda pressionados; soltar um modificador antes faz o soltar nunca casar. - Portal do GNOME 50 devolve `triggerDescription` = `Press F12`; o nativo compara com `CTRL+SHIFT+F12` (de `getWaylandPortalTrigger`) e marca `matchesTrigger:false` sempre. - Padrão de plugin: `src/plugins/noDeepLinks.web/index.ts` (patch com `$self`). ## 5. Desenho - Corrigir no renderer, onde o Discord monta o registro: troca o modo de entrega por ação. Alternativas descartadas: D-Bus próprio (`dbus-next`/Rust: rejeitado nos PRs #326/#3505 e duplica o que o nativo já faz) e clique em DOM (regra do CONTRIBUTING). - `logic.ts` (puro, sem imports do Vencord): - `type DispatchMode = "passthrough" | "pressAsRelease" | "hold"` - `planRegistration(options: KeyEventOptions, portalActive: boolean, representable: boolean): DispatchMode` - `adjustOptions(options: T, mode: DispatchMode): T` (pede pressionar e soltar ao nativo) - `createDispatcher(callback, mode, rearm: () => void, scheduler: Scheduler): { dispatch, cancel }`: no pressionar age e agenda sondagem em `FIRST_PROBE_MS` (1000); sondar = `rearm()` (re-registro, zera o estado preso do nativo) + janela `PROBE_WINDOW_MS` (300); repetição em até `ECHO_MS` (150) após o re-registro = ainda segurado; sem repetição = solto; soltura dentro de `ECHO_MS` é eco sintético do re-registro e é ignorada. - `normalizeTrigger(trigger: string): string` → forma canônica `MODS|tecla` - `fixPortalState(state: S, expected: string | undefined): S` - `index.ts`: três patches no `find: "Skipping registration."`: `$self.wrapRegistration(bridge, id, shortcut, callback, options)` (devolve `[callback, options]`; o `rearm` usa a própria ponte do Discord), `$self.unregister(bridge, id)` (cancela a sondagem antes de desregistrar) e `$self.fixPortalState(state)`. Entradas vêm do nativo (`isWaylandGlobalShortcutsAvailable`, `getWaylandPortalTrigger`) e da store (`getUserAgnosticState()[id].shortcut`), tudo em `try/catch` com fallback para o comportamento original. - `patches.ts`: dados dos patches, usados pelo `index.ts` e pelo `check-patches.mjs`. - Arquivos (todos novos): `logic.ts`, `logic.test.ts`, `patches.ts`, `index.ts`, `check-patches.mjs`, esta spec. ## 6. Restrições, riscos, suposições - A inferência de soltura depende da repetição de tecla do GNOME (medido: atraso 500 ms, intervalo 30 ms). Com repetição desligada, o push-to-talk terminaria ~1,3 s após o pressionar mesmo segurado; com atraso > 1000 ms, a primeira sondagem erra. Reverter: desligar o plugin. - Cada sondagem é um re-registro (D-Bus `BindShortcuts`); só ocorre enquanto a tecla está pressionada. Medido: não reabre o diálogo do GNOME. - Patches dependem do bundle minificado; quebram em atualizações do Discord (o CA5 detecta). - Explicado na v2: os "pressionar" extras e os gestos sumidos vinham da repetição automática do GNOME e do estado preso do nativo. ## 7. Microtasks ### T1 · escrever testes de caminhos-base de `logic.ts` · N0 - Serve: CA1, CA2, CA3, CA4, CA7 · Depende de: nenhuma - Arquivos: criar `logic.test.ts` - Pronto quando: um caso por caminho-base de cada função; roda e falha (lógica ausente) - Verificar: `pnpm wk:test` → falha de import/asserção - [x] feito ### T2 · implementar `logic.ts` · N0 - Serve: CA1–CA4 · Depende de: T1 - Pronto quando: todos os testes passam - Verificar: `pnpm wk:test` → 0 falhas - [x] feito ### T3 · medir CC e conferir caminhos · N0 - Serve: CA7 · Depende de: T2 - Verificar: `uvx lizard logic.ts` → CC ≤ 10 em todas - [x] feito ### CP1 · após T1–T3 · N0 - [x] feito ### T4 · `patches.ts` + `index.ts` · N0 - Serve: CA1, CA4, CA6 · Depende de: T2 - Padrão: `src/plugins/noDeepLinks.web/index.ts` - Verificar: `pnpm build --dev` → sucesso - [x] feito ### T5 · `check-patches.mjs` contra o bundle real · N0 - Serve: CA5 · Depende de: T4 - Verificar: `node check-patches.mjs` → find único, match único, bundle válido - [x] feito ### T6 · tsc + eslint · N0 - Serve: CA6 · Depende de: T4 - [x] feito ### CP2 · após T4–T6 · N0 - [x] feito ### T7 · teste real: nativo do Discord + GNOME + `logic.ts` · N0 - Serve: CA1, S1 · Depende de: T2 · precisa de um toque do usuário - [x] feito ### T8 · teste ao vivo no Discord · N0 - Serve: CA8 · Depende de: CP2 · precisa do ok do usuário para reiniciar o Discord - [x] feito ## 8. Resultado por critério | CA | Resultado | Evidência | |---|---|---| | CA1 | ok | 31/31 testes; ao vivo: 11 pressionamentos → 11 alternâncias de mudo (11–16 ms), inclusive segurar de 6 s soltando os modificadores antes (57 repetições do GNOME → 1 alternância) e os gestos seguintes | | CA2 | ok (unidade) | testes com relógio falso; push-to-talk não exercitado ao vivo (sem atalho cadastrado) | | CA3 | ok | testes | | CA4 | ok | testes; ao vivo `Press F10/F11` → `matchesTrigger: true` | | CA5 | ok | `check-patches.mjs`: find único (módulo 532624), 3 matches × 1, módulo válido; ao vivo `Patched by WaylandKeybinds` | | CA6 | ok | `pnpm build --dev`, `tsc` 0 erros, eslint 0 | | CA7 | ok | CC máx. 7 (`planRegistration`), despachante 6; 32/32 mutantes mortos | | CA8 | ok (mudo) | confirmado pelo usuário; ensurdecer registrado e vinculado no GNOME, não pressionado no teste | | CA9 | ok (unidade) | teste `cancel` + patch do `inputEventUnregister` aplicado; não exercitado ao vivo | ## 9. Registro - 2026-11-01 — aprovação prévia do usuário ("pode fazer tudo de maneira autônoma"); portão da spec dispensado. - 2026-11-01 — `wk:test` é um alias de comando, não script no `package.json` (não mexer em arquivo versionado): `pnpm exec esbuild logic.test.ts --bundle --platform=node --format=esm --outfile=/tmp/wk-test.mjs && node --test /tmp/wk-test.mjs`. - 2026-11-02 — v1 (agir no pressionar) insuficiente ao vivo: o nativo do Discord ignora `Activated` enquanto acha a tecla pressionada; soltura perdida o deixa preso e o gesto seguinte some. Medido com `busctl monitor` (portal) × eventos crus no renderer (CDP): repetição automática do GNOME (500 ms, depois 30 ms) e engolimento até `Deactivated`. - 2026-11-02 — experimento ao vivo: re-registrar (`inputEventUnregister`+`inputEventRegister`) zera o estado; com a tecla segurada, a repetição chega 0–30 ms após o re-registro; solta, nada chega. Base da v2 (sondagem). Desregistro gera soltura sintética assíncrona (tratada como eco). - 2026-11-02 — usuário pediu tecla configurável: já é, pela tela de atalhos do Discord; o plugin não fixa teclas e vale para todas as ações (mudo, ensurdecer, push-to-talk). - 2026-11-02 — a pedido do usuário, movido de `src/userplugins` para `src/plugins` (branch `feat/wayland-keybinds`, sem commit) com `enabledByDefault: true`: só altera o comportamento quando o Discord registra pelo portal.