wayland-input
v1.0.1
Published
Wayland-native input injection via XDG Desktop Portal RemoteDesktop — mouse, keyboard, and scroll with automatic X11 fallback
Maintainers
Readme
wayland-input
Wayland-native input injection via XDG Desktop Portal RemoteDesktop — mouse, keyboard, and scroll with automatic X11 fallback.
Handles the full XDG Desktop Portal RemoteDesktop session lifecycle, including a race condition fix that prevents silent hangs during session initialisation on compositors that auto-approve portal requests (e.g. GNOME).
Install
npm install wayland-inputRequirements
- Linux (Wayland or X11)
- For Wayland: GNOME 41+ or KDE Plasma 6.1+ (any compositor that implements the XDG Desktop Portal RemoteDesktop)
- For X11 fallback:
xdotoolinstalled (sudo apt install xdotool)
Compositor compatibility
| Compositor | Input injection | Notes | |---|---|---| | GNOME (41+) | ✅ Full support | Via XDG Desktop Portal | | KDE Plasma (6.1+) | ✅ Full support | Via XDG Desktop Portal | | Hyprland | 🔄 In progress | Portal backend being developed | | Sway | ❌ Not supported | No portal backend | | Niri | ❌ Not supported | No portal backend | | Cosmic | ❌ Not supported | No portal backend | | X11 (any desktop) | ✅ Full support | Via xdotool fallback | | XWayland apps | ✅ Full support | Automatically uses X11 fallback |
Usage
TypeScript
import { createInputInjector } from 'wayland-input'
// Create the injector at app startup — no dialog appears yet
const input = await createInputInjector({ portalTimeoutMs: 30000 })
// Call preinit() when your sharing session begins — this is when
// the Wayland permission dialog appears. Call it early so the session
// is ready before the first inject() call arrives.
input.preinit()
// Mouse
await input.inject({ type: 'mousemove', x: 500, y: 300 })
await input.inject({ type: 'click', button: 1, x: 500, y: 300 })
await input.inject({ type: 'mousedown', button: 1 })
await input.inject({ type: 'mouseup', button: 1 })
// Scroll
await input.inject({ type: 'scroll', deltaX: 0, deltaY: 100 })
// Keyboard
await input.inject({ type: 'keydown', key: 'Enter' })
await input.inject({ type: 'keyup', key: 'Enter' })
// Always close when done
await input.close()JavaScript (CommonJS)
const { createInputInjector } = require('wayland-input')
const input = await createInputInjector({ portalTimeoutMs: 30000 })
input.preinit()
await input.inject({ type: 'mousemove', x: 500, y: 300 })
await input.inject({ type: 'click', button: 1, x: 500, y: 300 })
await input.inject({ type: 'keydown', key: 'Enter' })
await input.close()How it works
On Wayland
The library requests input control through the XDG Desktop Portal RemoteDesktop interface (org.freedesktop.portal.RemoteDesktop). On first use, the system shows a permission dialog asking the user to allow input control. Once approved, the library can inject:
- Mouse movement — absolute (when stream dimensions are known) or relative
- Mouse buttons — left, middle, right click via evdev button codes
- Scroll — both axes via
NotifyPointerAxis - Keyboard — full key support via X11 keysyms via
NotifyKeyboardKeysym
The race condition fix
GNOME auto-approves the CreateSession call instantly — the Response signal arrives before a naive listener has time to subscribe, causing the initialisation to hang forever. This library solves this by predicting the exact D-Bus response path before making the call and subscribing first:
// Wrong (misses instant responses):
makeCall() → subscribeToResponse() → wait forever
// Correct (this library):
predictPath() → subscribeToResponse() → makeCall() → response caughtThis is a non-obvious fix — the XDG Desktop Portal specification does not document this timing behaviour, so implementations that follow the spec naively will hang silently.
On X11
Falls back to xdotool automatically. No portal, no permission dialog, works out of the box.
API
createInputInjector(options?): Promise<WaylandInput>
Creates an input injector. Automatically detects Wayland or X11. No permission dialog appears at this point.
Options:
{
portalTimeoutMs?: number // default: 120000 (2 minutes)
}portalTimeoutMs controls how long to wait for the user to click Allow in the Wayland permission dialog before timing out. Set it lower (e.g. 30000) to fail fast if the user doesn't respond.
input.preinit(): void
Starts the Wayland portal session in the background. The permission dialog appears at this point. Call this when your sharing session begins — not at app startup — so the dialog appears at the right moment for the user.
On X11 this is a no-op.
input.inject(event: InputEvent): Promise<void>
Injects an input event. The InputEvent type:
type InputEvent = {
type: 'mousemove' | 'click' | 'mousedown' | 'mouseup' | 'keydown' | 'keyup' | 'scroll'
// Mouse position (absolute)
x?: number
y?: number
// Mouse movement (relative)
dx?: number
dy?: number
// Mouse button (1=left, 2=middle, 3=right)
button?: number
// Keyboard key (browser KeyboardEvent.key format e.g. 'Enter', 'a', 'ArrowUp')
key?: string
// Scroll amount
deltaX?: number
deltaY?: number
// Source video dimensions (for coordinate scaling in remote desktop scenarios)
videoWidth?: number
videoHeight?: number
}input.close(): Promise<void>
Closes the portal session and releases all resources. Always call this when done — GNOME blocks new sessions if the previous one was not explicitly closed.
Supported keys
All printable characters plus:
Backspace Tab Enter Escape Delete Home End
ArrowLeft ArrowRight ArrowUp ArrowDown
PageUp PageDown Insert
Shift Control Alt Meta CapsLock
F1 – F12
Deep dive
For a full technical explanation of how the portal protocol works, the race condition, and the two-phase listener pattern — see TECHNICAL.md.
License
MIT © ABDOURAHMAN MOHAMED
