Files
BizGaze_Remote/agent/renderer/agent.js
T
Sravan 7a2ab3fc8d fix(agent): remote-control input now works (data channel + nut-js API)
Two latent bugs that broke input control on any setup:
- Data channel was created by the viewer (the answerer), so the agent's offer had
  no SCTP m-line and the channel never opened -> no input reached the agent. The
  agent (offerer) now creates the 'input' channel; the viewer receives it.
- inject.js used nut.screen.getResolution() which doesn't exist in this nut-js;
  switched to screen.width()/height() with per-session caching.

Verified end-to-end locally: screen streams + mouse injection moves the remote cursor.
Also commits desktop/ + mobile/ package-lock.json from client installs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:47:09 +05:30

136 lines
5.0 KiB
JavaScript

// Agent renderer: connects to the signaling server, handles consent,
// captures the screen, and streams it over WebRTC to the technician's viewer.
// Remote input events arrive on the data channel and are handed to the main
// process (via the preload bridge) for OS-level injection.
const statusEl = document.getElementById('status');
const consentBox = document.getElementById('consentBox');
const indicator = document.getElementById('indicator');
const logEl = document.getElementById('log');
let cfg = null;
let ws = null;
let pc = null;
let localStream = null;
let currentSessionId = null;
const log = (t) => { const d = document.createElement('div'); d.textContent = t; logEl.appendChild(d); logEl.scrollTop = logEl.scrollHeight; };
const setStatus = (t, cls = '') => { statusEl.textContent = t; statusEl.className = 'status ' + cls; };
window.agent.onConfig((c) => {
cfg = c;
if (!cfg.enrollToken) {
setStatus('No enroll token. Set AGENT_ENROLL_TOKEN and restart.', 'warn');
return;
}
connect();
});
function wsUrl() {
const u = new URL(cfg.serverUrl);
const proto = u.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${u.host}/ws`;
}
function connect() {
setStatus('Connecting to server…');
ws = new WebSocket(wsUrl());
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'agent-hello', enrollToken: cfg.enrollToken }));
};
ws.onmessage = (e) => handle(JSON.parse(e.data));
ws.onclose = () => { setStatus('Disconnected. Reconnecting in 3s…', 'warn'); setTimeout(connect, 3000); };
}
async function handle(m) {
switch (m.type) {
case 'agent-registered':
setStatus(`Online as "${m.name}". Waiting for sessions.`, 'on');
log(`registered: ${m.name}`);
break;
case 'session-request':
if (m.unattended) { log(`unattended session ${m.sessionId} — auto-granting`); grant(m.sessionId); }
else showConsent(m);
break;
case 'start-stream':
currentSessionId = m.sessionId;
await startStreaming();
break;
case 'answer':
if (pc) await pc.setRemoteDescription(new RTCSessionDescription(m.sdp));
break;
case 'ice-candidate':
if (m.candidate && pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate));
break;
case 'session-ended':
teardown();
break;
case 'error':
setStatus('Server: ' + m.message, 'warn');
break;
}
}
function showConsent(m) {
consentBox.innerHTML = `
<div class="consent">
<h2>Allow remote support?</h2>
<p class="muted"><b>${escapeHtml(m.technician)}</b> is requesting to view and control this PC.</p>
<button class="grant" id="grantBtn">Allow</button>
<button class="deny" id="denyBtn">Deny</button>
</div>`;
document.getElementById('grantBtn').onclick = () => { consentBox.innerHTML = ''; grant(m.sessionId); };
document.getElementById('denyBtn').onclick = () => { consentBox.innerHTML = ''; deny(m.sessionId); };
}
const grant = (sessionId) => ws.send(JSON.stringify({ type: 'consent', sessionId, granted: true }));
const deny = (sessionId) => ws.send(JSON.stringify({ type: 'consent', sessionId, granted: false }));
async function startStreaming() {
setStatus('Sharing screen with technician…', 'on');
indicator.classList.add('show');
try {
localStream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 30 } }, audio: false });
} catch (err) {
log('getDisplayMedia failed: ' + err.message);
setStatus('Screen capture failed.', 'warn');
return;
}
pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
localStream.getTracks().forEach((t) => pc.addTrack(t, localStream));
// The agent is the OFFERER, so it must create the input data channel — otherwise the
// SCTP m-line is absent from the offer and the channel never negotiates (viewer's stays
// closed, so no input arrives). The viewer receives this channel via ondatachannel.
const inputCh = pc.createDataChannel('input', { ordered: true });
inputCh.onmessage = (msg) => {
let evt; try { evt = JSON.parse(msg.data); } catch { return; }
window.agent.injectInput(evt); // -> main process -> OS injection
};
pc.onicecandidate = (ev) => {
if (ev.candidate) ws.send(JSON.stringify({ type: 'ice-candidate', sessionId: currentSessionId, candidate: ev.candidate }));
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
ws.send(JSON.stringify({ type: 'offer', sessionId: currentSessionId, sdp: pc.localDescription }));
log('sent offer for session ' + currentSessionId);
}
function teardown() {
indicator.classList.remove('show');
window.agent.sessionEnded();
if (localStream) { localStream.getTracks().forEach((t) => t.stop()); localStream = null; }
if (pc) { pc.close(); pc = null; }
currentSessionId = null;
setStatus('Session ended. Waiting for sessions.', 'on');
}
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }