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

10 KiB
Raw Blame History

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
  • feito

T2 · implementar logic.ts · N0

  • Serve: CA1–CA4 · Depende de: T1
  • Pronto quando: todos os testes passam
  • Verificar: pnpm wk:test → 0 falhas
  • feito

T3 · medir CC e conferir caminhos · N0

  • Serve: CA7 · Depende de: T2
  • Verificar: uvx lizard logic.ts → CC ≤ 10 em todas
  • feito

CP1 · após T1–T3 · N0

  • 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
  • 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
  • feito

T6 · tsc + eslint · N0

  • Serve: CA6 · Depende de: T4
  • feito

CP2 · após T4–T6 · N0

  • feito

T7 · teste real: nativo do Discord + GNOME + logic.ts · N0

  • Serve: CA1, S1 · Depende de: T2 · precisa de um toque do usuário
  • feito

T8 · teste ao vivo no Discord · N0

  • Serve: CA8 · Depende de: CP2 · precisa do ok do usuário para reiniciar o Discord
  • 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.