How "let this participant drive my desktop" actually works, end to end.
The OS-level injection layer lives in a standalone package,
@profullstack/remote-input — see its
README for backends, per-OS requirements, and the safety guarantees. This
document covers how PairUX drives it.
Only the desktop app can be controlled. A web host shares a browser tab and has no OS to inject into, so web-hosted sessions leave control off (
apps/web/src/app/host/page.tsx) rather than showing viewers a button that could never work.
Control is never implicit. Two independent things must be true:
- The session allows requests —
session.settings.allowControl. Set at session creation from the desktop's Settings toggle (getDefaultAllowGuestControl()inapps/desktop/src/renderer/lib/sessionDefaults.ts). When false, the viewer never renders a "Request control" button, and the host hooks drop any control-request or input message that arrives anyway. - The host approved that participant — the participant's
control_statemust begrantedin the database. The host-side injector is enabled from that row, so approval is what actually opens the input path.
Gate 1 is a preference. Gate 2 is the security boundary: nothing is injected until a human clicks Allow control.
sequenceDiagram
participant V as Viewer (web)
participant T as Transport (SFU or P2P)
participant H as Desktop host UI
participant API as Control API
participant OS as OS input
V->>T: control-request
T->>H: onControlRequest(viewerId)
H->>H: Show Allow / Deny prompt (30s expiry)
H->>API: PATCH control_state = granted
API->>V: control-grant (LiveKit signal, SFU)
H->>H: participantWithControl set -> injector enabled
V->>T: input events (mouse / keyboard)
T->>H: onInputReceived
H->>OS: injectEvent -> validated, rate-limited, injected
Note over H,OS: Ctrl+Shift+Esc revokes instantly at any point
| Concern | Location |
|---|---|
| Viewer input capture | apps/web/src/hooks/useRemoteControl.ts |
| Viewer control UI | apps/web/src/components/control/ |
| Viewer transport (SFU / P2P) | apps/web/src/hooks/useWebRTCSFU.ts, useWebRTC.ts |
| Host transport + gating | apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.ts, useWebRTCHostAPI.ts |
| Host prompt, grant/revoke | apps/desktop/src/renderer/components/capture/CapturePreview.tsx |
| Host injection hook | apps/desktop/src/renderer/hooks/useInputInjection.ts |
| Main-process facade | apps/desktop/src/main/input/injector.ts |
| IPC + emergency hotkey | apps/desktop/src/main/ipc/input.ts |
| OS injection + safety | packages/remote-input/ |
| Control state persistence | apps/web/src/app/api/sessions/[sessionId]/participants/[participantId]/control/route.ts |
A session is either sfu (LiveKit, the default) or p2p. Control works on
both, over the same message shapes:
- SFU — messages ride LiveKit data packets (
publishData/RoomEvent.DataReceived). Grants are additionally pushed server-side from the control API viaRoomServiceClient.sendData, so a grant lands even if the host's own data channel is momentarily unhealthy. - P2P — messages ride an
RTCDataChannelnamedcontrol.
Both host hooks expose the same surface (onControlRequest, onInputReceived,
grantControl, revokeControl), and CapturePreview picks one on
session.mode, so the control code above the transport is identical.
// packages/shared-types/src/signaling.ts and input.ts
type ControlMessage =
| { type: 'control-request'; participantId: string; timestamp: number }
| { type: 'control-grant'; participantId: string; timestamp: number }
| { type: 'control-revoke'; participantId: string; timestamp: number };
interface InputMessage {
type: 'input';
timestamp: number;
sequence: number; // ordering
event: InputEvent; // mouse or keyboard, coordinates normalized 0-1
}Cursor positions travel as a separate cursor message, sent lossily (~60fps)
because a dropped cursor frame is irrelevant a frame later, while a dropped
click is not.
Guests are anonymous, so a viewer cannot write its own control_state — the
control API requires an authenticated user. Requests therefore arrive over the
data channel and are held in host-local state until answered:
- The host sees an Allow control / Deny prompt naming the participant.
- Unanswered requests expire after 30s (
CONTROL_REQUEST_TIMEOUT_MS). - Allow resolves the viewer id back to its participant row and PATCHes
control_state = granted; the injector enables off that row. - Deny sends
control-revokeso the viewer leaves its "requested" state. - A viewer disconnecting clears any pending request.
Only one participant holds control at a time: granting to a second viewer revokes the first.
Both people keep a working cursor at the same time. Nobody hands control over, and the host's pointer is never taken away.
Every OS we support has exactly one system pointer, and none lets an ordinary process create a second (X11's XInput2 MPX aside, which exists on neither Wayland nor macOS). So the second cursor comes from not spending the real one on movement:
- remote movement only advances a tracked position, drawn by the host as the
participant's cursor (
RemoteCursorsContainer, fed byonCursorUpdate) - the real pointer is borrowed for the instant a remote click or scroll has to land, then returned to where its owner left it
- during a drag it stays with the remote user until they release, since restoring mid-drag would tear the drag apart
This lives in @profullstack/remote-input (virtualCursor, on by default), so
every host gets it without app-side logic.
Restoring the pointer means reading where it is, which is platform-dependent:
| Host | Restores the local pointer |
|---|---|
| macOS | Yes |
| Windows | Yes |
| Linux / X11 | Yes |
| Linux / Wayland (KDE) | Yes, via the KWin helper below |
| Linux / Wayland (other) | No — the click leaves the pointer where it landed |
Wayland refuses to tell a client where the pointer is, so on KDE the
compositor is asked instead. A KWin script can only talk outward over DBus,
and the bus rejects calls to a name nobody owns, so PairUX claims
org.profullstack.RemoteInput, exposes SetCursorPos, and installs plus loads
a script that pushes workspace.cursorPos to it. Distance-throttled, because
the signal fires on every motion event.
Automatic — nothing for the user to install beyond gdbus
(libglib2.0-bin), which desktops already have. Readings older than two
seconds are discarded rather than used, and any failure falls back to leaving
the pointer where the click landed. The script is unloaded on quit so it cannot
outlive the app pushing at a dead name.
Look for one of these in the host's terminal:
[RemoteInput] KWin cursor reporting active
[RemoteInput] Cursor reporting off: KWin would not load the helper (<reason>)
GNOME's equivalent (global.get_pointer() via a Shell extension) is not
implemented yet.
Mouse coordinates are normalized 0-1 relative to the shared surface, never
pixels — the viewer does not know the host's resolution, DPI, or monitor layout.
The host maps them using the captured video track's dimensions
(inputScreenSize), pushed down via input:updateScreenSize whenever the
capture source changes.
| Mechanism | Effect |
|---|---|
Ctrl+Shift+Esc |
Global hotkey. Releases every held key/button and disables injection immediately, regardless of UI state. |
| Host revokes | PATCHes control_state = view-only; injector disables on the next session refresh. |
| Viewer releases | Sends control-revoke. |
| Viewer disconnects | Host clears that viewer's control state. |
Anything the remote user is holding is always released — on revoke, on disconnect, when they alt-tab or leave the video, and by a five-second watchdog if input simply stops mid-hold. A button left down would otherwise put the host's desktop into a permanent drag that swallows every click, recoverable only by rebooting.
The emergency hotkey is registered for the app's lifetime
(apps/desktop/src/main/ipc/input.ts) so it works even if the renderer is
wedged. Host input is never blocked while a viewer has control — the host can
always take over by simply using their own mouse and keyboard.
Full requirements are in the package README. In short:
- macOS — needs Accessibility permission; the app prompts via
systemPreferences.isTrustedAccessibilityClient(true)(apps/desktop/src/main/permissions/index.ts). - Windows — works out of the box; only elevated target windows need the app elevated too.
- Linux/X11 — works out of the box.
- Linux/Wayland — needs
ydotoolplus a runningydotooldwith/dev/uinputaccess. The backend attempts a systemd auto-start and, if control still is not possible,CapturePreviewshows an actionable banner built fromgetInjectionDiagnostics()instead of failing silently.
Native modules are unpacked from the asar (asarUnpack: '**/*.node' in
electron-builder.yml), which packaged builds require.
packages/remote-input— safety layer, injector gating, backend selection, and both Wayland backends.apps/desktop/src/main/input/injector.test.ts— the full injection path against a mocked nut.js.apps/desktop/src/renderer/hooks/useWebRTCHostSFUAPI.test.ts—allowControlenforcement (requests and input dropped when the session disallows control).apps/desktop/src/renderer/lib/sessionDefaults.test.ts— the session-creation default that decides whether the viewer ever sees a request button.