feat(remote-control): viewer can control a desktop sharer's screen, with consent (0.1.13/batch70)

Fixes the core "viewer can't control the sharer even on desktop" gap. Three root
causes addressed:
- share.html DISCARDED every input-channel message (onmessage=()=>{}). It now
  parses the viewer's mouse/keyboard events and forwards them to the desktop shell.
- The main desktop app had NO OS injector (it lived only in the separate agent).
  Ported the nut-js injector (agent/input/inject.js) into desktop/input, wired an
  inject IPC + injectInput bridge, HARD-gated behind a consent flag (rcArmed).
- /share runs in an iframe (no direct bridge access) → it postMessages input to
  the top frame (home.html), which relays to the native bridge.

Consent + safety: the sharer sees an Allow/Deny prompt the first time the agent
interacts; while active a persistent "your screen is being controlled — Stop"
banner; instant revoke; auto-release on session end/teardown. Browser sharers stay
view-only (no OS injection possible). nut-js is an optionalDependency (N-API, ABI-
stable across Electron) — degrades to no-op if the native module is unavailable.
Windows-first; maps to the primary display.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 16:30:39 +05:30
parent 7bc40d8397
commit e562099344
7 changed files with 1654 additions and 62 deletions
+16
View File
@@ -83,6 +83,22 @@ function avatarToTempPng(src) {
});
}
// ---- Remote control: OS input injection for a screen the local user is SHARING ----
// The renderer (share flow) forwards a viewer's mouse/keyboard events here for injection. Injection is
// HARD-GATED behind an explicit consent flag (rcArmed): nothing is injected until the local user clicks
// "Allow control", and it stops the instant they revoke or the session ends. nut-js is optional — if the
// native module isn't present it degrades to a no-op (no crash), so control simply won't take effect.
let injector = null;
try { injector = require('./input/inject'); } catch (_) { injector = null; }
let rcArmed = false;
// The renderer arms/disarms control (mirrors the on-screen consent banner). Disarming releases any
// stuck keys immediately.
ipcMain.on('rc-arm', (_e, on) => { rcArmed = !!on; if (!rcArmed && injector && injector.releaseAll) { try { injector.releaseAll(); } catch (_) {} } });
ipcMain.on('rc-input', (_e, evt) => { if (rcArmed && injector && injector.inject && evt) { try { injector.inject(evt); } catch (_) {} } });
// Whether OS injection is even possible on this machine (native module loaded). The renderer uses this
// to show "control needs the desktop app" vs an actual Allow prompt.
ipcMain.on('rc-available', (e) => { e.returnValue = !!(injector && injector.available); });
// Pre-warm the DP cache for the renderer's contacts (called after chats load), so the FIRST
// notification from anyone already has their photo — no per-toast download wait.
ipcMain.handle('precache-avatars', async (_e, urls = []) => {