NetDesk 1.5.0 — source release

This commit is contained in:
NetDesk committed 2026-10-18 02:02:46 +00:00
commit 40318aafdd
227 files changed
+80798

No files matched your search

+307
View File
@@ -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();
})();