NetDesk 1.5.0 — source release
This commit is contained in:
commit
40318aafdd
227 files changed
+80798
No files matched your search
@@ -0,0 +1,307 @@
|
||||
/* 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();
|
||||
})();
|
||||
Reference in new issue
Block a user