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
+48 -2
View File
@@ -58,6 +58,19 @@
.profile .pmenu a{display:block;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer}
.profile .pmenu a:hover{background:#f1f5f9}
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
/* Remote-control consent prompt + "being controlled" banner (sharer side) */
.rc-consent{position:fixed;inset:0;background:rgba(15,23,42,.5);display:flex;align-items:center;justify-content:center;z-index:100000;padding:1rem;}
.rc-consent .rc-card{background:#fff;border-radius:14px;padding:1.4rem 1.5rem;max-width:380px;width:100%;box-shadow:0 18px 44px rgba(0,0,0,.3);text-align:center;font-family:'Segoe UI',system-ui,sans-serif;}
.rc-consent .rc-h{font-size:1.05rem;font-weight:700;color:#1F3B73;margin-bottom:.4rem;}
.rc-consent p{font-size:.9rem;color:#475569;line-height:1.5;margin:0 0 1.1rem;}
.rc-consent .rc-btns{display:flex;gap:.6rem;}
.rc-consent button{flex:1;padding:.7rem;border-radius:9px;font-size:.92rem;font-weight:600;cursor:pointer;border:1px solid #e3e8f2;}
.rc-consent .rc-deny{background:#fff;color:#334155;}
.rc-consent .rc-allow{background:#1F3B73;color:#fff;border-color:#1F3B73;}
.rc-banner{position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:100000;display:flex;align-items:center;gap:.6rem;background:#0f172a;color:#fff;border-radius:999px;padding:.5rem .6rem .5rem .9rem;font-family:'Segoe UI',system-ui,sans-serif;font-size:.85rem;box-shadow:0 6px 20px rgba(0,0,0,.35);}
.rc-banner .rc-dot{width:9px;height:9px;border-radius:50%;background:#22c55e;box-shadow:0 0 0 0 rgba(34,197,94,.6);animation:rcpulse 1.6s infinite;}
@keyframes rcpulse{0%{box-shadow:0 0 0 0 rgba(34,197,94,.6)}70%{box-shadow:0 0 0 8px rgba(34,197,94,0)}100%{box-shadow:0 0 0 0 rgba(34,197,94,0)}}
.rc-banner button{background:#ef4444;color:#fff;border:none;border-radius:999px;padding:.35rem .8rem;font-size:.8rem;font-weight:700;cursor:pointer;}
</style>
<script src="/icons.js?v=3"></script>
</head>
@@ -180,7 +193,7 @@ async function startStreaming(){
pc=new RTCPeerConnection(ICE);
buildBar();
localStream.getTracks().forEach(t=>pc.addTrack(t,localStream));
pc.ondatachannel=(ev)=>{ev.channel.onmessage=()=>{};};
pc.ondatachannel=(ev)=>{ const ch=ev.channel; if(ch&&ch.label==='input'){ ch.onmessage=(e)=>rcOnInput(e.data); } else if(ch){ ch.onmessage=()=>{}; } };
pc.ontrack=(ev)=>{ if(ev.track.kind==='audio'){ let a=document.getElementById('remoteAudio'); if(!a){a=document.createElement('audio');a.id='remoteAudio';a.autoplay=true;document.body.appendChild(a);} a.srcObject=ev.streams[0]; } };
pc.onicecandidate=(ev)=>{if(ev.candidate)ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate}));};
pc.onconnectionstatechange=()=>{ if(!pc) return; if(pc.connectionState==='connected'){ clearTimeout(window.__connWatch); } if(pc.connectionState==='failed'){ clearTimeout(window.__connWatch); try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} endShareSession("Couldn't connect on this network — it may be blocking screen sharing. Try a different network (e.g. mobile data / hotspot), then tap below for a new code."); } };
@@ -207,6 +220,38 @@ function startCustTranscription(){
}catch(e){}
}
function stopCustTranscription(){ crecogActive=false; if(crecog){ try{crecog.stop();}catch(_){} crecog=null; } }
// ---- Remote control (SHARER side) ----
// The agent (connect.html) streams mouse/keyboard events over the 'input' data channel. We forward them
// to the Biz Connect DESKTOP shell (via the parent frame's native bridge) for OS injection — but ONLY
// after the user explicitly taps "Allow control", and only inside the desktop app. In a plain browser
// there's no OS injection possible, so it stays view-only. The user can stop control at any time.
let rcDesktop=false, rcAllowed=false, rcPrompted=false;
try{ window.addEventListener('message',(e)=>{ if(e.origin!==location.origin) return; const d=e.data||{}; if(d.type==='rc-pong') rcDesktop=!!d.desktop; }); }catch(_){}
try{ if(window.parent && window.parent!==window) window.parent.postMessage({type:'rc-ping'}, location.origin); }catch(_){}
function rcPost(msg){ try{ if(window.parent && window.parent!==window) window.parent.postMessage(msg, location.origin); }catch(_){} }
function rcOnInput(data){
let evt; try{ evt=JSON.parse(data); }catch(_){ return; }
if(rcAllowed){ rcPost({type:'rc-input', evt}); return; }
if(!rcDesktop) return; // browser sharer: cannot inject OS input — view-only
// Ask for consent the first time the agent actually interacts (a click/keypress, not mere cursor moves).
if(!rcPrompted && (evt.kind==='mousedown'||evt.kind==='keydown'||evt.kind==='dblclick')){ rcPrompted=true; showControlConsent(); }
}
function showControlConsent(){
if(document.getElementById('rcConsent')) return;
const el=document.createElement('div'); el.id='rcConsent'; el.className='rc-consent';
el.innerHTML='<div class="rc-card"><div class="rc-h">Allow remote control?</div><p>Your agent is asking to control your mouse &amp; keyboard to help you. You stay in charge — stop anytime.</p><div class="rc-btns"><button id="rcDeny" class="rc-deny">Not now</button><button id="rcAllow" class="rc-allow">Allow control</button></div></div>';
document.body.appendChild(el);
el.querySelector('#rcAllow').onclick=()=>{ rcAllowed=true; rcPost({type:'rc-arm', on:true}); el.remove(); showControlBanner(); };
el.querySelector('#rcDeny').onclick=()=>{ el.remove(); };
}
function showControlBanner(){
if(document.getElementById('rcBanner')) return;
const el=document.createElement('div'); el.id='rcBanner'; el.className='rc-banner';
el.innerHTML='<span class="rc-dot"></span><span>Your agent is controlling your screen</span><button id="rcStop">Stop control</button>';
document.body.appendChild(el);
el.querySelector('#rcStop').onclick=rcStopControl;
}
function rcStopControl(){ rcAllowed=false; rcPrompted=false; rcPost({type:'rc-arm', on:false}); const b=document.getElementById('rcBanner'); if(b) b.remove(); }
let recTimerInt=null, recStartTs=0;
function fmtElapsed(ms){const s=Math.max(0,Math.floor(ms/1000));return String(Math.floor(s/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');}
function recNotice(on){
@@ -225,6 +270,7 @@ function recNotice(on){
} else { clearInterval(recTimerInt); recTimerInt=null; if(n) n.remove(); }
}
function endShareSession(msgText){
try{ rcStopControl(); }catch(_){} // release remote control when the session ends
sessionOver=true; window.onbeforeunload=null; bzcSession(false); { const hl=document.getElementById('homeLink'); if(hl) hl.style.display=''; } try{recNotice(false);stopCustTranscription();}catch(_){}
removeSessionUI();
indicator.classList.remove('show');
@@ -234,7 +280,7 @@ function endShareSession(msgText){
var card=document.querySelector('.panelside .card');
if(card){ card.innerHTML='<h1 style="color:var(--blue)">Session ended</h1><div class="sub">'+esc(msgText||'The session has ended.')+'</div><button onclick="location.reload()" style="width:100%;margin-top:.4rem">Get a new code</button>'; }
}
function teardown(){sessionOver=true;window.onbeforeunload=null;bzcSession(false);{const hl=document.getElementById('homeLink');if(hl)hl.style.display='';}try{recNotice(false);stopCustTranscription();}catch(_){}indicator.classList.remove('show');removeSessionUI();if(window.__mic){window.__mic.getTracks().forEach(t=>t.stop());window.__mic=null;}if(localStream){localStream.getTracks().forEach(t=>t.stop());localStream=null;}if(pc){pc.close();pc=null;}consentBox.innerHTML='';setStatus('Session ended. Refresh this page to get a new code.');}
function teardown(){try{rcStopControl();}catch(_){}sessionOver=true;window.onbeforeunload=null;bzcSession(false);{const hl=document.getElementById('homeLink');if(hl)hl.style.display='';}try{recNotice(false);stopCustTranscription();}catch(_){}indicator.classList.remove('show');removeSessionUI();if(window.__mic){window.__mic.getTracks().forEach(t=>t.stop());window.__mic=null;}if(localStream){localStream.getTracks().forEach(t=>t.stop());localStream=null;}if(pc){pc.close();pc=null;}consentBox.innerHTML='';setStatus('Session ended. Refresh this page to get a new code.');}
let chatOpen=false;
const SVG_MIC='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';