/* NetDesk BROWSER HOST — share this screen (view-only) with an ID + password, * no install. Speaks the exact NetDesk host protocol so any existing viewer * (web or native app) can connect: reg.host -> session.challenge -> verify proof * -> session.accept -> stream E2E-sealed H.264 (getDisplayMedia + WebCodecs). * * Hard limit (browser sandbox): VIEW-ONLY — a web page cannot inject OS mouse/ * keyboard, so viewers see+hear this screen but cannot control it. Everything * else (ID+password, end-to-end encryption, audio) works like the installed host. * * Reuses globals from app.js: enc, toHex, b64ToBytes, sha256Hex. */ (function () { "use strict"; const H = { ws: null, id: null, pw: null, salt: null, iters: 200000, verifier: null, sid: null, nonce: null, ecdh: null, e2eKey: null, authed: false, stream: null, venc: null, aenc: null, vreader: null, areader: null, forceKey: false, running: false, dims: { w: 0, h: 0 }, fcount: 0, }; const $ = (s) => document.querySelector(s); function supported() { return !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia && window.VideoEncoder && window.MediaStreamTrackProcessor && window.WebSocket); } function rand9() { let s = String(1 + Math.floor(Math.random() * 9)); for (let i = 0; i < 8; i++) s += Math.floor(Math.random() * 10); return s; } function genPw() { const a = "abcdefghjkmnpqrstuvwxyz23456789"; let s = ""; const r = crypto.getRandomValues(new Uint8Array(10)); for (let i = 0; i < 10; i++) s += a[r[i] % a.length]; return s; } function b64(u8) { let s = ""; for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return btoa(s); } function status(msg) { const e = $("#hostStatus"); if (e) e.textContent = msg; } async function deriveCreds(password) { const salt = crypto.getRandomValues(new Uint8Array(16)); const k = await crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveBits"]); const dk = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt, iterations: H.iters }, k, 256); H.salt = salt; H.verifier = toHex(dk); } async function start() { if (!supported()) { status("This browser can't host — use Chrome/Edge (needs WebCodecs + screen capture)."); return; } try { // getDisplayMedia must be in the click gesture — prompt for the screen first. H.stream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: true, }); } catch (e) { status("Screen share cancelled."); return; } H.id = $("#hostIdOut").textContent.trim() || rand9(); H.pw = $("#hostPwOut").textContent.trim() || genPw(); await deriveCreds(H.pw); // the user clicked "Stop sharing" in the browser's share bar H.stream.getVideoTracks()[0].addEventListener("ended", stop); const wsUrl = location.origin.replace(/^http/, "ws") + "/ws"; H.ws = new WebSocket(wsUrl); H.ws.binaryType = "arraybuffer"; H.running = true; H.ws.onopen = () => H.ws.send(JSON.stringify({ t: "reg.host", id: H.id, name: "Browser host", takeover: true })); H.ws.onmessage = onMessage; H.ws.onclose = () => { if (H.running) status("Disconnected from server."); }; H.ws.onerror = () => status("Server connection error."); $("#btnHostStart").hidden = true; $("#btnHostStop").hidden = false; status("Registering… share the ID + password below."); } async function onMessage(ev) { if (typeof ev.data !== "string") return onVoiceFrame(ev.data); // viewer mic (CHAN_VOICE) let m; try { m = JSON.parse(ev.data); } catch { return; } switch (m.t) { case "host_status": status(m.ok ? "✅ Live — anyone with the ID + password can view this screen." : ("Register failed: " + (m.msg || ""))); break; case "session.begin": return challenge(m); case "session.auth": return auth(m); case "keyframe": H.forceKey = true; break; case "session.end": case "session.deny": case "viewer.left": status("✅ Live — viewer disconnected; ready for the next."); break; // input / sys.cmd / clip.set / monitor.switch / file.* -> view-only: ignored } } async function challenge(m) { H.sid = m.sid || toHex(crypto.getRandomValues(new Uint8Array(16))); H.nonce = toHex(crypto.getRandomValues(new Uint8Array(16))); H.ecdh = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]); const pub = b64(new Uint8Array(await crypto.subtle.exportKey("raw", H.ecdh.publicKey))); H.ws.send(JSON.stringify({ t: "session.challenge", sid: H.sid, salt: b64(H.salt), iters: H.iters, nonce: H.nonce, e2e: true, totp: false, pub, })); status("A viewer is connecting…"); } async function auth(m) { const expect = await sha256Hex(H.nonce + H.verifier); if (m.proof !== expect) { H.ws.send(JSON.stringify({ t: "session.deny", sid: H.sid, reason: "wrong password" })); status("✅ Live — a viewer entered the wrong password (denied)."); return; } // E2E: ECDH(host_priv, viewer_pub) -> SHA256(shared || "e2e") -> AES-GCM key const viewerPub = await crypto.subtle.importKey("raw", b64ToBytes(m.pub), { name: "ECDH", namedCurve: "P-256" }, false, []); const shared = new Uint8Array(await crypto.subtle.deriveBits({ name: "ECDH", public: viewerPub }, H.ecdh.privateKey, 256)); const ctx = new Uint8Array(shared.length + 3); ctx.set(shared, 0); ctx.set(enc.encode("e2e"), shared.length); const keyBytes = new Uint8Array(await crypto.subtle.digest("SHA-256", ctx)); H.e2eKey = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt", "decrypt"]); const vs = H.stream.getVideoTracks()[0].getSettings(); H.dims = { w: (vs.width | 0) || 1280, h: (vs.height | 0) || 720 }; const audio = H.stream.getAudioTracks().length > 0; H.ws.send(JSON.stringify({ t: "session.accept", sid: H.sid, info: { w: H.dims.w, h: H.dims.h, name: "Browser host", e2e: true, audio, monitors: [{ i: 1, w: H.dims.w, h: H.dims.h }], mon: 1 }, })); H.authed = true; H.forceKey = true; status("✅ Live — a viewer is now watching this screen."); await startEncoders(); } // ---- Voice call RECEIVE: the viewer's microphone arrives sealed as // [CHAN_VOICE=0xa2][opus]. Decrypt, Opus-decode (WebCodecs) and play it on // this browser-host's speakers, with the same adaptive jitter buffer and the // REQUIRED planeIndex:0 copyTo the main viewer uses. let vdec = null, vctx = null, vNext = 0, vLead = 0.12; async function onVoiceFrame(buf) { if (!H.e2eKey) return; try { const data = new Uint8Array(buf); const pt = new Uint8Array(await crypto.subtle.decrypt( { name: "AES-GCM", iv: data.subarray(0, 12) }, H.e2eKey, data.subarray(12))); if (pt.length < 2 || pt[0] !== 0xa2) return; // CHAN_VOICE only if (!vdec) setupVoiceDec(); if (vdec && vdec.state !== "closed") { vdec.decode(new EncodedAudioChunk({ type: "key", timestamp: Math.round(performance.now() * 1000), data: pt.subarray(1), })); } } catch (e) {} } function setupVoiceDec() { if (!window.AudioDecoder) return; try { if (!vctx) vctx = new (window.AudioContext || window.webkitAudioContext)(); vNext = 0; vLead = 0.12; vdec = new AudioDecoder({ output: playVoice, error: () => { try { vdec = null; } catch (e) {} } }); vdec.configure({ codec: "opus", sampleRate: 48000, numberOfChannels: 2 }); } catch (e) { vdec = null; } } function playVoice(ad) { const ctx = vctx; if (!ctx) { if (ad.close) ad.close(); return; } if (ctx.state === "suspended") ctx.resume(); const n = ad.numberOfChannels, len = ad.numberOfFrames, rate = ad.sampleRate; const b = ctx.createBuffer(n, len, rate); const planar = (ad.format || "").includes("planar"); for (let ch = 0; ch < n; ch++) { const dst = b.getChannelData(ch); if (planar) ad.copyTo(dst, { planeIndex: ch, format: "f32-planar" }); else { const tmp = new Float32Array(len * n); ad.copyTo(tmp, { planeIndex: 0, format: "f32" }); for (let i = 0; i < len; i++) dst[i] = tmp[i * n + ch]; } } if (ad.close) ad.close(); const now = ctx.currentTime; if (vNext === 0) vNext = now + vLead; else if (vNext < now) { vLead = Math.min(vLead + 0.04, 0.45); vNext = now + vLead; } else if (vNext > now + vLead + 0.35) vNext = now + vLead; const src = ctx.createBufferSource(); src.buffer = b; src.connect(ctx.destination); src.start(vNext); vNext += len / rate; } async function sealSend(frameBytes) { if (!H.e2eKey || !H.ws || H.ws.readyState !== 1) return; const nonce = crypto.getRandomValues(new Uint8Array(12)); const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, H.e2eKey, frameBytes)); const out = new Uint8Array(12 + ct.length); out.set(nonce, 0); out.set(ct, 12); H.ws.send(out.buffer); } async function startEncoders() { if (H.venc) return; // one viewer at a time (one encoder) const vt = H.stream.getVideoTracks()[0]; H.venc = new VideoEncoder({ output: (chunk) => { const data = new Uint8Array(chunk.byteLength); chunk.copyTo(data); const frame = new Uint8Array(2 + data.length); frame[0] = 0x01; // CHAN_VIDEO frame[1] = chunk.type === "key" ? 1 : 0; // bit0 keyframe, codec 0 = H.264 frame.set(data, 2); sealSend(frame); }, error: (e) => console.error("video encoder", e), }); // Pick a supported H.264 config (profile/level varies by browser); keep // annexb so the output has in-band SPS/PPS + start codes the viewer parses. const cfgBase = { width: H.dims.w, height: H.dims.h, bitrate: 4_000_000, framerate: 30, avc: { format: "annexb" } }; let chosen = null; for (const codec of ["avc1.42E01F", "avc1.42001F", "avc1.4D401F", "avc1.640028", "avc1.42E01E"]) { try { const sup = await VideoEncoder.isConfigSupported(Object.assign({ codec }, cfgBase)); if (sup && sup.supported) { chosen = sup.config || Object.assign({ codec }, cfgBase); break; } } catch (e) { /* try next */ } } if (!chosen) { status("This browser can't H.264-encode the screen — try Chrome/Edge."); try { H.venc.close(); } catch { } H.venc = null; return; } H.venc.configure(chosen); const vproc = new MediaStreamTrackProcessor({ track: vt }); H.vreader = vproc.readable.getReader(); (async function pump() { while (H.running && H.venc) { let r; try { r = await H.vreader.read(); } catch { break; } if (r.done) break; const frame = r.value; try { if (H.venc && H.venc.state === "configured" && H.venc.encodeQueueSize < 3) { const key = H.forceKey || (H.fcount % 120 === 0); H.forceKey = false; H.venc.encode(frame, { keyFrame: key }); H.fcount++; } } catch (e) { /* encoder closed */ } frame.close(); } })(); // audio (system/tab sound captured by getDisplayMedia), best-effort const at = H.stream.getAudioTracks()[0]; if (at && window.AudioEncoder) { try { H.aenc = new AudioEncoder({ output: (chunk) => { const data = new Uint8Array(chunk.byteLength); chunk.copyTo(data); const frame = new Uint8Array(1 + data.length); frame[0] = 0xa1; // CHAN_AUDIO frame.set(data, 1); sealSend(frame); }, error: (e) => console.error("audio encoder", e), }); H.aenc.configure({ codec: "opus", sampleRate: 48000, numberOfChannels: 2, bitrate: 96000 }); const aproc = new MediaStreamTrackProcessor({ track: at }); H.areader = aproc.readable.getReader(); (async function apump() { while (H.running && H.aenc) { let r; try { r = await H.areader.read(); } catch { break; } if (r.done) break; try { H.aenc.encode(r.value); } catch (e) { } r.value.close(); } })(); } catch (e) { console.warn("audio unavailable", e); } } } function stop() { H.running = false; try { H.venc && H.venc.close(); } catch { } H.venc = null; try { H.aenc && H.aenc.close(); } catch { } H.aenc = null; try { H.vreader && H.vreader.cancel(); } catch { } try { H.areader && H.areader.cancel(); } catch { } try { H.stream && H.stream.getTracks().forEach((t) => t.stop()); } catch { } try { H.ws && H.ws.close(); } catch { } H.stream = null; H.ws = null; H.e2eKey = null; H.authed = false; $("#btnHostStart").hidden = false; $("#btnHostStop").hidden = true; status("Stopped sharing."); } // ---- wire the UI ---- function init() { const sec = $("#hostShare"); if (!sec) return; if (!supported()) { const b = $("#btnHostStart"); if (b) { b.disabled = true; b.textContent = "Not supported in this browser"; } status("Browser hosting needs Chrome or Edge (WebCodecs + screen capture)."); } // pre-generate a fresh ID + password to display $("#hostIdOut").textContent = rand9(); $("#hostPwOut").textContent = genPw(); $("#btnHostStart").onclick = start; $("#btnHostStop").onclick = stop; const regen = $("#btnHostRegen"); if (regen) regen.onclick = () => { if (H.running) return; $("#hostIdOut").textContent = rand9(); $("#hostPwOut").textContent = genPw(); }; } if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init); else init(); })();