feat(desktop): native OS input injection for remote control (LC-185, Windows first) #229

Merged
nrupard merged 2 commits from feat/lc-185-native-input-injection into main 2026-05-27 16:01:21 +02:00
Owner

Controlled-side core of the TeamViewer-style remote-control story (LC-181): inject the input stream that arrives on the WebRTC data channel (LC-184) into the host OS. Windows first via SendInput; other platforms are #[cfg(...)] no-ops until their slice lands. The controller side (LC-184, #228) and the server consent gate (LC-183, #227) already shipped.

What lands

  • Injector (desktop/src/inject.rs). Managed ControlState = an active flag + held-keys/buttons sets. rc_session(active) arms/disarms on the LC-184 lifecycle; disarming force-releases every held key/button (stuck-key prevention on clean revoke, teardown, or channel drop). rc_input(frame) decodes the compact wire frame and synthesizes input, but only while active - the native re-assertion of the grant, so a peer flooding the channel with no live grant injects nothing.
  • Windows synthesis. Mouse: [0,1] over the virtual desktop via MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK (DPI/resolution-independent, no GetSystemMetrics). Keyboard: scan-code (KeyboardEvent.code -> PS/2 set-1, KEYEVENTF_EXTENDEDKEY for nav/right-modifier keys). Modifier state is not consulted - the controller sends discrete down/up for the modifier keys themselves.
  • Bridge (main.rs). A Tauri initialization_script forwards the lc:control-start / lc:control-end / lc:control-input DOM events (LC-184) to the two commands via window.__TAURI_INTERNALS__.invoke, so withGlobalTauri stays off (no broad Tauri API exposed to the page).
  • Remote-IPC capability. App commands are unreachable from a remote (non-tauri://) origin unless a capability allows them; the server URL is user-configurable, so a runtime CapabilityBuilder is added at setup scoped to exactly the configured origin (.local(false).remote("scheme://host[:port]/*")) granting only allow-rc-session / allow-rc-input.
  • ACL plumbing. build.rs declares the app commands (AppManifest::commands) so tauri-build generates the allow-* permissions the runtime capability references. Declaring an app manifest also makes app commands ACL-checked for local pages, so set_server_url is declared and granted allow-set-server-url in capabilities/default.json. Autogenerated permission files are gitignored (regenerated every build, like desktop/gen/).

Security posture

The bridge is reachable only from the configured server origin, and every event is gated on active, which is flipped on only by the local user's explicit Grant click (lc:control-start). The data channel alone is never trusted. This is no weaker than the existing LETS_CHAT_SERVER_URL trust (a compromised server page can already drive the IPC bridge); it is not a defense against a malicious server, which is documented.

Documented limits (not defeated)

  • UIPI: SendInput cannot drive a higher-integrity (elevated/admin) window; those events are silently dropped by Windows.
  • Single shared surface: [0,1] maps over the whole virtual desktop, matching a primary / full-screen share. Per-monitor surface selection is out of scope.
  • Wheel granularity: web wheel delta is treated as ~100px per notch; scroll speed is approximate.

Out of scope (later)

  • LC-186: sharer kill-switch (button + global hotkey), server-side request rate-limit, audit log, max session duration.
  • Linux (uinput/XTEST) and macOS (CGEvent + Accessibility grant) injection backends.

Verification

  • Linux desktop cargo check + clippy + fmt clean (exercises ACL generation, generate_context! accepting allow-set-server-url, the runtime capability, and the non-Windows no-op module).
  • cargo check --target x86_64-pc-windows-gnu clean in the mingw builder image (exercises the #[cfg(windows)] SendInput path).
  • No server code changed; the just test surface is untouched.
Controlled-side core of the TeamViewer-style remote-control story (LC-181): inject the input stream that arrives on the WebRTC data channel (LC-184) into the host OS. **Windows first** via `SendInput`; other platforms are `#[cfg(...)]` no-ops until their slice lands. The controller side (LC-184, #228) and the server consent gate (LC-183, #227) already shipped. ## What lands - **Injector (`desktop/src/inject.rs`).** Managed `ControlState` = an `active` flag + held-keys/buttons sets. `rc_session(active)` arms/disarms on the LC-184 lifecycle; **disarming force-releases every held key/button** (stuck-key prevention on clean revoke, teardown, or channel drop). `rc_input(frame)` decodes the compact wire frame and synthesizes input, **but only while `active`** - the native re-assertion of the grant, so a peer flooding the channel with no live grant injects nothing. - **Windows synthesis.** Mouse: `[0,1]` over the virtual desktop via `MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK` (DPI/resolution-independent, no `GetSystemMetrics`). Keyboard: scan-code (`KeyboardEvent.code -> PS/2 set-1`, `KEYEVENTF_EXTENDEDKEY` for nav/right-modifier keys). Modifier state is not consulted - the controller sends discrete down/up for the modifier keys themselves. - **Bridge (`main.rs`).** A Tauri `initialization_script` forwards the `lc:control-start` / `lc:control-end` / `lc:control-input` DOM events (LC-184) to the two commands via `window.__TAURI_INTERNALS__.invoke`, so `withGlobalTauri` stays off (no broad Tauri API exposed to the page). - **Remote-IPC capability.** App commands are unreachable from a remote (non-`tauri://`) origin unless a capability allows them; the server URL is user-configurable, so a **runtime `CapabilityBuilder`** is added at setup scoped to exactly the configured origin (`.local(false).remote("scheme://host[:port]/*")`) granting only `allow-rc-session` / `allow-rc-input`. - **ACL plumbing.** `build.rs` declares the app commands (`AppManifest::commands`) so tauri-build generates the `allow-*` permissions the runtime capability references. Declaring an app manifest also makes app commands ACL-checked for local pages, so `set_server_url` is declared and granted `allow-set-server-url` in `capabilities/default.json`. Autogenerated permission files are gitignored (regenerated every build, like `desktop/gen/`). ## Security posture The bridge is reachable only from the configured server origin, and every event is gated on `active`, which is flipped on **only by the local user's explicit Grant click** (`lc:control-start`). The data channel alone is never trusted. This is **no weaker than the existing `LETS_CHAT_SERVER_URL` trust** (a compromised server page can already drive the IPC bridge); it is **not** a defense against a malicious server, which is documented. ## Documented limits (not defeated) - **UIPI**: `SendInput` cannot drive a higher-integrity (elevated/admin) window; those events are silently dropped by Windows. - **Single shared surface**: `[0,1]` maps over the whole virtual desktop, matching a primary / full-screen share. Per-monitor surface selection is out of scope. - **Wheel granularity**: web wheel delta is treated as ~100px per notch; scroll speed is approximate. ## Out of scope (later) - LC-186: sharer kill-switch (button + global hotkey), server-side request rate-limit, audit log, max session duration. - Linux (uinput/XTEST) and macOS (`CGEvent` + Accessibility grant) injection backends. ## Verification - Linux desktop `cargo check` + `clippy` + `fmt` clean (exercises ACL generation, `generate_context!` accepting `allow-set-server-url`, the runtime capability, and the non-Windows no-op module). - `cargo check --target x86_64-pc-windows-gnu` clean in the mingw builder image (exercises the `#[cfg(windows)]` `SendInput` path). - No server code changed; the `just test` surface is untouched.
feat(desktop): native OS input injection for remote control (LC-185, Windows first)
All checks were successful
check-secrets / Nosey parker (push) Successful in 5s
check-secrets / TruffleHog (push) Successful in 8s
check-secrets / Kingfisher (push) Successful in 8s
check-secrets / TruffleHog (pull_request) Successful in 5s
check-secrets / Kingfisher (pull_request) Successful in 6s
check-secrets / Nosey parker (pull_request) Successful in 6s
Check / clippy + fmt + tests (pull_request) Successful in 1m53s
c6201a517d
Controlled-side core of the TeamViewer-style remote-control story (LC-181): inject the input stream that arrives on the WebRTC data channel (LC-184) into the host OS. Windows is the first target via SendInput; other platforms are #[cfg(...)] no-ops until their slice lands. The controller side (LC-184) and the server consent gate (LC-183) already shipped.

inject.rs: a managed ControlState (active flag + held-keys/buttons sets) plus two Tauri commands. rc_session(active) arms/disarms on the LC-184 lifecycle events; disarming force-releases every held key/button (stuck-key prevention). rc_input(frame) decodes the compact wire frame and synthesizes input, but only while active - the native re-assertion of the grant, so a peer flooding the channel with no live grant injects nothing. Windows synthesis maps normalized [0,1] over the virtual desktop with MOUSEEVENTF_ABSOLUTE|VIRTUALDESK (DPI/resolution-independent) and uses scan-code keyboard (KeyboardEvent.code -> PS/2 set-1, extended flag for nav/right-modifier keys); modifier state is not consulted because the controller sends discrete down/up for the modifier keys themselves.

main.rs: a Tauri initialization_script bridges the lc:control-start / lc:control-end / lc:control-input DOM events (LC-184) to the two commands via the internal IPC entrypoint, so withGlobalTauri stays off. App commands are unreachable from a remote (non-tauri://) origin unless a capability allows them, and the server URL is user-configurable, so a runtime CapabilityBuilder is added at setup scoped to exactly the configured origin (.local(false).remote("scheme://host[:port]/*")) and granting only allow-rc-session / allow-rc-input. This is no weaker than the existing LETS_CHAT_SERVER_URL trust posture; it is not a defense against a malicious server (documented).

build.rs: declare the app commands via AppManifest::commands so tauri-build generates the allow-* permissions the runtime capability references. Declaring an app manifest also makes app commands ACL-checked for local pages, so set_server_url is declared and granted allow-set-server-url in capabilities/default.json. The autogenerated permission files are gitignored (regenerated every build, like desktop/gen/).

Documented limits (not defeated): Windows UIPI blocks driving elevated/admin windows; single shared surface ([0,1] over the virtual desktop, matching a primary/full-screen share); approximate wheel granularity. Linux uinput/XTEST and macOS CGEvent are later subtasks. Verified: Linux desktop check + clippy + fmt, and a cargo check against x86_64-pc-windows-gnu for the SendInput path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fix(desktop): clear held input when arming a remote-control session
All checks were successful
check-secrets / Nosey parker (push) Successful in 3s
check-secrets / TruffleHog (pull_request) Successful in 4s
check-secrets / Kingfisher (push) Successful in 5s
check-secrets / Kingfisher (pull_request) Successful in 6s
check-secrets / Nosey parker (pull_request) Successful in 5s
check-secrets / TruffleHog (push) Successful in 6s
Create release / Create release from merged PR (pull_request) Has been skipped
Check / clippy + fmt + tests (pull_request) Successful in 4m42s
3ffb3e99c3
rc_session(true) now releases any held keys/buttons before arming, so a session that never received a clean disarm (a missed lc:control-end) cannot carry stale held state into the next session. Defensive hygiene: held entries would otherwise only be flushed on the following disarm.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nrupard deleted branch feat/lc-185-native-input-injection 2026-05-27 16:01:21 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!229
No description provided.