Skip to content

Fix global shortcuts silently degrading to focus bound on relaunch - #1324

Closed
pendo324 wants to merge 8 commits into
Vencord:mainfrom
pendo324:global-shortcuts-native-portal
Closed

pendo324 wants to merge 8 commits into
Vencord:mainfrom
pendo324:global-shortcuts-native-portal

Conversation

@pendo324

@pendo324 pendo324 commented Sep 5, 2026 •

Copy link
Copy Markdown

Summary

Global shortcuts registered through Electron's globalShortcut module on Linux/Wayland+KDE only work while Vesktop has focus after the first relaunch, requiring the user to manually remove and re-add the shortcut in System Settings each time.

Root cause

Traced via D-Bus tracing (busctl monitor) and Chromium source to a real upstream bug in GlobalAcceleratorListenerLinux::OnListShortcuts():

// If any bound command is not found among the registered shortcuts, bind
// them.
for (const auto& [modified_id, bound_cmd] : bound_commands_) {
  if (!registered_ids.contains(modified_id)) {
    ...
    return;
  }
}
// Every command is already registered with the portal, so no bind pass is
// needed. Mark the session as bound...
bind_state_ = BindState::kBound;

Shortcut IDs are derived from a hash of the accelerator group/profile ID (see GetShortcutPrefix() in the same file), so they're stable across relaunches. On relaunch, ListShortcuts returns the IDs from the previous session (the portal remembers them), so every modified_id is already containsed, the loop never enters its body, and BindShortcuts() is never called for the new session. The shortcut then quietly degrades to focus-bound, while KDE's System Settings still shows it as "registered" (stale, from the old session). This can't be worked around from application code through Electron's globalShortcut API, since it's a bug in Electron/Chromium's own binding logic.

Related, though not identical: electron/electron#52218 documents the adjacent problem that globalShortcut.register()/isRegistered() can't distinguish bound/pending/denied on this same Wayland portal path at all, since nothing surfaces the BindShortcuts response to callers.

Fix

Replaced Electron's globalShortcut on Linux/Wayland with a direct org.freedesktop.portal.GlobalShortcuts binding, implemented in a new Rust GlobalShortcuts class (via ashpd) inside packages/libvesktop. It sidesteps the bug entirely by never consulting ListShortcuts to decide whether to bind. Instead, every launch unconditionally calls BindShortcuts for the current session using a stable, app-generated shortcut ID (the keybind's own UUID from settings), which KDE's kglobalaccel recognizes and reuses the existing consent grant for, so there's no repeat prompt.

Verified via D-Bus tracing across multiple consecutive relaunches. Shortcuts bind silently with no re-prompt, and fire correctly from an unfocused window consistently.

Why a native binding instead of Electron's API

This trades being at the mercy of Electron/Chromium's shortcut implementation (and whatever bugs land in it) for direct control over the actual portal protocol calls. We talk to xdg-desktop-portal ourselves, so when something like this bug shows up we can fix it immediately rather than waiting on and tracking an upstream Electron fix. It also opens the door to functionality Electron's globalShortcut doesn't expose at all, like real keyup/keydown events (needed for correct push-to-talk as seen in #1290) instead of just a single fire-on-keydown callback.

Unlike at the time when PR #326 was proposed, now Vesktop already has a native module dependency in tree. This PR simply adds (relatively minimal) new functionality to the module, and ports it to Rust (which arguably has a more ergonomic NAPI developer experience).

What changed

  • packages/libvesktop ported from C++/node-addon-api to Rust/napi-rs 3
    • getAccentColor, updateUnityLauncherCount, requestBackground are now async (deliberate breaking change)
    • New GlobalShortcuts class, exposing a discriminated-union ShortcutsEvent payload over a threadsafe-function callback
  • Cross-compilation switched from Docker+node-gyp to cargo zigbuild, targeting an explicit glibc 2.17 floor for both x86_64/aarch64 without needing a container (verified via objdump that the built .so's max required glibc symbol is exactly 2.17)
    • Dockerfile kept only as an optional, documented local fallback for a hermetic build (consider replacing prebuilds/*.node binaries with CI/Docker built binaries). Uses fedora:44 since Fedora packages zig, rustup, and a current nodejs natively (Debian needs NodeSource for a current Node and has no zig package at all)
  • src/main/globalShortcuts/nativeGlobalShortcuts.ts lazy-loads the native module, falls back cleanly to Electron's globalShortcut if unavailable
  • src/main/globalShortcuts/acceleratorToXdgTrigger.ts converts Electron accelerator strings to XDG shortcuts spec trigger format
  • src/main/keyBinds.ts gated on shouldUseNativeGlobalShortcuts() (Linux+Wayland only)

Prior art

Vendicated and others added 8 commits September 4, 2026 20:01
e.g. create gnome keybind with following command:
`bash -c 'echo run-shortcut:toggleMute | nc -U $XDG_RUNTIME_DIR/vesktop.sock'`
BooleanSetting no longer has a defaultValue field on latest main;
this was left over from before the global-shortcuts branch was
rebased and caused a type error.
The Modal composition API (ModalRoot/ModalHeader/ModalContent/
ModalCloseButton) was replaced upstream by a single <Modal> component
with size/title props (see DeveloperOptions.tsx for the current
pattern). Also drops an eslint-disable directive that's no longer
triggered after the import merge in main.ts.
Replaces Electron's globalShortcut on Linux/Wayland with a direct
org.freedesktop.portal.GlobalShortcuts binding via ashpd, working around
a Chromium bug where relaunching silently degrades global shortcuts to
focus-bound only.

- packages/libvesktop ported from C++/node-addon-api to Rust/napi-rs 3
- GlobalShortcuts class binds via ashpd, unconditionally calling
  BindShortcuts on every launch instead of consulting ListShortcuts
- getAccentColor/updateUnityLauncherCount/requestBackground are now async
- Cross-compiled with cargo-zigbuild (glibc 2.17 floor), no Docker needed;
  Dockerfile kept as a documented, optional local fallback (Fedora-based)
- src/main/globalShortcuts/*.ts wires it in, falling back to Electron's
  globalShortcut when the native module or Wayland session is unavailable
@Vendicated

Copy link
Copy Markdown
Member

5000 lines of aislop, incredible

next time save your tokens

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants