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:
+12
-1
@@ -884,7 +884,7 @@
|
||||
<body>
|
||||
<script src="/icons.js?v=5"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
|
||||
<script>window.__BUILD='2026-07-10-batch69';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
<script>window.__BUILD='2026-07-10-batch70';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
|
||||
// (emojis stay as plain Unicode). (#5)
|
||||
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
|
||||
@@ -3757,6 +3757,17 @@ async function doRegister(){
|
||||
}catch(e){ showErr('rg_err', e.message); }
|
||||
}
|
||||
|
||||
// Relay remote-control messages from the embedded /share iframe to the native desktop shell for OS
|
||||
// injection. The iframe (share.html) can't reach the native bridge directly (it lives on this top
|
||||
// frame), so it postMessages here: we answer the availability handshake and forward arm/input events
|
||||
// only when running in the desktop app. Injection is still gated by the shell's consent flag.
|
||||
window.addEventListener('message',(e)=>{
|
||||
if(e.origin!==location.origin) return; const d=e.data||{}; const n=window.bizConnectNative;
|
||||
if(d.type==='rc-ping'){ const desktop=!!(n&&n.rcAvailable&&n.rcAvailable()); try{ e.source&&e.source.postMessage({type:'rc-pong', desktop}, location.origin); }catch(_){} return; }
|
||||
if(!n) return;
|
||||
if(d.type==='rc-arm'){ try{ n.rcArm&&n.rcArm(!!d.on); }catch(_){} return; }
|
||||
if(d.type==='rc-input'){ try{ n.rcInput&&n.rcInput(d.evt); }catch(_){} return; }
|
||||
});
|
||||
// ---------- Boot: show the app if signed in, otherwise the login ----------
|
||||
(async function(){
|
||||
let me=null;
|
||||
|
||||
@@ -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 & 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>';
|
||||
|
||||
Reference in New Issue
Block a user