Files
vencord-wayland-keybinds/specs/2026-11-01-wayland-keybinds.md
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

109 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <Shift><Control>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 <Shift><Control>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<T extends KeyEventOptions>(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<S extends PortalState>(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 <Shift><Control>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.