wayland-global-shortcut
v0.2.0
Published
Global shortcuts on Wayland (and XWayland) through the XDG Desktop Portal.
Maintainers
Readme
Installation
Install the wayland-global-shortcut package via npm:
npm install wayland-global-shortcutOverview
Wayland compositors do not allow applications to grab keys directly. Apps running under XWayland are affected too: X11 key grabs only receive events while an X11 window is focused, so "global" shortcuts silently become "focused-only" shortcuts.
The wayland-global-shortcut package registers shortcuts through the org.freedesktop.portal.GlobalShortcuts D-Bus portal, which is the standard way for any Wayland client (native or XWayland) to receive shortcuts regardless of focus.
Highlights
- Pure JavaScript — talks to D-Bus through
dbus-native, no native build required. - Framework agnostic — works with Electron, NW.js or plain Node.js processes.
- Accelerator syntax — uses the familiar
CmdOrCtrl+Shift+Hsyntax (the same as Electron) and converts it to the XDG shortcuts spec. - Promise based — every registration resolves after the portal (and the user) answers.
Requirements
- A Wayland session with an
xdg-desktop-portalbackend implementingGlobalShortcuts(e.g. GNOME, KDE Plasma 6, Hyprland). - A valid app ID. Recent portals (
xdg-desktop-portal1.21+, GNOME 50+) refuse unsandboxed apps that cannot be resolved to an installed.desktopfile:- The app ID must be a reverse-DNS name, e.g.
com.example.MyApp. - A
com.example.MyApp.desktopfile must be installed (e.g. in/usr/share/applicationsor~/.local/share/applications). - Electron: set
"desktopName": "com.example.MyApp.desktop"inpackage.json. The app ID is then read from theCHROME_DESKTOPenvironment variable automatically. Withelectron-builder, also setlinux.syncDesktopName: trueso the installed.desktopfile matches.
- The app ID must be a reverse-DNS name, e.g.
Usage
All public methods are documented with JSDoc and can be referenced during import.
Importing
const { WaylandGlobalShortcut } = require('wayland-global-shortcut')Creating an Instance
const shortcuts = new WaylandGlobalShortcut({ appId: 'com.example.MyApp' })| Option | Type | Default | Description |
|-----------|----------|--------------------------------------|--------------------------------------------------------------------|
| appId | string | CHROME_DESKTOP without .desktop | Reverse-DNS app ID matching the installed .desktop file. |
| timeout | number | 10000 | Timeout (ms) to connect to D-Bus and create the portal session. |
Registering Shortcuts
const ok = await shortcuts.register('CmdOrCtrl+Shift+H', () => {
console.log('Toggled!')
}, { description: 'Toggle window' })The description is shown by the desktop in the consent dialog and in the system shortcut settings. If not specified, the accelerator is used.
By default, the callback is called when the shortcut is released. The compositor keeps the key events of an active shortcut, so a window focused while the keys are still held down may never receive the key release, leaving the last key stuck (e.g. the W of Ctrl+W repeating in a text field). To call it as soon as the keys are pressed, use trigger: 'press':
await shortcuts.register('CmdOrCtrl+Shift+J', callback, { trigger: 'press' })To register multiple shortcuts with a single consent dialog, use registerAll:
await shortcuts.registerAll(['CmdOrCtrl+Shift+H', 'CmdOrCtrl+Shift+J'], callback, { description: 'Toggle window' })Unregistering Shortcuts
await shortcuts.unregister('CmdOrCtrl+Shift+H')
await shortcuts.unregisterAll()
// Unregister everything and close the D-Bus connection
await shortcuts.destroy()Using with Electron under XWayland
Electron's globalShortcut already uses the portal on native Wayland. When the app is forced to run under XWayland (--ozone-platform=x11, usually to keep window positioning and always-on-top working), use this package instead:
const { app, globalShortcut } = require('electron')
const { WaylandGlobalShortcut } = require('wayland-global-shortcut')
const isXWayland = process.platform === 'linux'
&& process.argv.includes('--ozone-platform=x11')
&& (process.env.XDG_SESSION_TYPE === 'wayland' || !!process.env.WAYLAND_DISPLAY)
app.whenReady().then(async () => {
const toggle = () => { /* ... */ }
if (isXWayland) {
await new WaylandGlobalShortcut().register('CmdOrCtrl+Shift+H', toggle, { description: 'Toggle window' })
} else {
globalShortcut.register('CmdOrCtrl+Shift+H', toggle)
}
})Converting Accelerators
The accelerator conversion is also exported:
const { toXdgTrigger } = require('wayland-global-shortcut')
toXdgTrigger('CmdOrCtrl+Shift+H') // 'CTRL+SHIFT+h'
toXdgTrigger('Alt+Plus') // 'ALT+SHIFT+equal'
toXdgTrigger('Super+num5') // 'LOGO+KP_5'
toXdgTrigger('AltGr+A') // null (not representable)
toXdgTrigger('Ctrl+Foo') // throws TypeErrorHow It Works
- On the first registration, a D-Bus session bus connection is opened and the app ID is registered through
org.freedesktop.host.portal.Registry. - A portal session is created and all shortcuts are bound with a single
BindShortcutscall, using the XDG trigger as both the shortcut ID and thepreferred_trigger. - The desktop may show a consent dialog. The returned promise resolves only after the user accepts or dismisses it.
- The portal allows a single bind per session, so every change (register/unregister) closes the session and binds the full set again. If a bind fails, the previous set is restored.
Deactivated(release) orActivated(press) signals from the current session call the matching callback, according to itstrigger.
Limitations
- The user can choose a different key combination in the consent dialog or in the system settings. The callback still fires, but the key may differ from the requested accelerator.
AltGrcombinations cannot be represented by the XDG shortcuts spec and are rejected.- Behavior varies by desktop: GNOME shows a consent dialog on the first bind; KDE binds silently and lists the shortcuts in System Settings.
API Reference
/**
* Registers a global shortcut.
* @param {string} accelerator - Accelerator string (e.g. `CmdOrCtrl+Shift+H`).
* @param {() => void} callback - Called when the shortcut is activated.
* @param {ShortcutOptions} [options] - Registration options (`description`, `trigger`).
* @returns {Promise<boolean>} Whether the shortcut was bound by the portal.
*/
register(accelerator, callback, options)
/**
* Registers multiple global shortcuts with a single portal bind.
* If any shortcut fails, none of them are registered.
* @returns {Promise<boolean>} Whether all shortcuts were bound by the portal.
*/
registerAll(accelerators, callback, options)
/**
* @returns {boolean} Whether the accelerator is registered by this instance.
*/
isRegistered(accelerator)
/**
* Unregisters a global shortcut.
* @returns {Promise<void>}
*/
unregister(accelerator)
/**
* Unregisters all global shortcuts. The D-Bus connection is kept open.
* @returns {Promise<void>}
*/
unregisterAll()
/**
* Unregisters all global shortcuts and closes the D-Bus connection.
* @returns {Promise<void>}
*/
destroy()Author
Created by Emerson Capuchi Romaneli (@ECRomaneli).
License
This project is licensed under the MIT License.
