I was building Parallel — an Electron app for local network screen sharing on Linux. No server, no account, just WebRTC and mDNS between two machines on the same WiFi.
Everything worked until I tried to add remote input control on Wayland.
The problem with Wayland
The X11 display protocol allowed apps to inject input directly. Wayland was designed to prevent this — no app can touch the input system without going through the XDG Desktop Portal RemoteDesktop interface, which requires the user to explicitly grant permission.
So I implemented the portal workflow. Three calls: CreateSession, SelectDevices, Start. After the user clicks Allow on the dialog, you can inject input freely.
The code looked correct. But on GNOME it would silently hang.
The race condition
Each portal call responds asynchronously via a D-Bus signal on a specific path. The naive pattern is:
make the call → subscribe to the response → wait
The problem: CreateSession requires no user interaction, so GNOME approves it almost instantly. The Response signal fires before your subscriber has time to register. It's already gone. Your code waits forever.
The fix
The response path is deterministic:
/org/freedesktop/portal/desktop/request/{sender}/{token}
Where sender is your D-Bus connection name and token is a string you generate yourself. Since you control both, you can calculate the exact path before making the call:
const token = makeToken('prefix')
const path = predictHandlePath(bus, token)
// Subscribe FIRST
const response = waitForResponse(bus, path)
// Then make the call
await remoteDesktop.CreateSession({ handle_token: new Variant('s', token) })
// Now safely await
await response
This applies to every call in the sequence — CreateSession, SelectDevices, SelectSources, and Start.
The package
Once it worked I extracted it into wayland-input so nobody else has to go through this.
npm install wayland-input
(https://www.npmjs.com/package/wayland-input)
import { createInputInjector } from 'wayland-input'
const input = await createInputInjector({ portalTimeoutMs: 30000 })
// Call when sharing starts — this is when the permission dialog appears
input.preinit()
await input.inject({ type: 'mousemove', x: 500, y: 300 })
await input.inject({ type: 'keydown', key: 'Enter' })
await input.close()
Handles the full portal session lifecycle, coordinate scaling for remote desktop scenarios, configurable timeouts, and automatic xdotool fallback on X11.

Top comments (0)