Cross-platform OS input injection for remote control. One normalized event shape in, real cursor movement and keystrokes out — on macOS, Windows, Linux/X11 and Linux/Wayland.
Extracted from PairUX, where it drives "let this participant control my screen" in a WebRTC screen-sharing session.
Every remote-control feature needs the same unglamorous layer: figure out which OS input API is even available, convert viewport-relative coordinates into absolute pixels, map browser key codes onto OS key codes, and make sure a remote peer cannot lock your machine or flood your event loop. Wayland makes this sharply worse — it refuses synthetic input from ordinary clients, so the X11 approach simply does not work there.
This package is that layer, with no Electron or GUI-toolkit dependency, so it runs in a desktop app, a CLI agent, or a headless daemon.
pnpm add @profullstack/remote-input
# For macOS / Windows / Linux-X11 injection, also install the optional peer:
pnpm add @nut-tree-fork/nut-js@nut-tree-fork/nut-js is an optional peer dependency: it ships native binaries,
so it is not required to install this package or to use the Wayland backends.
import { RemoteInputInjector } from '@profullstack/remote-input';
const injector = new RemoteInputInjector({
onRejected: (reason, event, detail) => console.warn('refused', reason, detail),
});
await injector.init();
// Nothing is injected until you explicitly enable it. `false` means this host
// cannot inject at all — show the reason instead of pretending control works.
if (!injector.enable()) {
console.error(injector.getDiagnostics().reason);
}
// Tell it the size of the surface the viewer is looking at, so normalized
// coordinates land on the right pixel.
injector.updateScreenSize(2560, 1440);
await injector.inject({ type: 'mouse', action: 'move', x: 0.5, y: 0.5 });
await injector.inject({
type: 'keyboard',
action: 'press',
key: 'a',
code: 'KeyA',
modifiers: { ctrl: false, alt: false, shift: false, meta: false },
});
// On disconnect or a panic hotkey: stop accepting input and release anything
// the remote peer was still holding down.
await injector.emergencyStop();Mouse coordinates are normalized 0-1 relative to the shared surface, not
pixels. The viewer never needs to know the host's resolution, DPI, or monitor
layout — call updateScreenSize() on the host and the injector maps them.
Every desktop OS we support has exactly one system pointer, and none of them lets an ordinary process create a second one (X11's XInput2 MPX aside, which does not exist on Wayland or macOS). Injecting remote movement into that single pointer is what makes remote control feel like the local user's mouse has been stolen.
So by default (virtualCursor: true) remote movement never touches the local
pointer at all — it only advances a tracked position, which the host renders as
the remote participant's cursor. The real pointer is borrowed for the instant a
remote click or scroll has to land somewhere, then handed straight back to
where its owner left it. Both people keep a usable cursor at the same time.
injector.getRemoteCursorPosition(); // { x, y } normalized — draw thisDuring a drag the pointer necessarily stays with the remote user until they release, otherwise the drag would tear.
Restoration needs to read where the local pointer is. X11 and macOS answer directly. Wayland refuses — no protocol tells a client where the pointer is — so there the compositor is asked instead.
KWinCursorProvider closes the gap on KWin. Since a KWin script can only talk
outward over DBus, and the bus rejects calls to a name nobody owns, the
provider claims org.profullstack.RemoteInput, exposes a SetCursorPos
method, then installs and loads a small script that pushes workspace.cursorPos
to it — distance-throttled, since the signal fires on every motion event. This
happens automatically; the user installs nothing.
Enabled automatically on a KDE session running Wayland — the only environment
it targets. PAIRUX_WAYLAND_CURSOR_RESTORE=0 forces it off if a compositor
misbehaves; =1 forces it on for a KDE session that does not advertise itself
in XDG_CURRENT_DESKTOP.
Requires gdbus (libglib2.0-bin, present on essentially every desktop).
Readings older than two seconds are discarded rather than used, so a
half-working helper can never fling the pointer somewhere its owner never left
it. If any part fails, getCursorPosition() returns null and behaviour falls
back to leaving the pointer where the click landed.
GNOME's equivalent (global.get_pointer() via a Shell extension) is not
implemented yet.
Pass virtualCursor: false for the old behaviour where remote input drives the
system cursor directly.
| Platform | Backend | Two cursors | Requirements |
|---|---|---|---|
| macOS | nut-js |
Full — local pointer restored | Accessibility permission (see below) |
| Windows | nut-js |
Full — local pointer restored | None. Admin only to drive elevated windows. |
| Linux / X11 | nut-js |
Full — local pointer restored | None |
| Linux / Wayland (KDE) | wayland-ydotool |
Full via KWinCursorProvider — falls back to leaving the pointer where the click landed |
ydotool + running ydotoold with /dev/uinput; gdbus for cursor reporting |
| Linux / Wayland (other) | wayland-ydotool |
Partial — movement never hijacked, but a click leaves the pointer where it landed | ydotool + a running ydotoold with /dev/uinput |
| Linux / Wayland | wayland-portal |
n/a | Diagnostic only — reports why control is unavailable |
This package injects into a real OS, so it runs only where one exists. A browser cannot be the controlled machine; a browser-based client can only ever be the side doing the controlling.
Backend selection is automatic. On Wayland the package probes ydotool first
(and will try to auto-start ydotoold via systemd), then falls back to a
backend whose only job is to explain, in reason, why control cannot work —
rather than failing silently.
import { detectDisplayServer, getInputBackendSelection } from '@profullstack/remote-input';
detectDisplayServer(); // 'macos' | 'windows' | 'x11' | 'wayland' | 'unknown'
getInputBackendSelection(); // { kind, platform, displayServer }macOS gates synthetic input behind Accessibility (TCC) and will not prompt on
your behalf from a background process. Check
requiresAccessibilityPermission() and send the user to
System Settings → Privacy & Security → Accessibility. In Electron,
systemPreferences.isTrustedAccessibilityClient(true) triggers the prompt.
sudo apt install ydotool # or your distro's package
sudo systemctl enable --now ydotooldydotoold needs access to /dev/uinput. If the daemon starts and immediately
dies, that is almost always the cause; the backend surfaces this in
getDiagnostics().reason.
Remote input is untrusted even after the host has approved the peer, so every event passes three guards before it reaches the OS:
-
Validation — coordinates must be finite and within
0-1; scroll deltas must be finite; keys must be non-empty and of plausible length. -
Rate limiting — a sliding one-second window (default 1000 events/sec). Each injection is a synchronous OS call, so an unbounded stream is a cheap way to wedge the host.
-
Blocked combinations — refused even while control is granted, because they either hand over a privileged surface or end the session in a way the remote peer cannot undo:
Combination Why Ctrl+Alt+DeleteSecure attention / task manager Meta+LLocks the screen Meta+Alt+EscapeForce-quit picker Ctrl+Shift+Meta+QLogs the host out
Rejections are counted in getDiagnostics().stats and reported through
onRejected, so a host can surface them rather than lose them.
Injection is off by default and enable() returns false when the backend
cannot drive the machine — a host should never believe it granted control that
silently does nothing.
| Export | Purpose |
|---|---|
RemoteInputInjector |
The gated, validated, rate-limited injection path |
getInputBackendSelection |
Which backend applies to this host |
createInputBackend |
Construct a backend directly |
selectInputBackend |
Pure platform → backend-kind mapping |
detectPlatform / detectDisplayServer |
Environment probes |
requiresAccessibilityPermission |
True on macOS |
validateInputEvent |
Single-event validation |
isDangerousCombination |
Blocked-combination check |
InputRateLimiter |
Sliding-window limiter |
BLOCKED_COMBINATIONS |
The blocklist, for display in a UI |
Backends (NutJsInputBackend, WaylandYdotoolInputBackend,
WaylandPortalInputBackend, UnsupportedWaylandInputBackend) are exported for
hosts that want to select or wrap one themselves. Any object satisfying the
InputBackend interface can be supplied via createBackend, which is also the
seam for testing without touching a real desktop.
MIT