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.
PairUX remote control drives the host's real system cursor directly. The host and guest can take turns naturally: when the guest stops moving, the host can move and click the same cursor anywhere in the system. No cursor is borrowed, restored, or rendered separately.
On KDE/Wayland, PairUX requests a short-lived XDG RemoteDesktop portal session for that same pointer. KDE shows the host an approval dialog when control is granted. PairUX closes the session on every revoke, so a previous guest cannot keep sending input after a handoff.
| Platform | Backend | Pointer behavior | Requirements |
|---|---|---|---|
| macOS | nut-js |
Drives the shared system pointer directly. | Accessibility permission (see below) |
| Windows | nut-js |
Drives the shared system pointer directly. | None. Admin only to drive elevated windows. |
| Linux / X11 | nut-js |
Drives the shared system pointer directly. | None |
| Linux / Wayland (KDE) | wayland-portal |
Drives the shared system pointer directly. | A running XDG portal and host approval in KDE |
| Linux / Wayland (other) | wayland-portal |
Drives the shared system pointer directly. | A RemoteDesktop-capable XDG portal and host approval |
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 uses the compositor's
XDG RemoteDesktop portal. It does not automatically fall back to /dev/uinput:
an input daemon acknowledging a command cannot prove the compositor honored
it, which is unsafe for a host's real pointer. A legacy ydotool backend is
available only with PAIRUX_WAYLAND_INPUT_BACKEND=ydotool for administrator
diagnosis of older desktops.
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.
Install and run your compositor's XDG desktop portal implementation. On KDE
this is xdg-desktop-portal-kde. When a guest receives control, approve KDE's
Remote Desktop request on the host. If the portal is unavailable or approval
is denied, PairUX leaves remote input disabled and exposes the reason through
getDiagnostics().
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