Files

2996 lines
121 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* NetDesk web viewer - WebCodecs H.264 decode, E2E (AES-GCM), input, files, chat. */
"use strict";
const $ = (s) => document.querySelector(s);
window.__ndErr = [];
window.addEventListener("error", (e) => window.__ndErr.push("err: " + e.message));
window.addEventListener("unhandledrejection", (e) => window.__ndErr.push("rej: " + e.reason));
/* ------------------------------------------------------------- utils */
const enc = new TextEncoder();
const dec = new TextDecoder();
function toHex(buf) {
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
function b64ToBytes(b64) {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
async function sha256Hex(str) {
return toHex(await crypto.subtle.digest("SHA-256", enc.encode(str)));
}
function fmtBytes(n) {
if (n > 1e9) return (n / 1e9).toFixed(2) + " GB";
if (n > 1e6) return (n / 1e6).toFixed(1) + " MB";
if (n > 1e3) return (n / 1e3).toFixed(1) + " KB";
return n + " B";
}
function now() { return performance.now(); }
// Resolution stays NATIVE on every rung (crisp text, like TeamViewer):
// a bad link costs fps and bitrate, never sharpness. Scale changes also
// force an encoder rebuild + keyframe - that was the visible "break".
const QUALITY = {
min: { fps: 8, bitrate: 700, scale: 1.0 }, // very slow links: still sharp, fewer frames
low: { fps: 15, bitrate: 2500, scale: 1.0 },
balanced: { fps: 30, bitrate: 6000, scale: 1.0 },
high: { fps: 30, bitrate: 12000, scale: 1.0 },
extreme: { fps: 60, bitrate: 20000, scale: 1.0 },
};
/* ------------------------------------------------------------- state */
const S = {
ws: null,
sid: null,
direct: false,
serverUrl: "",
hostId: "",
viewOnly: false,
dims: { w: 0, h: 0 },
monitors: [],
e2e: false,
e2eKey: null, // CryptoKey AES-GCM once derived
decoder: null,
audioDecoder: null,
audioCtx: null,
sps: null,
pps: null,
started: false, // got first keyframe
latestFrame: null,
drawing: false,
bytesVideo: 0,
framesDecoded: 0,
fpsShown: 0,
lastFpsTick: now(),
rtt: 0,
buttons: 0,
pendingMouse: null,
clipSync: false,
lastRemoteClip: "",
transfers: Object.create(null), // fid -> download state
nextFid: 1,
chatOpen: false,
kbdLocked: false,
dead: false,
_timers: [],
heldKeys: new Set(),
curShapes: {}, curId: null, curPos: null,
};
let decodeQueue = Promise.resolve(); // preserve chunk order across async decrypt
/* ------------------------------------------------------------- connect screen */
$("#btnPwEye").onclick = () => {
const i = $("#inPw");
i.type = i.type === "password" ? "text" : "password";
};
$("#inServer").value = localStorage.getItem("nd.server") || "";
$("#inId").value = localStorage.getItem("nd.id") || "";
if (localStorage.getItem("nd.remember") === "1") {
$("#inRemember").checked = true;
$("#inPw").value = localStorage.getItem("nd.pw") || "";
}
// deep link from the portal: /?id=123456789[&server=...]
{
const qs = new URLSearchParams(location.search);
const qid = (qs.get("id") || "").replace(/\D/g, "");
// opened inside the desktop app's own viewer window (no site chrome there)
if (qs.get("embed")) document.body.classList.add("embed");
if (qid) {
$("#inId").value = qid;
if (qs.get("server")) $("#inServer").value = qs.get("server");
setTimeout(() => $("#inPw").focus(), 100);
}
// Reload while a session was live (and Disconnect was never clicked):
// resume it automatically - with the remembered password it reconnects by
// itself; otherwise one keystroke away.
try {
const rs = JSON.parse(localStorage.getItem("nd.resume") || "null");
if (!qid && rs && rs.id && Date.now() - rs.t < 12 * 3600e3) {
$("#inId").value = rs.id;
if ($("#inPw").value) {
connectErr("You were in a session — resuming…");
setTimeout(() => connect(), 300);
} else {
connectErr("You were in a session — enter the password to resume.");
setTimeout(() => $("#inPw").focus(), 150);
}
}
} catch {}
}
$("#btnConnect").onclick = () => connect();
$("#inPw").addEventListener("keydown", (e) => { if (e.key === "Enter") connect(); });
$("#inId").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#inPw").focus(); });
/* ------------------------------------------------------------- viewer account */
const QS = new URLSearchParams(location.search);
const URLTOKEN = QS.get("reset");
const VERIFIED = QS.get("verified") === "1";
let myEmail = null;
async function refreshAuth() {
try {
const r = await fetch("/api/auth/me", { credentials: "same-origin" });
if (r.ok) { const d = await r.json(); setSignedIn(d.email, d.owner); }
else setSignedOut();
} catch { setSignedOut(); }
}
async function signOut() {
await fetch("/api/auth/logout", { method: "POST" }).catch(() => {});
window.__isOwner = false;
setSignedOut();
}
function renderNavAuth() {
const nav = $("#navAuth");
if (nav) {
nav.innerHTML = "";
if (myEmail) {
const t = document.createElement("span");
t.className = "nav-auth-email";
t.textContent = (window.__isOwner ? "⭐ " : "") + myEmail;
const b = document.createElement("button");
b.className = "nav-auth-btn";
b.textContent = "Sign out";
b.onclick = signOut;
nav.append(t, b);
} else {
const b = document.createElement("button");
b.className = "nav-auth-btn";
b.textContent = "Sign in / Sign up";
b.onclick = () => openAuth("in");
nav.append(b);
}
}
const fb = $("#btnAuthOpen");
if (fb) {
fb.textContent = myEmail ? "Sign out" : "Sign in / Sign up";
fb.onclick = myEmail ? signOut : () => openAuth("in");
}
}
function setSignedIn(email, owner) {
myEmail = email;
window.__isOwner = !!owner;
renderNavAuth();
document.querySelectorAll("#authChip").forEach((c) => {
c.hidden = false; c.textContent = email;
c.style.cursor = "pointer"; c.title = "Account & two-factor";
c.onclick = () => openAuth("2fa");
});
const a2 = $("#atab2fa"); if (a2) a2.hidden = false;
loadDevices();
}
function setSignedOut() {
myEmail = null;
const a2 = $("#atab2fa"); if (a2) a2.hidden = true;
renderNavAuth();
document.querySelectorAll("#authChip").forEach((c) => { c.hidden = true; });
const w = $("#myDevsWrap");
if (w) w.hidden = true;
}
/* my computers: devices bound to this account from the desktop app */
async function loadDevices() {
const wrap = $("#myDevsWrap"), box = $("#myDevs");
if (!wrap || !myEmail) return;
let list = [];
try {
const r = await fetch("/api/devices");
if (!r.ok) return;
list = (await r.json()).devices || [];
} catch { return; }
wrap.hidden = false;
box.innerHTML = "";
if (!list.length) {
const e = document.createElement("div");
e.className = "foot";
e.textContent = "No computers yet — sign in with this account in the NetDesk app on a PC and it shows up here.";
box.append(e);
return;
}
for (const d of list) {
const row = document.createElement("div");
row.className = "ab-row";
const dot = document.createElement("span");
dot.className = "dev-dot" + (d.online ? (d.busy ? " busy" : " on") : "");
dot.title = d.online ? (d.busy ? "in a session" : "online") : "offline";
const lbl = document.createElement("span");
lbl.className = "ab-label";
lbl.textContent = d.name;
if (d.shared) {
const sh = document.createElement("span");
sh.className = "ab-shared";
sh.textContent = "shared" + (d.team ? " · " + d.team : "");
sh.title = "Shared with you via team" + (d.team ? " “" + d.team + "”" : "") + (d.owner ? " by " + d.owner : "");
lbl.append(" ", sh);
}
const id = document.createElement("span");
id.className = "ab-id";
id.textContent = d.id;
row.append(dot, lbl, id);
if (!d.shared) {
// only devices you own can be removed from here; team-shared ones are managed by their owner
const del = document.createElement("button");
del.className = "tb";
del.textContent = "🗑";
del.title = "Remove from my computers";
del.onclick = async (ev) => {
ev.stopPropagation();
if (!confirm("Remove " + d.name + " from your computers?")) return;
await fetch("/api/devices/remove", { method: "POST",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: d.id }) });
loadDevices();
};
row.append(del);
}
row.onclick = () => { $("#inId").value = d.id; $("#inPw").focus(); };
box.append(row);
}
}
setInterval(() => { if (myEmail && $("#screenSession").hidden) loadDevices(); }, 15000);
function authMsg(msg, ok) {
const m = $("#auMsg"), o = $("#auOk");
o.hidden = true;
m.hidden = !msg;
m.textContent = msg || "";
if (ok) { m.hidden = true; o.hidden = false; o.textContent = msg; }
}
function showAuthPane(t) {
for (const id of ["in", "up", "fp", "rs", "2fa"]) { const p = $("#pane-" + id); if (p) p.hidden = id !== t; }
document.querySelectorAll(".atab").forEach(b => b.classList.toggle("on", b.dataset.t === t));
$("#auMsg").hidden = true; $("#auOk").hidden = true;
if (t === "2fa") tf2Refresh();
}
function openAuth(defaultTab) {
$("#authModal").hidden = false;
showAuthPane(defaultTab || "in");
}
$("#btnAuthClose").onclick = () => { $("#authModal").hidden = true; };
async function authPost(path, body, okMsg, closeAfter) {
let r, d;
try {
r = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body) });
d = await r.json().catch(() => ({}));
} catch { authMsg("Server unreachable"); return false; }
if (!r.ok) { authMsg(d.error || "failed"); return false; }
if (d.email) setSignedIn(d.email, d.owner);
authMsg(okMsg || d.msg || "done", true);
if (closeAfter) setTimeout(() => { $("#authModal").hidden = true; }, 1200);
return true;
}
document.querySelectorAll(".atab").forEach(b => b.onclick = () => showAuthPane(b.dataset.t));
const onEnter = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("keydown", (e) => { if (e.key === "Enter") fn(); }); };
const doLogin = async () => {
const body = { email: $("#auEmail").value, password: $("#auPass").value };
const totp = ($("#auTotp") && $("#auTotp").value || "").trim();
if (totp) body.totp = totp;
let r, d;
try {
r = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body) });
d = await r.json().catch(() => ({}));
} catch { authMsg("Server unreachable"); return; }
if (!r.ok) {
if (d.twofa) {
$("#auTotpLbl").hidden = false; $("#auTotp").hidden = false; $("#auTotp").focus();
authMsg(totp ? "Invalid 2FA code — try again." : "Enter your 6-digit authenticator code.");
} else { authMsg(d.error || "failed"); }
return;
}
if (d.email) setSignedIn(d.email, d.owner);
authMsg("Signed in.", true);
$("#auTotp").value = ""; $("#auTotpLbl").hidden = true; $("#auTotp").hidden = true;
setTimeout(() => { $("#authModal").hidden = true; }, 1200);
};
const doSignup = () => authPost("/api/auth/signup",
{ email: $("#auEmail2").value, password: $("#auPass2").value },
"Account created — you are signed in.", true);
const doForgot = () => authPost("/api/auth/forgot",
{ email: $("#auEmail3").value }, "If that account exists, a reset email was sent.", false);
const doReset = async () => {
if (await authPost("/api/auth/reset", { token: URLTOKEN, password: $("#auPass4").value },
"Password changed — sign in with the new password.", false)) {
history.replaceState(null, "", "/");
setTimeout(() => showAuthPane("in"), 1400);
}
};
$("#auGo").onclick = doLogin; onEnter("#auPass", doLogin);
$("#auGo2").onclick = doSignup; onEnter("#auPass2", doSignup);
$("#auGo3").onclick = doForgot;
$("#auGo4").onclick = doReset; onEnter("#auPass4", doReset);
/* ----------------------------------------------- account 2FA (TOTP) */
let tf2Secret = "";
async function tf2Refresh() {
tf2Secret = "";
const st = $("#tf2Status"), en = $("#tf2Enable"), enr = $("#tf2Enroll"), dis = $("#tf2DisableWrap");
if (enr) enr.hidden = true; if (dis) dis.hidden = true; if (en) en.hidden = true;
if (!myEmail) { if (st) st.textContent = "Sign in first."; return; }
try {
const r = await fetch("/api/auth/2fa/status", { credentials: "same-origin" });
const d = await r.json().catch(() => ({}));
if (d.enabled) { st.textContent = "✅ Two-factor is ON for this account."; dis.hidden = false; }
else { st.textContent = "Two-factor is off — enable it to require a code at sign-in."; en.hidden = false; }
} catch { st.textContent = "Could not load 2FA status."; }
}
async function tf2BeginEnroll() {
try {
const r = await fetch("/api/auth/2fa/setup", { method: "POST", credentials: "same-origin" });
const d = await r.json().catch(() => ({}));
if (!r.ok || !d.secret) { authMsg(d.error || "setup failed"); return; }
tf2Secret = d.secret;
$("#tf2Secret").value = d.secret;
$("#tf2Uri").textContent = d.otpauth || "";
$("#tf2Enable").hidden = true; $("#tf2Enroll").hidden = false;
$("#tf2Code").value = ""; $("#tf2Code").focus();
} catch { authMsg("Server unreachable"); }
}
async function tf2Confirm() {
const code = ($("#tf2Code").value || "").trim();
if (!/^\d{6}$/.test(code)) { authMsg("Enter the 6-digit code."); return; }
const r = await fetch("/api/auth/2fa/enable", { method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ secret: tf2Secret, code }) });
const d = await r.json().catch(() => ({}));
if (!r.ok) { authMsg(d.error || "invalid code"); return; }
authMsg("Two-factor enabled.", true); tf2Refresh();
}
async function tf2Disable() {
const code = ($("#tf2DisCode").value || "").trim();
const r = await fetch("/api/auth/2fa/disable", { method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code }) });
const d = await r.json().catch(() => ({}));
if (!r.ok) { authMsg(d.error || "invalid code"); return; }
authMsg("Two-factor disabled.", true); tf2Refresh();
}
{ const b = $("#tf2Enable"); if (b) b.onclick = tf2BeginEnroll; }
{ const b = $("#tf2Confirm"); if (b) b.onclick = tf2Confirm; }
{ const b = $("#tf2Disable"); if (b) b.onclick = tf2Disable; }
onEnter("#auTotp", doLogin);
onEnter("#tf2Code", tf2Confirm);
onEnter("#tf2DisCode", tf2Disable);
refreshAuth();
if (VERIFIED) { openAuth("in"); authMsg("Email verified - thank you.", true); history.replaceState(null, "", "/"); }
if (URLTOKEN) { openAuth("rs"); }
/* ------------------------------------------------------------- theme */
function applyTheme(t) {
document.documentElement.dataset.theme = t;
const btns = [document.querySelector("#btnTheme"), document.querySelector("#btnThemeP")];
btns.forEach(b => { if (b) b.textContent = t === "light" ? "☾ Dark" : "☀ Light"; });
}
applyTheme(localStorage.getItem("nd.theme") || "dark");
$("#btnTheme").onclick = () => {
const t = document.documentElement.dataset.theme === "light" ? "dark" : "light";
localStorage.setItem("nd.theme", t);
applyTheme(t);
};
/* ------------------------------------------------------------- address book */
function abookGet() {
try { return JSON.parse(localStorage.getItem("nd.abook") || "[]"); } catch { return []; }
}
function abookSet(list) { localStorage.setItem("nd.abook", JSON.stringify(list)); renderAbook(); }
function renderAbook() {
const list = abookGet();
$("#abook").hidden = list.length === 0;
const box = $("#abook");
box.innerHTML = "";
for (const e of list) {
const row = document.createElement("div");
row.className = "ab-row";
const lbl = document.createElement("span");
lbl.className = "ab-label";
lbl.textContent = e.label;
const id = document.createElement("span");
id.className = "ab-id";
id.textContent = e.id;
const del = document.createElement("button");
del.className = "tb";
del.textContent = "🗑";
del.onclick = (ev) => { ev.stopPropagation(); abookSet(abookGet().filter((x) => x.id !== e.id)); };
row.append(lbl, id, del);
row.onclick = () => {
$("#inServer").value = e.server || $("#inServer").value;
$("#inId").value = e.id;
$("#inPw").focus();
};
box.append(row);
}
}
$("#btnAbAdd").onclick = () => {
const id = $("#inId").value.replace(/\D/g, "");
if (!id) { connectErr("Enter an ID first, then save it."); return; }
const label = prompt("Name for this connection:", "My PC") || id;
const list = abookGet().filter((x) => x.id !== id);
list.push({ id, label, server: $("#inServer").value.trim() });
abookSet(list);
};
renderAbook();
function connectErr(msg) {
const el = $("#connectErr");
el.textContent = msg;
el.hidden = false;
$("#btnConnect").disabled = false;
$("#btnConnect").textContent = "Connect";
}
function connectErrHide() { $("#connectErr").hidden = true; }
async function connect() {
if (!window.VideoDecoder) {
connectErr("This browser has no WebCodecs video decoder. Use Chrome, Edge or Safari 16.4+.");
return;
}
if (!$("#inId").value.replace(/\D/g, "") && !/^wss?:\/\//i.test($("#inServer").value.trim())) {
connectErr("Enter the remote computer's ID.");
return;
}
if (S.ws) { const old = S.ws; S.ws = null; try { old.close(); } catch {} }
resetSessionState();
$("#connectErr").hidden = true;
$("#btnConnect").disabled = true;
$("#btnConnect").textContent = "Connecting…";
let server = $("#inServer").value.trim();
const id = $("#inId").value.replace(/\D/g, "");
const pw = $("#inPw").value;
S.viewOnly = $("#inViewOnly").checked;
localStorage.setItem("nd.server", server);
localStorage.setItem("nd.id", id);
if ($("#inRemember").checked) {
localStorage.setItem("nd.remember", "1");
localStorage.setItem("nd.pw", pw);
} else {
localStorage.removeItem("nd.remember");
localStorage.removeItem("nd.pw");
}
let wsUrl;
if (/^wss?:\/\//i.test(server)) {
// Direct connection to a host running --listen
S.direct = true;
wsUrl = server.replace(/\/+$/, "") + "/ws";
} else {
S.direct = false;
let loc = location;
if (server) {
try { loc = new URL(/^https?:\/\//i.test(server) ? server : "https://" + server); }
catch { connectErr("Invalid server address: " + server); return; }
}
const scheme = loc.protocol === "https:" ? "wss" : "ws";
wsUrl = `${scheme}://${loc.host}/ws`;
}
S.serverUrl = server;
let ws;
try {
ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
await new Promise((res, rej) => {
ws.onopen = res;
ws.onerror = () => rej(new Error("cannot reach server"));
setTimeout(() => rej(new Error("connection timeout")), 10000);
});
} catch (e) {
connectErr("Could not connect: " + e.message);
return;
}
S.ws = ws;
S.hostId = id;
S.dead = false;
ws.onmessage = (ev) => { if (ws === S.ws) onMessage(ev); };
ws.onclose = onWsClose;
ws.onerror = () => {};
// Keep the password for the challenge (cleared after auth)
S._pw = pw;
send({ t: "session.begin", id: S.direct ? "0" : id, direct: S.direct,
takeover: !!S._takeover });
S._takeover = false;
}
function resetSessionState() {
teardownMedia();
for (const t of S._timers || []) clearInterval(t);
S._timers = [];
Object.assign(S, {
sid: null, e2e: false, e2eKey: null, e2ePending: null, info: null,
dims: { w: 0, h: 0 }, monitors: [], sps: null, pps: null, started: false,
plan: null, limit: 0, startedAt: 0, _denyReason: null, _challenge: null,
_privacyNoted: false, _logonShown: false, buttons: 0, pendingMouse: null,
transfers: Object.create(null), autoLevel: null, rtt: 0, fpsShown: 0,
_rttBase: 0, _localCurTs: 0, _lastMoveSend: 0,
heldKeys: new Set(), curShapes: {}, curId: null, curPos: null,
});
wheelAccY = 0; wheelAccX = 0;
decodeQueue = Promise.resolve();
const rc = document.getElementById('remoteCursor'); if (rc) rc.hidden = true;
const stg = document.getElementById('stage'); if (stg) stg.classList.remove('remote-cursor');
// menus are per-host: forget the previous host's resolution list & wallpaper state
window.__modesLoaded = false;
const mm = document.getElementById('miModes');
if (mm) mm.innerHTML = '<button class="mi" id="miModesLoad">Load resolutions…</button>';
const wp = document.getElementById('miWallpaper'); if (wp) wp.classList.remove('on');
}
function onWsClose(ev) {
if (ev && ev.target && ev.target !== S.ws) return;
if (S.dead) return;
S.dead = true;
teardownMedia();
const st = $("#tbState");
if (!$("#screenSession").hidden) {
setState("disconnected", "err");
showBanner("Connection closed. Returning to connect screen…", true);
setTimeout(showConnectScreen, 600);
} else {
connectErr("Connection closed (" + (S._denyReason || "unexpectedly") + ")");
}
scheduleReconnect();
}
// The user never clicked Disconnect, so a drop (network blip, relay restart,
// host reconnect, page glitch) must heal itself: retry with backoff until the
// session is back. Credential problems never loop.
function scheduleReconnect() {
if (S.userEnd || S._recPending) return;
if (!$("#inPw").value || !$("#inId").value.replace(/\D/g, "")) return;
const reason = (S._denyReason || "").toLowerCase();
// credential / policy problems never loop
if (/password|2fa|denied|allowlist|not allowed|bad id/.test(reason)) return;
const busy = /busy|another|elsewhere/.test(reason);
S._recN = (S._recN || 0) + 1;
// Busy = the session is open on another device/tab. Don't storm: poll
// gently and, after a couple of tries, offer to take it over.
if (busy) {
if (S._recN >= 3) { offerTakeover(); return; }
scheduleIn(4000, "This computer is in use on another device — retrying…");
return;
}
if (S._recN > 10) { // give up storming; let the user decide
connectErr("Couldn't reconnect after several tries. Press Connect to retry.");
return;
}
// floor at 2s so even a fast accept→drop cycle can't hammer the relay
scheduleIn(Math.min(2000 + 1000 * S._recN, 8000),
"Connection lost — reconnecting (attempt " + S._recN + ")…");
}
function scheduleIn(delay, msg) {
S._recPending = true;
setTimeout(() => {
S._recPending = false;
if (S.userEnd || !$("#screenSession").hidden) return;
connectErr(msg);
connect();
}, delay);
}
function offerTakeover() {
const box = $("#connectErr");
connectErr("This computer is being controlled on another device.");
if (box && !$("#btnTakeover")) {
const b = document.createElement("button");
b.id = "btnTakeover"; b.className = "primary"; b.style.marginTop = "10px";
b.textContent = "Take over the session here";
b.onclick = () => { b.remove(); S._takeover = true; S._recN = 0; connect(); };
box.appendChild(b);
}
}
function showConnectScreen() {
for (const t of S._timers || []) clearInterval(t);
S._timers = [];
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
$("#screenSession").hidden = true;
$("#screenConnect").hidden = false;
$("#btnConnect").disabled = false;
$("#btnConnect").textContent = "Connect";
S._pw = null;
S._challenge = null;
hideBanner();
}
/* ------------------------------------------------------------- send helpers */
function send(obj) {
obj.sid = obj.sid || S.sid;
if (S.ws && S.ws.readyState === 1) S.ws.send(JSON.stringify(obj));
}
async function sendBinary(bytes) {
if (!S.e2eKey) {
if (S.ws && S.ws.readyState === 1) S.ws.send(bytes);
return;
}
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, S.e2eKey, bytes));
const out = new Uint8Array(12 + ct.length);
out.set(nonce, 0);
out.set(ct, 12);
if (S.ws && S.ws.readyState === 1) S.ws.send(out);
}
/* ------------------------------------------------------------- message dispatch */
function onMessage(ev) {
if (typeof ev.data === "string") {
let m;
try { m = JSON.parse(ev.data); } catch { return; }
handleJson(m);
} else {
const bytes = new Uint8Array(ev.data);
decodeQueue = decodeQueue.then(() => handleBinary(bytes)).catch((e) => console.error("binary error", e));
}
}
async function handleBinary(bytes) {
if (S.e2eKey) {
if (bytes.length < 29) return;
try {
const pt = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: bytes.subarray(0, 12) }, S.e2eKey, bytes.subarray(12));
bytes = new Uint8Array(pt);
} catch {
return; // drop undecryptable chunk
}
}
const chan = bytes[0];
if (chan === 0x01) onVideo(bytes);
else if (chan === 0xa1) onAudio(bytes);
else if (chan === 0xf1) onFileData(bytes, false);
else if (chan === 0xf2) onFileData(bytes, true);
}
async function handleJson(m) {
const t = m.t;
// Host RTT probe: echo it back with our measured frame jitter so the host's
// adaptive QoS gets a real end-to-end delay (not just RTT). Also keeps the
// host's loss estimate honest (an unanswered ping now counts as loss).
if (t === "net.ping") { send({ t: "net.pong", sid: m.sid, id: m.id, jit: frameJitterMs() }); return; }
if (t === "session.challenge") return doAuth(m);
if (t === "session.deny") {
S._denyReason = m.reason || "denied";
if (m.reason === "signin") { openAuth("in"); authMsg("Sign in with your email to connect."); }
else {
connectErr("Connection refused: " + (m.reason || "denied") +
(m.reason === "host offline" ? " — is the host agent running and registered?" : ""));
}
try { S.ws.close(); } catch {}
return;
}
if (t === "session.accept") {
// session is live: remember it so a page reload resumes automatically
S.userEnd = false;
S._recN = 0;
try {
localStorage.setItem("nd.resume", JSON.stringify(
{ id: (S.hostId || $("#inId").value).replace(/\D/g, ""), t: Date.now() }));
} catch {}
return onAccept(m);
}
if (t === "session.info") return onInfo(m.info);
if (t === "session.end") {
teardownMedia();
showBanner("Session ended: " + (m.reason || "closed"), true);
setState("ended", "err");
S._denyReason = m.reason || S._denyReason;
setTimeout(showConnectScreen, 600);
scheduleReconnect();
return;
}
if (t === "pong") { S.rtt = Math.round(now() - m.c); updateStats(); return; }
if (t === "rtc.answer") {
if (S._rtc && S._rtc.pc) {
S._rtc.pc.setRemoteDescription({ type: "answer", sdp: m.sdp })
.catch(() => { S._rtc = null; });
}
return;
}
if (t === "rtc.unavailable") { S._rtc = null; return; } // relay keeps working
if (t === "rtc.config") { if (window.__onRtcConfig) window.__onRtcConfig(m); return; }
if (t === "session.plan") {
S.plan = m.plan; S.limit = m.limit || 0; S.startedAt = now();
S.planEmail = m.email || "";
if (m.email) setSignedIn(m.email, m.plan === "owner");
return;
}
if (t === "cursor.shape") return onCursorShape(m);
if (t === "cursor") return onCursorPos(m);
if (t === "clip.set") return onRemoteClip(m);
if (t === "clip.image") return onRemoteClipImage(m);
if (t === "chat") return onChat(m.text, false);
if (t === "file.roots.res" || t === "file.list.res") return onFileList(m);
if (t === "file.done") return onFileDone(m);
if (t === "file.ok") return onFileOk(m);
if (t === "file.err") { setFileStatus("Error: " + m.msg, true); return; }
if (t === "sys.res") {
if (m.kind === "processes") return renderProcesses(m.items);
if (m.kind === "privacy") {
$("#btnPrivacy").classList.toggle("active", !!m.on);
return setFileStatus(m.msg || "ok");
}
// menu-driven results (modes / setmode / wallpaper / cad) handled below
if (window.onSysRes && window.onSysRes(m)) return;
if (m.msg) showBanner((m.ok ? "" : "Failed: ") + m.msg, m.ok, 2600);
return setFileStatus(m.ok ? (m.msg || "done") : "failed: " + m.msg, !m.ok);
}
}
/* ------------------------------------------------------------- auth + E2E */
async function doAuth(ch) {
S.sid = ch.sid;
if (ch.totp && !$("#inTotp").value.trim()) {
// wait for the user's authenticator code before proving the password
S._challenge = ch;
$("#lblTotp").hidden = false;
$("#inTotp").hidden = false;
$("#btnConnect").disabled = false;
$("#btnConnect").textContent = "Enter 2FA code…";
connectErr("This host requires a 2FA code — type it and press Enter.");
$("#inTotp").focus();
return;
}
await doAuthSend(ch);
}
$("#inTotp").addEventListener("keydown", (e) => {
if (e.key === "Enter" && S._challenge) {
const ch = S._challenge;
S._challenge = null;
connectErrHide();
$("#btnConnect").textContent = "Connecting…";
doAuthSend(ch);
}
});
async function doAuthSend(ch) {
let pub = null;
if (ch.e2e) {
try {
const kp = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]);
pub = btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.exportKey("raw", kp.publicKey))));
const hostPub = await crypto.subtle.importKey(
"raw", b64ToBytes(ch.pub), { name: "ECDH", namedCurve: "P-256" }, false, []);
const shared = new Uint8Array(await crypto.subtle.deriveBits({ name: "ECDH", public: hostPub }, kp.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));
S.e2ePending = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt", "decrypt"]);
} catch (e) {
window.__ndErr.push("doAuth: " + e);
console.warn("E2E unavailable, falling back to transport-only encryption", e);
pub = null;
}
}
// verifier = PBKDF2(password, salt, iters) hex ; proof = SHA256(nonce + verifier) hex
const salt = b64ToBytes(ch.salt);
const bits = await crypto.subtle.importKey("raw", enc.encode(S._pw || ""), "PBKDF2", false, ["deriveBits"]);
const dk = await crypto.subtle.deriveBits(
{ name: "PBKDF2", hash: "SHA-256", salt, iterations: ch.iters }, bits, 256);
const verifier = toHex(dk);
const proof = await sha256Hex(ch.nonce + verifier);
// Channel-bound proof (MITM hardening): when E2E is active, bind the host's
// exact ephemeral pubkey (ch.pub) into the proof so a relay that swapped it is
// detected host-side. Sent alongside the plain proof; non-E2E omits it.
const proof_bind = (ch.e2e && ch.pub && pub)
? await sha256Hex(ch.nonce + verifier + ch.pub)
: undefined;
S._pw = null; // discard
send({ t: "session.auth", proof, proof_bind, pub, totp: $("#inTotp").value.trim() });
// clear the code field for next time; hide unless still needed
$("#inTotp").value = "";
if (!ch.totp) { $("#lblTotp").hidden = true; $("#inTotp").hidden = true; }
}
async function onAccept(m) {
S._pw = null;
if (S.e2ePending && m.info && m.info.e2e) {
S.e2eKey = S.e2ePending;
S.e2ePending = null;
S.e2e = true;
}
$("#screenConnect").hidden = true;
$("#screenSession").hidden = false;
setState("live", "ok");
onInfo(m.info);
$("#canvas").focus();
// clipboard sync ON by default (RustDesk-style): text copied on the remote
// appears in your local clipboard automatically; copying locally pushes to
// the remote once you've granted the browser clipboard permission
S.clipSync = true;
$("#btnClipboard").classList.add("active");
// restore host-enforced view-only across reconnects (the engine resets the
// flag per session, so a reconnect must re-assert it)
if (S.viewOnly) send({ t: "view.only", on: true });
// ping loop
S._timers.push(setInterval(() => { if (!S.dead) send({ t: "ping", c: now() }); }, 2000));
// mouse coalesce flush loop (~120 Hz for smooth, low-latency dragging)
S._timers.push(setInterval(flushMouse, 8));
drawLoop();
}
function onInfo(info) {
S.info = Object.assign({}, S.info || {}, info); // later partial infos merge, not replace
info = S.info;
const reconfigure = S.dims.w && (S.dims.w !== info.w || S.dims.h !== info.h);
S.dims = { w: info.w, h: info.h };
S.monitors = info.monitors || [];
const sel = $("#selMonitor");
sel.innerHTML = "";
for (const mon of S.monitors) {
const o = document.createElement("option");
o.value = mon.i;
o.textContent = mon.i < 0
? `All displays (${mon.w}×${mon.h})`
: `Display ${mon.i} (${mon.w}×${mon.h})`;
sel.appendChild(o);
}
// restore last-viewed monitor + quality on reconnect (RustDesk 1.4.9 parity)
if (S.restoring !== false) {
const lastMon = localStorage.getItem("nd.lastmon");
const lastQ = localStorage.getItem("nd.lastq");
if (lastMon && [...sel.options].some(o => o.value === lastMon)) sel.value = lastMon;
if (lastQ && $("#selQuality").querySelector('option[value="' + lastQ + '"]'))
$("#selQuality").value = lastQ;
}
sel.value = sel.value || info.mon;
if (parseInt(sel.value, 10) !== info.mon) {
setTimeout(() => send({ t: "monitor.switch", mon: parseInt(sel.value, 10) }), 300);
}
$("#tbTitle").textContent = `NetDesk — ${info.host_name || S.hostId}` + (info.e2e ? " 🔒" : "");
// Host-enforced input permission: when the host disables input entirely,
// reflect it — force view-only and lock the toggle (the engine drops input
// regardless; this keeps the UI truthful).
if (info.perms && info.perms.input === false) {
S.inputDeniedByHost = true;
if (typeof setViewOnly === "function" && !S.viewOnly) setViewOnly(true);
$("#btnViewOnly").title = "Input is disabled by the host's permissions";
$("#btnViewOnly").classList.add("disabled");
}
if (typeof info.privacy_active === "boolean") {
$("#btnPrivacy").classList.toggle("active", info.privacy_active);
if (info.privacy_active && !S._privacyNoted) {
S._privacyNoted = true;
showBanner("Privacy mode active — the host's monitor is off.", true, 3000);
}
}
if (!info.privacy_active) S._privacyNoted = false;
if (info.logon_screen) showBanner("Host is at the Windows logon screen — you can type credentials to log in.", true);
else if (S.info && S._logonShown && !info.logon_screen) hideBanner();
if (info.logon_screen) S._logonShown = true;
if (info.audio && !S.audioDecoder) setupAudio();
// Remote sound ON by default (like TeamViewer/RDP): the moment the host says
// it has audio, request SYSTEM sound. Playback stays muted by the browser
// until a user gesture resumes the AudioContext — but you're controlling the
// host, so your next click/keypress on the canvas does that (gesture resumer
// below). Net effect: audio just plays once you interact.
if (info.audio && !S._audioAutoStarted) {
S._audioAutoStarted = true;
S.audioSrc = 1;
send({ t: "audio.set", src: 1 });
try { if (S.audioCtx && S.audioCtx.state !== "running") S.audioCtx.resume(); } catch (e) {}
const lbl = $("#audioLbl"); if (lbl) lbl.textContent = "🔊 System";
const b = $("#btnAudio"); if (b) b.classList.add("active");
}
// Reconnected with unfinished downloads? Re-request only the missing ranges.
try { resumeTransfers(); } catch (e) {}
if (reconfigure) {
// new dimensions: rebuild decoder and wait for the next keyframe
S.started = false;
S.sps = null;
S.pps = null;
if (S.decoder) { try { S.decoder.close(); } catch {} S.decoder = null; }
send({ t: "keyframe" });
}
}
/* ------------------------------------------------------------- video decode */
function splitNals(bytes) {
// find 00 00 01 start codes (00 00 00 01 tolerated); return NAL bodies
const nals = [];
let i = 0, starts = [];
while (i < bytes.length - 2) {
if (bytes[i] === 0 && bytes[i + 1] === 0 && bytes[i + 2] === 1) {
starts.push(i);
i += 3;
} else i++;
}
for (let k = 0; k < starts.length; k++) {
let s = starts[k];
let bodyStart = s + 3;
let bodyEnd = k + 1 < starts.length ? starts[k + 1] : bytes.length;
// strip extra leading zero from 4-byte start code of the *next* nal
while (bodyEnd > bodyStart && bytes[bodyEnd - 1] === 0) bodyEnd--; // keep zero-padding out
if (bodyEnd > bodyStart) nals.push(bytes.subarray(bodyStart, bodyEnd));
}
return nals;
}
/* ------ frame-arrival jitter -> reported to the host in net.pong for QoS ---- */
function recordFrameJitter() {
const now = performance.now();
if (!S.jitIv) S.jitIv = [];
if (S.jitLast) {
const dt = Math.min(now - S.jitLast, 2000);
S.jitIv.push(dt);
if (S.jitIv.length > 30) S.jitIv.shift();
}
S.jitLast = now;
}
function frameJitterMs() {
const iv = S.jitIv || [];
if (iv.length < 2) return 0;
const mean = iv.reduce((a, b) => a + b, 0) / iv.length;
const mad = iv.reduce((a, b) => a + Math.abs(b - mean), 0) / iv.length;
return Math.round(mad);
}
function onVideo(bytes) {
recordFrameJitter();
const kf = bytes[1] === 1;
const nals = splitNals(bytes.subarray(2));
const au = [];
for (const n of nals) {
const t = n.length ? n[0] & 31 : 0;
if (t === 7) S.sps = n;
else if (t === 8) S.pps = n;
if (t === 1 || t === 5) au.push(n);
}
if (!au.length) return;
if (!S.sps || !S.pps) return;
if (!S.decoder) {
const codec = "avc1." + toHex(S.sps.subarray(1, 4));
try {
S.decoder = new VideoDecoder({
output: (frame) => {
if (S.latestFrame) S.latestFrame.close();
S.latestFrame = frame;
S.framesDecoded++;
},
error: (e) => {
// self-heal (vital on the lossy P2P path): rebuild + ask for an IDR
console.error("decoder", e);
try { S.decoder.close(); } catch {}
S.decoder = null; S.started = false;
send({ t: "keyframe" });
},
});
S.decoder.configure({
codec,
codedWidth: S.dims.w,
codedHeight: S.dims.h,
optimizeForLatency: true,
hardwareAcceleration: "prefer-hardware",
avc: { format: "avc" },
description: avccDescription(S.sps, S.pps),
});
} catch (e) {
showBanner("Cannot start decoder: " + e.message, false);
return;
}
}
if (!S.started && !kf) { // waiting for an IDR: keep asking (rate-limited)
if (now() - (S._kfReqAt || 0) > 400) { S._kfReqAt = now(); send({ t: "keyframe" }); }
return;
}
// Backpressure: if the decoder queue is deep, drop this delta - BUT a
// dropped delta breaks the reference chain, and decoding the next deltas
// against a frame we never saw is exactly the "pixels break" artifact.
// So: resync - stop decoding deltas and request a fresh keyframe.
if ((S.decoder.decodeQueueSize || 0) > 10 && !kf) {
S.started = false;
S._kfReqAt = now();
send({ t: "keyframe" });
return;
}
const data = new Uint8Array(au.reduce((a, n) => a + 4 + n.length, 0));
let o = 0;
for (const n of au) {
data[o++] = (n.length >>> 24) & 255; data[o++] = (n.length >>> 16) & 255;
data[o++] = (n.length >>> 8) & 255; data[o++] = n.length & 255;
data.set(n, o); o += n.length;
}
try {
S.decoder.decode(new EncodedVideoChunk({
type: kf ? "key" : "delta",
timestamp: Math.round(now()),
data,
}));
if (kf) S.started = true;
S.bytesVideo += bytes.length;
} catch (e) {
if (kf) { // config mismatch maybe; rebuild on next keyframe
try { S.decoder.close(); } catch {}
S.decoder = null; S.started = false; send({ t: "keyframe" });
}
}
}
function avccDescription(sps, pps) {
const d = new Uint8Array(11 + sps.length + pps.length);
d[0] = 1;
d[1] = sps[1]; d[2] = sps[2]; d[3] = sps[3];
d[4] = 0xfc | 3;
d[5] = 0xe0 | 1;
d[6] = (sps.length >> 8) & 255; d[7] = sps.length & 255;
d.set(sps, 8);
const p = 8 + sps.length;
d[p] = 1;
d[p + 1] = (pps.length >> 8) & 255; d[p + 2] = pps.length & 255;
d.set(pps, p + 3);
return d;
}
function drawLoop() {
if (S.dead) return;
const c = $("#canvas");
if (S.latestFrame && c.width !== S.dims.w) { c.width = S.dims.w; c.height = S.dims.h; }
if (S.latestFrame) {
const ctx = c.getContext("2d");
ctx.drawImage(S.latestFrame, 0, 0, c.width, c.height);
}
// fps accounting
if (now() - S.lastFpsTick > 1000) {
S.fpsShown = S.framesDecoded;
S.framesDecoded = 0;
S.lastFpsTick = now();
updateStats();
}
requestAnimationFrame(drawLoop);
}
/* ------------------------------------------------------------- audio */
function setupAudio() {
if (!window.AudioDecoder) return;
try {
// Fresh jitter-buffer state for this (re)connect.
audioNextTime = 0;
audioLead = 0.12;
S._audioUnderruns = 0;
// Reuse an existing (possibly already-resumed) context on a decoder rebuild —
// creating a fresh one would start suspended again and lose the gesture.
if (!S.audioCtx) S.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
S.audioDecoder = new AudioDecoder({
output: playAudio,
// A single bad/truncated Opus packet closes the decoder; drop it so the
// next packet rebuilds it (see onAudio) instead of killing audio silently
// for the whole session.
error: () => { try { S.audioDecoder = null; } catch (e) {} },
});
S.audioDecoder.configure({ codec: "opus", sampleRate: 48000, numberOfChannels: 2 });
} catch { S.audioDecoder = null; }
}
let audioNextTime = 0;
let audioLead = 0.12; // adaptive jitter-buffer depth (s); grows on underrun
const AUDIO_LEAD_MAX = 0.45;
function playAudio(ad) {
const ctx = S.audioCtx;
if (!ctx) { ad.close?.(); return; }
if (ctx.state === "suspended") ctx.resume();
const n = ad.numberOfChannels, len = ad.numberOfFrames, rate = ad.sampleRate;
const buf = ctx.createBuffer(n, len, rate);
const planar = (ad.format || "").includes("planar");
for (let ch = 0; ch < n; ch++) {
const dst = buf.getChannelData(ch);
if (planar) ad.copyTo(dst, { planeIndex: ch, format: "f32-planar" });
else {
// planeIndex is REQUIRED by AudioData.copyTo even for interleaved formats;
// omitting it throws "Required member planeIndex is undefined" on EVERY
// frame, which silently killed all web-viewer audio. Chromium decodes opus
// to interleaved "f32", so this branch is the common path.
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];
}
}
ad.close?.();
// Jitter buffer: schedule each 20 ms frame on a running clock that leads the
// context by `audioLead`. Relay/network timing is uneven, so too small a lead
// underruns (gaps/crackle). We keep frames seamlessly back-to-back and, on an
// underrun, GROW the lead so the stream self-heals toward smooth; if latency
// creeps too far ahead we resync down.
const now = ctx.currentTime;
if (audioNextTime === 0) {
audioNextTime = now + audioLead; // first frame: prime
} else if (audioNextTime < now) {
audioLead = Math.min(audioLead + 0.04, AUDIO_LEAD_MAX); // underrun: deepen buffer
audioNextTime = now + audioLead;
S._audioUnderruns = (S._audioUnderruns || 0) + 1;
} else if (audioNextTime > now + audioLead + 0.35) {
audioNextTime = now + audioLead; // drifted ahead: resync
}
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
src.start(audioNextTime);
audioNextTime += len / rate;
}
function onAudio(bytes) {
// Rebuild the decoder if a prior decode error dropped it (error cb nulls it).
if (!S.audioDecoder) {
if (!window.AudioDecoder) return;
setupAudio();
if (!S.audioDecoder) return;
}
try {
S.audioDecoder.decode(new EncodedAudioChunk({
type: "key",
timestamp: Math.round(now()),
data: bytes.subarray(1),
}));
} catch {}
}
/* ------------------------------------------------------------- voice call */
// Two-way voice: capture THIS browser's microphone, Opus-encode it (WebCodecs),
// and send each packet as [CHAN_VOICE=0xa2][opus] (E2E-sealed like everything
// else). The host decodes it and plays it on its speakers. We also flip the
// host's audio source to its mic so you hear the person there — a real call.
async function startVoice() {
if (S.voiceOn) return;
if (!window.AudioEncoder || !window.MediaStreamTrackProcessor) {
showBanner("Voice call needs a Chromium-based browser (WebCodecs).", true, 2800);
return;
}
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 },
});
} catch (e) { showBanner("Microphone permission denied — can't start the call.", true, 2800); return; }
S.voiceStream = stream;
const track = stream.getAudioTracks()[0];
S.voiceTrack = track;
S.voiceOn = true;
const enc = new AudioEncoder({
output: (chunk) => {
try {
const tmp = new Uint8Array(chunk.byteLength);
chunk.copyTo(tmp);
const buf = new Uint8Array(tmp.length + 1);
buf[0] = 0xa2; // CHAN_VOICE
buf.set(tmp, 1);
sendBinary(buf);
} catch (e) {}
},
error: () => {},
});
S.voiceEncoder = enc;
// Hear the other side: if host audio is off, switch it to the host mic.
if (!S.audioSrc) {
if (!S.audioDecoder) setupAudio();
try { if (S.audioCtx && S.audioCtx.state !== "running") await S.audioCtx.resume(); } catch (e) {}
S.audioSrc = 2;
send({ t: "audio.set", src: 2 });
const lbl = $("#audioLbl"); if (lbl) lbl.textContent = "🎤 Mic";
const b = $("#btnAudio"); if (b) b.classList.add("active");
}
const reader = new MediaStreamTrackProcessor({ track }).readable.getReader();
S.voiceReader = reader;
let configured = false;
(async () => {
while (S.voiceOn) {
let res;
try { res = await reader.read(); } catch (e) { break; }
if (res.done) break;
const ad = res.value;
try {
if (!configured) {
// Opus handles rate internally, so encoding at the mic's native rate is
// fine — the host's 48 kHz decoder reproduces it correctly.
enc.configure({ codec: "opus", sampleRate: ad.sampleRate, numberOfChannels: ad.numberOfChannels, bitrate: 48000 });
configured = true;
}
if (enc.state === "configured") enc.encode(ad);
} catch (e) {}
ad.close();
}
})();
updateVoiceUi();
showBanner("Voice call started — speak into your mic.", true, 2000);
}
function stopVoice() {
if (!S.voiceOn && !S.voiceStream) { updateVoiceUi(); return; }
S.voiceOn = false;
try { if (S.voiceReader) S.voiceReader.cancel(); } catch (e) {}
try { if (S.voiceStream) S.voiceStream.getTracks().forEach((t) => t.stop()); } catch (e) {}
try { if (S.voiceEncoder && S.voiceEncoder.state !== "closed") S.voiceEncoder.close(); } catch (e) {}
S.voiceReader = S.voiceTrack = S.voiceStream = S.voiceEncoder = null;
updateVoiceUi();
}
function updateVoiceUi() {
const mi = $("#miVoice");
if (!mi) return;
mi.classList.toggle("on", !!S.voiceOn);
const txt = mi.childNodes[0];
const label = S.voiceOn ? "End voice call" : "Start voice call (your mic)";
if (txt && txt.nodeType === 3) txt.nodeValue = label; else mi.textContent = label;
}
/* ------------------------------------------------------------- input */
function canvasPos(e) {
// canvas is letterboxed (object-fit: contain): map into the drawn image rect
const c = $("#canvas");
const r = c.getBoundingClientRect();
const iw = S.dims.w || 1, ih = S.dims.h || 1;
const k = Math.min(r.width / iw, r.height / ih) || 1;
const dw = iw * k, dh = ih * k;
const ox = r.left + (r.width - dw) / 2, oy = r.top + (r.height - dh) / 2;
return {
x: Math.max(0, Math.min(iw - 1, Math.round((e.clientX - ox) / k))),
y: Math.max(0, Math.min(ih - 1, Math.round((e.clientY - oy) / k))),
};
}
// button-bit map: left=1, right=2, middle=4, back(X1)=8, forward(X2)=16
function mouseBtnBit(b) {
return b === 0 ? 1 : b === 2 ? 2 : b === 1 ? 4 : b === 3 ? 8 : b === 4 ? 16 : 0;
}
const canvas = $("#canvas");
canvas.addEventListener("pointermove", (e) => {
if (S.viewOnly || S.dead) return;
if (e.pointerType === "touch") return touchMove(e);
const p = canvasPos(e);
S.pendingMouse = { x: p.x, y: p.y, b: S.buttons, w: 0, hw: 0 };
localCursorEcho(p);
});
// Local cursor echo (RustDesk-style): draw the remote cursor SHAPE at YOUR
// pointer position instantly instead of waiting for the host to echo it back.
// On a long link the echo lags by RTT, which made the cursor feel slow-motion.
function localCursorEcho(p) {
S.curPos = { x: p.x, y: p.y };
S._localPos = S.curPos;
S._localCurTs = performance.now();
if (nativeCur()) {
// the OS draws the pointer (with the remote shape, see applyCursorShape):
// it never waits on the page, so it stays crisp even while a slow link
// keeps the main thread busy decoding. Keep the overlay out of the way.
const rc = $("#remoteCursor");
if (rc && !rc.hidden) { rc.hidden = true; $("#stage").classList.remove("remote-cursor"); }
return;
}
if (S.curId == null || !(S.curShapes || {})[S.curId]) return;
const rc = $("#remoteCursor");
if (rc) {
positionCursor();
rc.hidden = false;
$("#stage").classList.add("remote-cursor");
}
}
// mouse/trackpad clients get the shape as the real OS cursor; touch clients
// and view-only keep the JS overlay (there is no pointer to dress up)
function nativeCur() { return !IS_TOUCH_CLIENT && !S.viewOnly; }
canvas.addEventListener("pointerdown", (e) => {
canvas.focus();
if (S.viewOnly || S.dead) return;
if (e.pointerType === "touch") return touchDown(e);
// capture the pointer so a drag that leaves the canvas keeps tracking and the
// button-up is never lost (fixes "drag freezes / stuck button" past the edge)
try { canvas.setPointerCapture(e.pointerId); } catch {}
S.buttons |= mouseBtnBit(e.button);
const p = canvasPos(e);
send({ t: "i.mouse", x: p.x, y: p.y, b: S.buttons, w: 0, hw: 0 });
});
function pointerRelease(e) {
if (S.viewOnly || S.dead) return;
if (e.pointerType === "touch") return touchUp(e);
try { canvas.releasePointerCapture(e.pointerId); } catch {}
S.buttons &= ~mouseBtnBit(e.button);
const p = canvasPos(e);
send({ t: "i.mouse", x: p.x, y: p.y, b: S.buttons, w: 0, hw: 0 });
}
canvas.addEventListener("pointerup", pointerRelease);
canvas.addEventListener("pointercancel", (e) => {
if (e && e.pointerType === "touch") return touchUp(e, true);
if (S.buttons) { S.buttons = 0; send({ t: "i.mouse", ...(S.pendingMouse || { x: 0, y: 0 }), b: 0, w: 0, hw: 0 }); }
});
/* ---------------- touch: TOUCHPAD mode for phones/tablets (RustDesk-style)
drag = move the cursor · tap = left click · double-tap = double click ·
double-tap & hold = drag · hold still ~0.6s = right click · 2 fingers = scroll */
const T = { pts: new Map(), mode: null, lpTimer: null, lastMidY: 0, scrollAcc: 0,
vx: 0, vy: 0, vInit: false, sid: null, lastX: 0, lastY: 0,
lastTapT: 0, moved: false, downT: 0 };
function tClearLP() { if (T.lpTimer) { clearTimeout(T.lpTimer); T.lpTimer = null; } }
function tScale() {
const r = canvas.getBoundingClientRect();
return Math.min(r.width / (S.dims.w || 1), r.height / (S.dims.h || 1)) || 1;
}
function tCursorInit() {
if (T.sid !== S.sid) { T.vInit = false; T.sid = S.sid; try { zoomReset(); } catch (e) {} }
if (T.vInit) return;
if (S.curPos) { T.vx = S.curPos.x; T.vy = S.curPos.y; }
else { T.vx = (S.dims.w || 2) / 2; T.vy = (S.dims.h || 2) / 2; }
T.vInit = true;
}
function tXY() { return { x: Math.round(T.vx), y: Math.round(T.vy) }; }
function tSendMove() {
const p = tXY();
S.pendingMouse = { x: p.x, y: p.y, b: S.buttons, w: 0, hw: 0 };
localCursorEcho(p);
try { followCursor(); } catch (e) {} // zoomed view pans with the cursor
}
function tapClick(btnBit) {
const p = tXY();
send({ t: "i.mouse", x: p.x, y: p.y, b: btnBit, w: 0, hw: 0 });
setTimeout(() => send({ t: "i.mouse", x: p.x, y: p.y, b: 0, w: 0, hw: 0 }), 60);
}
function touchDown(e) {
try { canvas.setPointerCapture(e.pointerId); } catch {}
T.pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (T.pts.size === 1) {
tCursorInit();
T.lastX = e.clientX; T.lastY = e.clientY;
T.moved = false; T.downT = performance.now();
if (performance.now() - T.lastTapT < 320) {
// double-tap & hold: press the left button at the cursor and drag
T.mode = "drag";
S.buttons |= 1;
const p = tXY();
send({ t: "i.mouse", x: p.x, y: p.y, b: S.buttons, w: 0, hw: 0 });
} else {
T.mode = "move";
tClearLP();
T.lpTimer = setTimeout(() => {
if (T.mode === "move" && !T.moved && T.pts.size === 1) {
T.mode = "rclick";
tapClick(2);
if (navigator.vibrate) try { navigator.vibrate(25); } catch {}
}
}, 600);
}
} else if (T.pts.size === 2) {
tClearLP();
if (T.mode === "drag") {
S.buttons &= ~1;
const p = tXY();
send({ t: "i.mouse", x: p.x, y: p.y, b: S.buttons, w: 0, hw: 0 });
}
T.mode = "two"; // undecided: becomes "scroll" or "zoom" by motion
const ps = [...T.pts.values()];
T.lastMidY = (ps[0].y + ps[1].y) / 2;
T.lastDist = Math.hypot(ps[0].x - ps[1].x, ps[0].y - ps[1].y);
T.scrollAcc = 0;
}
}
function touchMove(e) {
const rec = T.pts.get(e.pointerId);
if (!rec) return;
const dx = e.clientX - rec.x, dy = e.clientY - rec.y;
rec.x = e.clientX; rec.y = e.clientY;
if ((T.mode === "two" || T.mode === "scroll" || T.mode === "zoom") && T.pts.size >= 2) {
const ps = [...T.pts.values()];
const mid = (ps[0].y + ps[1].y) / 2;
const dist = Math.hypot(ps[0].x - ps[1].x, ps[0].y - ps[1].y);
const dMid = mid - T.lastMidY, dDist = dist - T.lastDist;
if (T.mode === "two") { // decide the gesture once, then commit
if (Math.abs(dDist) > Math.abs(dMid) * 1.3 && Math.abs(dDist) > 6) T.mode = "zoom";
else if (Math.abs(dMid) > 6) T.mode = "scroll";
}
if (T.mode === "zoom") {
const mx = (ps[0].x + ps[1].x) / 2, my = (ps[0].y + ps[1].y) / 2;
zoomBy(dist / (T.lastDist || dist), mx, my);
T.lastDist = dist; T.lastMidY = mid;
return;
}
if (T.mode === "scroll") {
T.scrollAcc += dMid;
T.lastMidY = mid; T.lastDist = dist;
while (Math.abs(T.scrollAcc) >= 26) {
const w = T.scrollAcc > 0 ? 1 : -1;
T.scrollAcc -= w * 26;
const p = tXY();
send({ t: "i.mouse", x: p.x, y: p.y, b: S.buttons, w: w, hw: 0 });
}
return;
}
T.lastMidY = mid; T.lastDist = dist;
return;
}
if (T.mode !== "move" && T.mode !== "drag") return;
if (Math.abs(dx) + Math.abs(dy) >= 1) { T.moved = true; tClearLP(); }
const k = tScale();
T.vx = Math.max(0, Math.min((S.dims.w || 1) - 1, T.vx + (dx / k) * 1.15));
T.vy = Math.max(0, Math.min((S.dims.h || 1) - 1, T.vy + (dy / k) * 1.15));
tSendMove();
}
function touchUp(e, cancelled) {
try { canvas.releasePointerCapture(e.pointerId); } catch {}
T.pts.delete(e.pointerId);
tClearLP();
if (T.mode === "move" && !cancelled && !T.moved &&
performance.now() - T.downT < 300 && T.pts.size === 0) {
tapClick(1);
T.lastTapT = performance.now(); // arms double-tap / tap-drag
} else if (T.mode === "drag" && T.pts.size === 0) {
S.buttons &= ~1;
const p = tXY();
send({ t: "i.mouse", x: p.x, y: p.y, b: S.buttons, w: 0, hw: 0 });
}
if (T.pts.size === 0) T.mode = null;
}
// Mac trackpads emit many tiny pixel deltas per two-finger swipe; sending
// them raw truncated to zero on the host and most Windows apps ignore
// sub-notch wheel values anyway. Accumulate and emit WHOLE notches (±120
// on the host side), carrying the remainder - trackpad now scrolls like a
// real wheel, and fast flicks scroll fast.
let wheelAccY = 0, wheelAccX = 0;
canvas.addEventListener("wheel", (e) => {
if (S.viewOnly || S.dead) return;
e.preventDefault();
const p = canvasPos(e);
const unit = e.deltaMode === 1 ? 3 : e.deltaMode === 2 ? 1 : 100;
wheelAccY += -e.deltaY / unit;
wheelAccX += e.deltaX / unit;
// half-notch granularity (host sends ±60): same stream a Windows Precision
// Touchpad produces, so apps scroll smoothly and gentle swipes respond fast
const w = Math.trunc(wheelAccY * 2) / 2;
const hw = Math.trunc(wheelAccX * 2) / 2;
if (w || hw) {
wheelAccY -= w;
wheelAccX -= hw;
send({ t: "i.mouse", x: p.x, y: p.y, b: S.buttons,
w: Math.max(-10, Math.min(10, w)), hw: Math.max(-10, Math.min(10, hw)) });
}
}, { passive: false });
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
// back/forward mouse buttons fire 'auxclick'; also stop the browser navigating
canvas.addEventListener("auxclick", (e) => { if (e.button === 3 || e.button === 4) e.preventDefault(); });
function flushMouse() {
if (!S.pendingMouse || S.dead || S.viewOnly) return;
// adapt the move rate to the link: flooding a 300ms+ mobile link with
// 120 msg/s just builds a queue and replays your hand in slow motion
const gap = S.rtt > 400 ? 50 : S.rtt > 200 ? 33 : S.rtt > 100 ? 16 : 8;
const now = performance.now();
if (now - (S._lastMoveSend || 0) < gap - 1) return;
S._lastMoveSend = now;
send({ t: "i.mouse", ...S.pendingMouse, b: S.buttons, _mv: true }); // _mv: pure motion → unreliable channel
S.pendingMouse = null;
}
// Keyboard is captured at the WINDOW level (capture phase), not the canvas, so
// it keeps working in fullscreen and after you click any toolbar button - the
// canvas losing focus no longer kills the keyboard (a major "keys don't work"
// cause). We only skip when the session isn't showing or you're typing into a
// real form field (chat, file path, connect screen).
function keyGoesToRemote() {
if (S.viewOnly || S.dead) return false;
if ($("#screenSession").hidden) return false;
const el = document.activeElement, tag = (el && el.tagName) || "";
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || (el && el.isContentEditable))
return false;
return true;
}
window.addEventListener("keydown", (e) => {
if (e.code === "F11") return; // let the browser toggle fullscreen
if (!keyGoesToRemote()) return;
// Mac client: let Cmd+V fall through untouched so the browser fires the
// 'paste' event below - we then push the Mac clipboard and paste remotely.
// (Works even in Safari, which blocks background clipboard polling.)
if (IS_MAC_CLIENT && e.metaKey && e.code === "KeyV" && !e.altKey && !e.shiftKey) return;
e.preventDefault(); // keep Ctrl+C/V/A/W/Tab etc. from hitting the browser
const kc = mapKey(e.code);
S.heldKeys.add(kc);
send({ t: "i.key", c: kc, d: true });
}, true);
window.addEventListener("keyup", (e) => {
if (S.dead) return;
const kc = mapKey(e.code);
const had = S.heldKeys.delete(kc);
if (!had && !keyGoesToRemote()) return; // still release keys we had pressed
e.preventDefault();
send({ t: "i.key", c: kc, d: false });
}, true);
// Mac keyboards: Cmd acts as Ctrl on the remote Windows PC (RustDesk-style),
// so Cmd+C / Cmd+V / Cmd+A / Cmd+Z do what a Mac user expects instead of
// hitting the Windows key.
const IS_MAC_CLIENT = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
function mapKey(code) {
if (IS_MAC_CLIENT) {
if (code === "MetaLeft") return "ControlLeft";
if (code === "MetaRight") return "ControlRight";
}
return code;
}
// Gesture-based paste path (Safari-proof): grab the pasted text directly from
// the event, push it to the remote clipboard, then tap Ctrl+V over there.
window.addEventListener("paste", (e) => {
if (!keyGoesToRemote()) return;
const txt = e.clipboardData && e.clipboardData.getData("text");
if (!txt) return;
e.preventDefault();
S.lastRemoteClip = txt;
send({ t: "clip.set", text: txt, origin: "viewer" });
setTimeout(() => {
send({ t: "i.key", c: "ControlLeft", d: true });
send({ t: "i.key", c: "KeyV", d: true });
send({ t: "i.key", c: "KeyV", d: false });
send({ t: "i.key", c: "ControlLeft", d: false });
}, 140);
}, true);
// release every held key when focus leaves / tab hides, so modifiers never get
// "stuck" down on the remote (classic RustDesk/TeamViewer issue after Alt+Tab)
window.addEventListener("blur", releaseAllKeys);
document.addEventListener("visibilitychange", () => { if (document.hidden) releaseAllKeys(); });
document.addEventListener("fullscreenchange", () => {
const c = $("#canvas"); if (c && !$("#screenSession").hidden) c.focus();
});
function releaseAllKeys() {
if (!S.heldKeys || !S.heldKeys.size) return;
for (const code of S.heldKeys) send({ t: "i.key", c: code, d: false });
S.heldKeys.clear();
}
/* ------------------------------------------------------------- toolbar */
$("#btnDisconnect").onclick = () => {
// an EXPLICIT disconnect: don't auto-resume afterwards
S.userEnd = true;
try { localStorage.removeItem("nd.resume"); } catch {}
try { S.ws.close(); } catch {}
};
$("#selMonitor").addEventListener("change", () => {
localStorage.setItem("nd.lastmon", $("#selMonitor").value);
});
$("#selQuality").addEventListener("change", () => {
localStorage.setItem("nd.lastq", $("#selQuality").value);
});
const _origQChange = $("#selQuality").onchange;
$("#selQuality").onchange = (e) => {
localStorage.setItem("nd.lastq", $("#selQuality").value);
if (_origQChange) _origQChange(e);
};
$("#selMonitor").onchange = () => send({ t: "monitor.switch", mon: parseInt($("#selMonitor").value, 10) });
function setViewOnly(on) {
S.viewOnly = on;
$("#btnViewOnly span").textContent = on ? "Input off" : "Input on";
$("#btnViewOnly").classList.toggle("active", on);
canvas.classList.toggle("viewonly", on);
if (S.curId != null) applyCursorShape(S.curId); // native cursor on/off with input
// HOST-ENFORCED: the engine drops input while on (defense in depth under
// this local toggle — a modified client couldn't inject either).
if (!S.dead) send({ t: "view.only", on });
}
$("#btnViewOnly").onclick = () => {
if (S.inputDeniedByHost) return; // host policy wins
setViewOnly(!S.viewOnly);
};
$("#btnFullscreen").onclick = async () => {
try {
if (document.fullscreenElement) await document.exitFullscreen();
else await $("#stage").requestFullscreen();
} catch {}
};
$("#btnKbdLock").onclick = async () => {
if (S.kbdLocked) {
try { await navigator.keyboard.unlock(); } catch {}
S.kbdLocked = false;
} else {
try {
if (!document.fullscreenElement) await $("#stage").requestFullscreen();
await navigator.keyboard.lock();
S.kbdLocked = true;
showBanner("Keyboard locked: system keys (Alt+Tab, Win, Ctrl+W) now go to the remote PC.", true, 2600);
} catch {
showBanner("Keyboard lock needs Chrome/Edge and fullscreen.", false, 2600);
}
}
$("#btnKbdLock").classList.toggle("active", S.kbdLocked);
};
/* clipboard sync */
$("#btnClipboard").onclick = async () => {
S.clipSync = !S.clipSync;
$("#btnClipboard").classList.toggle("active", S.clipSync);
if (S.clipSync) {
try { await navigator.clipboard.readText(); } // triggers the permission prompt
catch { showBanner("Clipboard permission denied by browser.", false, 2200); }
}
};
async function onRemoteClip(m) {
S.lastRemoteClip = m.text || "";
if (S.clipSync && m.text) {
try { await navigator.clipboard.writeText(m.text); } catch {}
}
}
// The host copied an IMAGE: write it to the browser clipboard as a PNG
// ClipboardItem so the user can paste it locally. Requires the clipboard
// permission + a secure context (netdesk.space is https), and a recent
// browser; failures are silent (the feature degrades, nothing breaks).
async function onRemoteClipImage(m) {
if (!S.clipSync || !m.png) return;
try {
const bytes = b64ToBytes(m.png);
const blob = new Blob([bytes], { type: "image/png" });
if (navigator.clipboard && window.ClipboardItem) {
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
}
} catch {}
}
/* ------------------------------------------------------------- remote cursor overlay */
// fallback arrow for the touch overlay when the host could only name the cursor
const ARROW_SVG = "data:image/svg+xml;utf8," + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">' +
'<path d="M3 2l7 18 2.5-7.5L20 10z" fill="#fff" stroke="#000" stroke-width="1.5" stroke-linejoin="round"/></svg>');
function onCursorShape(m) {
S.curShapes = S.curShapes || {};
// png = exact bitmap (custom cursors); css = a standard cursor's name, which the
// OS pointer renders natively and which arrives even when the bitmap can't
S.curShapes[m.id] = { url: m.png ? "data:image/png;base64," + m.png : ARROW_SVG,
w: m.w || 24, h: m.h || 24, hx: m.hx || 0, hy: m.hy || 0, css: m.css || null };
if (S.curId === m.id) applyCursorShape(m.id);
}
function onCursorPos(m) {
const el = $("#remoteCursor");
if (!el) return;
if (m.hidden) { el.hidden = true; $("#stage").classList.remove("remote-cursor"); return; }
if (S.curId !== m.id) {
S.curId = m.id;
if ((S.curShapes || {})[m.id]) applyCursorShape(m.id);
}
// while the local pointer is actively driving, ignore the host's laggy
// position echoes (they arrive ~RTT late) - shape/id updates were kept above
if (S._localCurTs && performance.now() - S._localCurTs < 400) return;
if (nativeCur()) {
// someone at the remote PC moved its mouse: show where it went as an
// overlay next to our own (parked) pointer - but never as a duplicate of
// our pointer sitting right there
const lp = S._localPos;
const far = !lp || Math.abs(lp.x - m.x) + Math.abs(lp.y - m.y) > 12;
const shp = (S.curShapes || {})[m.id];
const onScreen = m.x >= 0 && m.y >= 0 && m.x < (S.dims.w || 1) && m.y < (S.dims.h || 1);
if (!far || !shp || !onScreen) { el.hidden = true; return; }
S.curPos = { x: m.x, y: m.y };
if (el.dataset.id != String(m.id)) applyCursorShape(m.id);
positionCursor();
el.hidden = false; // OS pointer stays visible (no remote-cursor class)
return;
}
S.curPos = { x: m.x, y: m.y };
// hide the overlay when the cursor is off the monitor currently being viewed
if (m.x < 0 || m.y < 0 || m.x >= (S.dims.w || 1) || m.y >= (S.dims.h || 1)) {
el.hidden = true; $("#stage").classList.remove("remote-cursor"); return;
}
const shp = (S.curShapes || {})[m.id];
if (!shp) return; // shape not arrived yet; wait for cursor.shape
if (el.dataset.id != String(m.id)) applyCursorShape(m.id);
positionCursor();
el.hidden = false;
$("#stage").classList.add("remote-cursor"); // hide the local cursor over the canvas
}
function applyCursorShape(id) {
const shp = (S.curShapes || {})[id]; const el = $("#remoteCursor");
if (!shp || !el) return;
el.src = shp.url; el.dataset.id = String(id);
// fine pointer: the remote shape becomes the real OS cursor (Chrome caps
// custom cursors at 128px; bigger ones fall back to the normal arrow)
if (!IS_TOUCH_CLIENT) {
const bitmap = shp.url.startsWith("data:image/png") && shp.w <= 128 && shp.h <= 128;
canvas.style.cursor = S.viewOnly ? "" : shp.css ? shp.css
: bitmap ? "url(" + shp.url + ") " + shp.hx + " " + shp.hy + ", auto" : "";
}
}
function positionCursor() {
const el = $("#remoteCursor"); if (!el) return;
const shp = (S.curShapes || {})[S.curId]; if (!shp || !S.curPos) return;
const c = $("#canvas"), st = $("#stage");
const r = c.getBoundingClientRect(), sr = st.getBoundingClientRect();
const iw = S.dims.w || 1, ih = S.dims.h || 1;
const k = Math.min(r.width / iw, r.height / ih) || 1; // image px -> display px
const ox = r.left + (r.width - iw * k) / 2, oy = r.top + (r.height - ih * k) / 2;
const px = ox + S.curPos.x * k, py = oy + S.curPos.y * k;
// in Original (1:1) mode the stage scrolls; getBoundingClientRect already
// subtracts scroll, so add it back once to place the overlay in content space
el.style.left = (px - sr.left + st.scrollLeft - shp.hx * k) + "px";
el.style.top = (py - sr.top + st.scrollTop - shp.hy * k) + "px";
el.style.width = (shp.w * k) + "px";
el.style.height = (shp.h * k) + "px";
}
// keep the overlay aligned when the window/layout changes
window.addEventListener("resize", () => { if (!$("#screenSession").hidden) positionCursor(); });
const IS_TOUCH_CLIENT = window.matchMedia && matchMedia("(pointer: coarse)").matches;
setInterval(async () => {
if (!S.clipSync || S.dead || !document.hasFocus()) return;
// iOS shows its native "Paste" permission bubble on EVERY programmatic
// clipboard read - polling made it pop nonstop. Touch devices use the
// gesture paste path (long-press → Paste in a text field, or the ⌨ panel).
if (IS_TOUCH_CLIENT) return;
try {
const txt = await navigator.clipboard.readText();
if (txt && txt !== S.lastRemoteClip) {
S.lastRemoteClip = txt;
send({ t: "clip.set", text: txt, origin: "viewer" });
}
} catch {}
}, 900);
/* panels */
function bindPanel(btn, panel, onClose) {
$(btn).onclick = () => {
const p = $(panel);
const show = p.hidden;
for (const q of ["#filePanel", "#chatPanel", "#sysPanel"]) $(q).hidden = true;
p.hidden = !show;
if (show && onClose) onClose();
};
}
bindPanel("#btnFiles", "#filePanel", () => send({ t: "file.roots" }));
bindPanel("#btnChat", "#chatPanel", () => { S.chatOpen = true; $("#chatText").focus(); });
bindPanel("#btnSys", "#sysPanel", () => {
send({ t: "sys.cmd", a: "processes" });
$("#chkLockOnEnd").checked = !!(S.info && S.info.lock_on_end);
});
$("#btnFilesClose").onclick = () => $("#filePanel").hidden = true;
$("#btnChatClose").onclick = () => { $("#chatPanel").hidden = true; S.chatOpen = false; };
$("#btnSysClose").onclick = () => $("#sysPanel").hidden = true;
/* chat */
function onChat(text, mine) {
const log = $("#chatLog");
const div = document.createElement("div");
div.className = "chat-msg" + (mine ? " me" : "");
const who = document.createElement("div");
who.className = "who";
who.textContent = mine ? "You" : "Host";
const body = document.createElement("div");
body.textContent = text;
div.append(who, body);
log.append(div);
log.scrollTop = log.scrollHeight;
}
function sendChat() {
const txt = $("#chatText").value.trim();
if (!txt) return;
$("#chatText").value = "";
send({ t: "chat", text: txt, ts: Date.now() });
onChat(txt, true);
}
$("#chatSend").onclick = sendChat;
$("#chatText").addEventListener("keydown", (e) => {
e.stopPropagation();
if (e.key === "Enter") sendChat();
});
/* power */
for (const b of document.querySelectorAll("#sysPanel [data-cmd]")) {
b.onclick = () => {
const cmd = b.dataset.cmd;
if (cmd === "reboot" || cmd === "shutdown") {
if (!confirm(`Really ${cmd} the remote computer?`)) return;
}
send({ t: "sys.cmd", a: cmd });
};
}
$("#btnSysRun").onclick = () => {
const c = $("#sysCmd").value.trim();
if (c) send({ t: "sys.cmd", a: "run", cmd: c });
};
/* lock-on-disconnect */
$("#chkLockOnEnd").onchange = () =>
send({ t: "set.cfg", lock_on_end: $("#chkLockOnEnd").checked });
/* privacy mode (monitor off) */
$("#btnPrivacy").onclick = () => send({ t: "sys.cmd", a: "privacy" });
/* process manager */
$("#btnProcRefresh").onclick = () => send({ t: "sys.cmd", a: "processes" });
function renderProcesses(items) {
const box = $("#procList");
box.innerHTML = "";
for (const p of items || []) {
const row = document.createElement("div");
row.className = "file-row";
const icon = document.createElement("span");
icon.textContent = "⚙";
const name = document.createElement("span");
name.className = "name";
name.textContent = p.name;
const meta = document.createElement("span");
meta.className = "meta";
meta.textContent = `${p.pid} · ${(p.mem / 1024).toFixed(0)} MB`;
const kill = document.createElement("span");
kill.className = "acts";
kill.style.display = "flex";
const btn = document.createElement("button");
btn.textContent = "⛔";
btn.title = "End process";
btn.onclick = (e) => {
e.stopPropagation();
if (confirm(`End ${p.name} (PID ${p.pid})?`)) {
send({ t: "sys.cmd", a: "kill", pid: p.pid });
setTimeout(() => send({ t: "sys.cmd", a: "processes" }), 700);
}
};
kill.append(btn);
row.append(icon, name, meta, kill);
box.append(row);
}
}
/* TCP tunnel (portal-gated): raw TCP to a host port over the session server */
let tnlWs = null, tnlBytes = 0;
$("#btnTnlOpen").onclick = () => {
const st = $("#tnlStatus");
if (tnlWs) { tnlWs.close(); tnlWs = null; st.textContent = "closed"; return; }
const port = parseInt($("#tnlPort").value, 10);
if (!port) { st.textContent = "enter a port"; return; }
const proto = location.protocol === "https:" ? "wss" : "ws";
st.textContent = "connecting…";
const ws = new WebSocket(`${proto}://${location.host}/tunnel/${S.hostId}`);
tnlWs = ws;
ws.binaryType = "arraybuffer";
ws.onopen = () => { ws.send(JSON.stringify({ t: "open", port })); };
ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
const m = JSON.parse(ev.data);
if (m.t === "tunnel.ok") st.textContent = "open ✓ " + fmtBytes(tnlBytes);
if (m.t === "tunnel.err") st.textContent = "error: " + m.msg;
if (m.t === "tunnel.closed") st.textContent = "closed";
return;
}
tnlBytes += ev.data.byteLength;
st.textContent = "open ✓ " + fmtBytes(tnlBytes) + " from host";
};
ws.onclose = () => { if (tnlWs === ws) { tnlWs = null; st.textContent = "disconnected"; } };
ws.onerror = () => { st.textContent = "unauthorized? sign in to the portal"; };
};
/* panel behavior: ESC closes, headers drag (TeamViewer-style floaters) */
window.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
let closed = false;
for (const q of ["#filePanel", "#chatPanel", "#sysPanel"]) {
const p = $(q);
if (p && !p.hidden) { p.hidden = true; closed = true; }
}
if (closed) e.stopPropagation();
});
document.querySelectorAll(".panel").forEach((p) => {
const head = p.querySelector(".panel-head");
if (!head) return;
let dragArmed = false, dx = 0, dy = 0, moved = false;
head.addEventListener("mousedown", (e) => {
if (e.target.closest("button")) return;
dragArmed = true; moved = false;
const r = p.getBoundingClientRect();
p.style.left = r.left + "px";
p.style.top = r.top + "px";
p.style.right = "auto";
dx = e.clientX - r.left; dy = e.clientY - r.top;
const move = (ev) => {
if (!dragArmed) return;
moved = true;
p.style.left = Math.min(window.innerWidth - 60, Math.max(60 - r.width, ev.clientX - dx)) + "px";
p.style.top = Math.min(window.innerHeight - 40, Math.max(0, ev.clientY - dy)) + "px";
};
const up = () => {
dragArmed = false;
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
e.preventDefault();
});
head.addEventListener("click", (e) => {
if (e.target.closest("button")) return;
if (moved) return; // it was a drag, not a click
if (p.classList.contains("docked")) p.classList.remove("docked");
});
// minimize buttons
const minBtn = head.querySelector("[data-min]");
if (minBtn) minBtn.addEventListener("click", (e) => {
e.stopPropagation();
p.classList.add("docked");
});
// clicking a docked panel restores it
p.addEventListener("click", () => {
if (p.classList.contains("docked")) p.classList.remove("docked");
});
});
/* session recording (client-side, WebM) */
const REC_ICON_IDLE = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4" fill="currentColor" stroke="none"/></svg>';
const REC_ICON_ACTIVE = '<svg class="ic" viewBox="0 0 24 24" fill="currentColor" stroke="none"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
let recorder = null, recChunks = [];
// host audio source cycle: 0 off -> 1 system ("what you hear") -> 2 microphone
S.audioSrc = 0;
if ($("#btnAudio")) $("#btnAudio").onclick = async () => {
// Make sure the decoder exists and the AudioContext is RUNNING — browsers keep
// it suspended until a user gesture resumes it, so a resume() from inside
// playAudio (no gesture) is often ignored and you get silence. This click IS
// the gesture, so resume here.
if (!S.audioDecoder) setupAudio();
try { if (S.audioCtx && S.audioCtx.state !== "running") await S.audioCtx.resume(); } catch (e) {}
S.audioSrc = (S.audioSrc + 1) % 3;
send({ t: "audio.set", src: S.audioSrc });
const names = ["Audio", "🔊 System", "🎤 Mic"];
$("#audioLbl").textContent = names[S.audioSrc];
$("#btnAudio").classList.toggle("active", S.audioSrc !== 0);
showBanner("Host audio: " + (S.audioSrc === 0 ? "off (click for system sound)" : S.audioSrc === 1 ? "system sound ON" : "microphone ON"), true, 1800);
};
// Browsers keep a freshly created AudioContext suspended until the user
// interacts with the page. Because you actively control the host (mouse/keys on
// the canvas), ANY of those gestures is enough — resume the audio engine on the
// first one so remote sound (auto-requested on connect) starts playing without
// needing a separate, specific button click.
["pointerdown", "mousedown", "keydown", "touchstart"].forEach((evt) =>
window.addEventListener(
evt,
() => { try { if (S.audioCtx && S.audioCtx.state === "suspended") S.audioCtx.resume(); } catch (e) {} },
{ capture: true, passive: true }
)
);
$("#btnRec").onclick = () => {
if (recorder) {
recorder.stop();
return;
}
try {
const stream = $("#canvas").captureStream(30);
recChunks = [];
recorder = new MediaRecorder(stream, { mimeType: "video/webm" });
recorder.ondataavailable = (e) => { if (e.data.size) recChunks.push(e.data); };
recorder.onstop = () => {
const blob = new Blob(recChunks, { type: "video/webm" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `netdesk-${S.hostId}-${new Date().toISOString().replace(/[:.]/g, "-")}.webm`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 30000);
recorder = null;
$("#btnRec").classList.remove("active", "rec-on");
$("#btnRec").innerHTML = REC_ICON_IDLE;
showBanner("Recording saved.", true, 2500);
};
recorder.start();
$("#btnRec").classList.add("active", "rec-on");
$("#btnRec").innerHTML = REC_ICON_ACTIVE;
showBanner("Recording…", true);
} catch (e) {
showBanner("Recording not supported in this browser: " + e.message, false, 3000);
recorder = null;
}
};
/* adaptive quality */
let autoTimer = null;
const AUTO_ORDER = ["min", "low", "balanced", "high", "extreme"];
function autoStep() {
if ($("#selQuality").value !== "auto" || S.dead) return;
const TOP = AUTO_ORDER.length - 1;
const queue = S.decoder ? S.decoder.decodeQueueSize : 0;
const idx = AUTO_ORDER.indexOf(S.autoLevel || "balanced");
// Congestion = RTT INFLATION over the session's baseline, not absolute RTT.
// A stable 500ms link (far away / mobile) can still run high quality; what
// hurts is rtt growing above its own floor (queues building = link choked).
// RTT measured over the direct path when it's up, else over the relay
const rtt = (S._rtc && S._rtc.up && S._rtc.rttMs) ? S._rtc.rttMs : S.rtt;
if (rtt) S._rttBase = Math.min((S._rttBase || rtt) * 1.01 + 1, rtt);
const base = S._rttBase || 0;
const lag = Math.max(0, (rtt || 0) - base);
// congestion = inflation relative to the link's own floor (a 300ms
// intercontinental link jitters far more in absolute ms than a 20ms one)
const bad = lag > Math.max(120, base * 0.6) || queue > 8;
const good = lag < Math.max(40, base * 0.2) && queue < 2;
// hysteresis: act only on a SUSTAINED condition, then cool down 8s -
// every change rebuilds the encoder (keyframe), so flapping = stutter
S._autoBad = bad ? (S._autoBad || 0) + 1 : 0;
S._autoGood = good ? (S._autoGood || 0) + 1 : 0;
if (performance.now() - (S._autoChangedAt || 0) < 8000) return;
let want = idx;
if (S._autoBad >= 3) want = Math.max(0, idx - (queue > 20 ? 2 : 1));
else if (S._autoGood >= 6 &&
S.fpsShown >= QUALITY[AUTO_ORDER[Math.min(idx + 1, TOP)]].fps - 5)
want = Math.min(TOP, idx + 1);
if (want !== idx) { S._autoChangedAt = performance.now(); S._autoBad = S._autoGood = 0; }
if (want !== idx) {
S.autoLevel = AUTO_ORDER[want];
const q = QUALITY[S.autoLevel];
send({ t: "set.quality", ...q });
$("#tbStats").textContent = `auto → ${S.autoLevel}`;
}
}
$("#selQuality").addEventListener("change", () => {
S.autoLevel = null;
const v = $("#selQuality").value;
if (v === "auto") {
S.autoLevel = "balanced";
send({ t: "set.quality", ...QUALITY.balanced });
} else if (QUALITY[v]) {
// manual preset: actually apply it (was silently doing nothing before)
send({ t: "set.quality", ...QUALITY[v] });
}
});
/* ------------------------------------------------------------- files */
function setFileStatus(msg, isErr) {
const el = $("#fileStatus");
el.textContent = msg;
el.style.color = isErr ? "#fda4a4" : "";
}
function onFileList(m) {
const list = $("#fileList");
list.innerHTML = "";
$("#filePath").value = m.path || "";
for (const it of m.items || []) {
const row = document.createElement("div");
row.className = "file-row";
const icon = document.createElement("span");
icon.textContent = it.t === "d" ? "📁" : "📄";
const name = document.createElement("span");
name.className = "name";
name.textContent = it.n;
const meta = document.createElement("span");
meta.className = "meta";
meta.textContent = it.t === "d" ? "" : fmtBytes(it.s || 0);
const acts = document.createElement("span");
acts.className = "acts";
if (it.t === "f") {
const dl = document.createElement("button");
dl.textContent = "⬇";
dl.title = "download";
dl.onclick = (e) => { e.stopPropagation(); downloadFile(joinPath(m.path, it.n), it.n, it.s); };
acts.append(dl);
if (isPrintable(it.n)) {
const pr = document.createElement("button");
pr.textContent = "🖨";
pr.title = "print on this device";
pr.onclick = (e) => { e.stopPropagation(); printFile(joinPath(m.path, it.n), it.n, it.s); };
acts.append(pr);
}
}
const ren = document.createElement("button");
ren.textContent = "✏";
ren.title = "rename";
ren.onclick = (e) => {
e.stopPropagation();
const nn = prompt("New name", it.n);
if (nn) send({ t: "file.ren", src: joinPath(m.path, it.n), dst: joinPath(m.path, nn) });
};
const del = document.createElement("button");
del.textContent = "🗑";
del.title = "delete";
del.onclick = (e) => {
e.stopPropagation();
if (confirm(`Delete ${it.n}?`)) send({ t: "file.del", path: joinPath(m.path, it.n), dir: it.t === "d" });
};
acts.append(ren, del);
row.append(icon, name, meta, acts);
if (it.t === "d") row.ondblclick = () => send({ t: "file.list", path: joinPath(m.path, it.n) });
else row.ondblclick = () => downloadFile(joinPath(m.path, it.n), it.n, it.s);
list.append(row);
}
}
function joinPath(dir, name) {
if (!dir) return name;
if (/[\\/]$/.test(dir)) return dir + name;
return dir + "/" + name;
}
$("#btnFileGo").onclick = () => send({ t: "file.list", path: $("#filePath").value.trim() });
$("#filePath").addEventListener("keydown", (e) => {
e.stopPropagation();
if (e.key === "Enter") send({ t: "file.list", path: $("#filePath").value.trim() });
});
$("#btnFileUp").onclick = () => {
let p = $("#filePath").value.replace(/[\\/][^\\/]*[\\/]?$/, "");
send({ t: "file.list", path: p });
};
$("#btnFileHome").onclick = () => send({ t: "file.roots" });
$("#btnFileMkdir").onclick = () => {
const n = prompt("Folder name");
if (n) send({ t: "file.mkdir", path: joinPath($("#filePath").value, n) });
};
// ---- IDM-style downloads: when the host advertises `fr` (file ranges), split
// the file into parallel ranged segments with windowed file.ack flow control,
// live progress, and resume of only the missing byte ranges after a reconnect.
// Hosts without `fr` get the legacy single full-file stream.
function downloadFile(path, name, size, opts) {
size = size || 0;
const print = !!(opts && opts.print);
const verb = print ? "Preparing to print " : "Downloading ";
const ranged = !!(S.info && S.info.fr) && size > 0;
if (!ranged) {
const fid = S.nextFid++;
S.transfers[fid] = { path, name, size, chunks: new Map(), ranged: false, print };
setFileStatus(verb + name + "…");
send({ t: "file.get", path, fid });
return;
}
const nSegs = size >= 8 * 1024 * 1024 ? 4 : size >= 2 * 1024 * 1024 ? 2 : 1;
const segLen = Math.ceil(size / nSegs);
const tr = { path, name, size, chunks: new Map(), ranged: true, print, segs: [], rcvdTotal: 0, lastUi: 0, sidEpoch: S.sid };
for (let i = 0; i < nSegs; i++) {
const off = i * segLen;
const len = Math.min(segLen, size - off);
if (len <= 0) break;
const fid = S.nextFid++;
tr.segs.push({ fid, off, len, rcvd: 0, reqRcvd: 0, lastAck: 0, done: false });
S.transfers[fid] = tr;
send({ t: "file.get", path, fid, offset: off, len });
}
setFileStatus(`${verb}${name} in ${tr.segs.length} segment(s)…`);
}
// Printable types the browser can render + print without a server-side driver.
function isPrintable(name) {
const ext = (String(name).split(".").pop() || "").toLowerCase();
return [
"pdf",
"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico",
"txt", "log", "md", "csv", "json", "xml", "yml", "yaml", "ini", "conf",
"html", "htm", "js", "css", "py", "rs", "c", "h", "cpp", "java", "sh", "bat", "ps1"
].includes(ext);
}
function printFile(path, name, size) { downloadFile(path, name, size, { print: true }); }
function escHtml(s) {
return String(s).replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
// Open an already-downloaded blob in a hidden same-origin iframe and invoke the
// browser's native print dialog — this prints the HOST's file on the VIEWER's
// local printer, with zero driver install on either side. PDFs/images render
// directly; text-like files are wrapped in a monospace <pre>.
function printBlob(blob, name) {
const ext = (String(name).split(".").pop() || "").toLowerCase();
const url = URL.createObjectURL(blob);
const done = (ok) => {
setFileStatus(ok ? "Sent " + name + " to your printer" : "Could not print " + name, !ok);
setTimeout(() => URL.revokeObjectURL(url), 60000);
};
const frame = document.createElement("iframe");
frame.setAttribute("aria-hidden", "true");
frame.style.cssText = "position:fixed;right:0;bottom:0;width:0;height:0;border:0;visibility:hidden";
const fire = () => {
try { frame.contentWindow.focus(); frame.contentWindow.print(); done(true); }
catch (e) { try { window.open(url, "_blank"); done(true); } catch (e2) { done(false); } }
};
const imgExts = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico"];
if (ext === "pdf") {
frame.src = url;
frame.onload = () => setTimeout(fire, 300);
document.body.appendChild(frame);
} else if (imgExts.includes(ext)) {
document.body.appendChild(frame);
const d = frame.contentWindow.document;
d.open();
d.write('<!doctype html><title>' + escHtml(name) + '</title>' +
'<style>@page{margin:10mm}html,body{margin:0;padding:0}img{max-width:100%;height:auto;display:block;margin:0 auto}</style>' +
'<img src="' + url + '" onload="setTimeout(function(){window.focus();window.print();},150)">');
d.close();
done(true);
} else {
blob.text().then((txt) => {
document.body.appendChild(frame);
const d = frame.contentWindow.document;
d.open();
d.write('<!doctype html><title>' + escHtml(name) + '</title>' +
'<style>@page{margin:14mm}body{font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;' +
'white-space:pre-wrap;word-break:break-word;color:#000}</style><pre>' + escHtml(txt) + '</pre>');
d.close();
setTimeout(fire, 150);
}).catch(() => done(false));
}
}
function onFileData(bytes, isUploadAck) {
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const fid = dv.getUint32(1, true);
const off = Number(dv.getBigUint64(5, true));
const tr = S.transfers[fid];
if (!tr) return;
const data = bytes.subarray(13);
tr.chunks.set(off, data);
if (!tr.ranged) return;
const seg = tr.segs.find((s) => s.fid === fid);
if (seg) {
seg.rcvd += data.length;
seg.reqRcvd += data.length;
// Advance the host's send window roughly every 1 MB per segment.
if (seg.reqRcvd - seg.lastAck >= 1024 * 1024) {
seg.lastAck = seg.reqRcvd;
send({ t: "file.ack", fid, rcvd: seg.reqRcvd });
}
// Completion is driven by BYTE COUNT here, NOT by file.done: the control-lane
// file.done can arrive before the last data chunks over the media lane, so
// gating completion on it raced and segments never closed even though every
// byte arrived. Once a segment has all its bytes, finalize it.
if (!seg.done && seg.rcvd >= seg.len) segComplete(tr, seg, fid);
}
tr.rcvdTotal += data.length;
const nowMs = performance.now();
if (nowMs - tr.lastUi > 300) {
tr.lastUi = nowMs;
const pct = Math.min(100, Math.round((tr.rcvdTotal / Math.max(1, tr.size)) * 100));
setFileStatus(`Downloading ${tr.name}… ${pct}% (${fmtBytes(tr.rcvdTotal)} / ${fmtBytes(tr.size)})`);
}
}
function segComplete(tr, seg, fid) {
if (seg.done || seg.rcvd < seg.len) return;
seg.done = true;
try { send({ t: "file.ack", fid, rcvd: seg.reqRcvd }); } catch (e) {} // final window release
if (tr.segs.every((s) => s.done)) {
finishDownload(tr);
for (const s of tr.segs) delete S.transfers[s.fid];
}
}
function finishDownload(tr) {
const parts = [...tr.chunks.keys()].sort((a, b) => a - b).map((k) => tr.chunks.get(k));
const blob = new Blob(parts);
if (tr.print) { printBlob(blob, tr.name || "file"); return; }
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = tr.name || "file";
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 30000);
setFileStatus("Saved " + a.download + " (" + fmtBytes(blob.size) + ")");
}
function onFileDone(m) {
const tr = S.transfers[m.fid];
if (!tr) return;
if (!tr.ranged) {
tr.name = m.name || tr.name;
finishDownload(tr);
delete S.transfers[m.fid];
return;
}
// file.done is a BACKUP completion trigger; byte-count completion in onFileData
// is primary (file.done can arrive before the last data chunks).
const seg = tr.segs.find((s) => s.fid === m.fid);
if (seg) segComplete(tr, seg, m.fid);
}
// After a reconnect (new sid), re-request ONLY the missing byte ranges of any
// unfinished ranged download — IDM-style resume.
function resumeTransfers() {
if (!(S.info && S.info.fr)) return;
const seen = new Set();
for (const k of Object.keys(S.transfers)) {
const tr = S.transfers[k];
if (!tr || !tr.ranged || seen.has(tr)) continue;
seen.add(tr);
if (tr.sidEpoch === S.sid) continue;
tr.sidEpoch = S.sid;
for (const seg of tr.segs) {
if (seg.done || seg.rcvd >= seg.len) { seg.done = true; continue; }
delete S.transfers[seg.fid];
seg.fid = S.nextFid++;
seg.reqRcvd = 0;
seg.lastAck = 0;
S.transfers[seg.fid] = tr;
send({ t: "file.get", path: tr.path, fid: seg.fid, offset: seg.off + seg.rcvd, len: seg.len - seg.rcvd });
}
setFileStatus(`Resuming ${tr.name}… (${fmtBytes(tr.rcvdTotal)} / ${fmtBytes(tr.size)})`);
}
}
function onFileOk(m) {
setFileStatus(m.msg || "ok");
if (m.refresh) send({ t: "file.list", path: $("#filePath").value });
}
async function uploadFile(file, destPath, viaBanner) {
const fid = S.nextFid++;
// no explicit target and no folder open in the Files panel -> the host
// drops it on the remote DESKTOP (TeamViewer behaviour)
const path = (destPath || joinPath($("#filePath").value || "", file.name)).replace(/\//g, "\\");
setFileStatus(`Uploading ${file.name} (${fmtBytes(file.size)})…`);
if (viaBanner) showBanner(`⬆ Sending ${file.name} to the remote Desktop… 0%`, true, 0);
send({ t: "file.put", path, size: file.size, fid });
const CHUNK = 256 * 1024;
for (let off = 0; off < file.size || off === 0; off += CHUNK) {
const blob = file.slice(off, off + CHUNK);
const head = new Uint8Array(13);
new DataView(head.buffer).setUint32(1, fid, true);
new DataView(head.buffer).setBigUint64(5, BigInt(off), true);
const part = new Uint8Array(await blob.arrayBuffer());
const msg = new Uint8Array(13 + part.length);
msg.set(head, 0);
msg.set(part, 13);
msg[0] = 0xf2;
await sendBinary(msg);
const pct = Math.min(100, Math.round(((off + part.length) / Math.max(1, file.size)) * 100));
setFileStatus(`Uploading ${file.name}… ${pct}%`);
if (viaBanner) showBanner(`⬆ Sending ${file.name} to the remote Desktop… ${pct}%`, true, 0);
// Real backpressure instead of a fixed sleep: run flat-out while the socket
// drains, pause only when >4 MB is buffered. Much faster on fast links,
// bounded memory on slow ones (IDM-style windowing for uploads).
while (S.ws && S.ws.readyState === 1 && S.ws.bufferedAmount > 4 * 1024 * 1024) {
await new Promise((r) => setTimeout(r, 10));
}
if (file.size === 0) break;
}
if (viaBanner) showBanner(`✓ ${file.name} delivered to the remote Desktop`, true, 3200);
}
$("#btnFileUpload").onclick = () => $("#fileInput").click();
$("#fileInput").onchange = async () => {
for (const f of $("#fileInput").files) await uploadFile(f);
$("#fileInput").value = "";
send({ t: "file.list", path: $("#filePath").value });
};
$("#btnDirUpload").onclick = () => $("#dirInput").click();
$("#dirInput").onchange = async () => {
const files = [...$("#dirInput").files];
const base = $("#filePath").value || "";
for (let i = 0; i < files.length; i++) {
const f = files[i];
const rel = (f.webkitRelativePath || f.name).split("/").slice(1).join("\\") || f.name;
const dest = base ? `${base}\\${rel}` : rel.replace(/\\[^\\]+$/, "");
setFileStatus(`Folder upload ${i + 1}/${files.length}: ${rel}`);
await uploadFile(f, dest);
}
$("#dirInput").value = "";
send({ t: "file.list", path: $("#filePath").value });
};
const stage = $("#stage");
stage.addEventListener("dragover", (e) => { e.preventDefault(); $("#dropHint").hidden = false; });
stage.addEventListener("dragleave", () => { $("#dropHint").hidden = true; });
stage.addEventListener("drop", async (e) => {
e.preventDefault();
$("#dropHint").hidden = true;
for (const f of e.dataTransfer.files) await uploadFile(f, null, true);
});
/* ------------------------------------------------------------- misc UI */
function setState(txt, cls) {
const el = $("#tbState");
el.textContent = txt;
el.className = "state " + (cls || "");
}
let bannerTimer = null;
function showBanner(msg, isInfo, ms) {
const b = $("#banner");
b.textContent = msg;
b.className = "banner" + (isInfo ? " info" : "");
b.hidden = false;
if (bannerTimer) clearTimeout(bannerTimer);
if (ms) bannerTimer = setTimeout(hideBanner, ms);
}
function hideBanner() { $("#banner").hidden = true; }
function updateStats() {
autoStep();
let limitTxt = "", rem = Infinity;
if (S.limit) {
rem = Math.max(0, S.limit - (now() - (S.startedAt || now())) / 1000);
const mm = String(Math.floor(rem / 60)).padStart(2, "0");
const ss = String(Math.floor(rem % 60)).padStart(2, "0");
limitTxt = ` · ${mm}:${ss} left`;
}
const mbps = ((S.bytesVideo * 8) / 1e6 / Math.max(0.001, (now() - S.lastFpsTick) / 1000 + 1)).toFixed(1);
S.bytesVideo = 0;
const el = $("#tbStats");
el.textContent =
`${S.dims.w}×${S.dims.h} · ${S.fpsShown}fps · ~${mbps} Mbps · ${S.rtt}ms${S.e2e ? " · 🔒E2E" : ""}${limitTxt}`;
el.classList.toggle("limit-warn", !!S.limit && rem < 300);
// live connection-quality tint (green / amber / red by round-trip latency)
el.dataset.q = S.rtt < 80 ? "good" : S.rtt < 180 ? "ok" : "bad";
}
function teardownMedia() {
if (S.latestFrame) { try { S.latestFrame.close(); } catch {} S.latestFrame = null; }
if (S.decoder) { try { S.decoder.close(); } catch {} S.decoder = null; }
if (S.audioDecoder) { try { S.audioDecoder.close(); } catch {} S.audioDecoder = null; }
if (S.audioCtx) { try { S.audioCtx.close(); } catch {} S.audioCtx = null; }
try { stopVoice(); } catch {}
S.started = false;
}
// TeamViewer-style collapse: ▲ hides the docked bar (video gets the full
// height), a tiny ▼ tab at top-center brings it back
(function () {
var tab = document.getElementById("tbTab"), tb = document.getElementById("toolbar"),
show = document.getElementById("tbShow");
if (tab && tb) tab.addEventListener("click", function (e) {
e.stopPropagation();
tb.classList.add("collapsed");
if (show) show.hidden = false;
});
if (show && tb) show.addEventListener("click", function (e) {
e.stopPropagation();
tb.classList.remove("collapsed");
show.hidden = true;
});
})();
/* ============== TeamViewer-style session menus (Actions / View / Extras) == */
(function () {
const menus = ["mActions", "mView", "mComm", "mExtras"];
function closeMenus() {
menus.forEach((id) => { const m = $("#" + id); if (m) m.hidden = true; });
document.querySelectorAll(".menu-btn.open").forEach((b) => b.classList.remove("open"));
}
function syncMenuState() {
const le = $("#miLockEnd");
if (le) le.classList.toggle("on", !!(S.info && S.info.lock_on_end));
document.querySelectorAll("#mView .mradio").forEach((r) =>
r.classList.toggle("on", (S.scaleMode || "fit") === r.dataset.scale));
}
document.querySelectorAll(".menu-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const m = $("#" + btn.dataset.menu);
const wasOpen = m && !m.hidden;
closeMenus();
if (!m || wasOpen) return;
syncMenuState();
// fixed positioning: never clipped by the bar, clamped to the viewport
const br = btn.getBoundingClientRect();
const tbr = document.getElementById("toolbar").getBoundingClientRect();
m.style.top = (tbr.bottom + 4) + "px";
m.style.left = Math.max(4, br.left - 6) + "px";
m.hidden = false;
requestAnimationFrame(() => {
m.style.left = Math.max(4, Math.min(parseFloat(m.style.left) || 4,
window.innerWidth - m.offsetWidth - 6)) + "px";
});
btn.classList.add("open");
if (btn.dataset.menu === "mView" && !window.__modesLoaded) loadModes();
});
});
document.addEventListener("click", closeMenus);
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenus(); });
// collapsing the toolbar must also dismiss any open menu (tbTab stops propagation)
const _tab = $("#tbTab"); if (_tab) _tab.addEventListener("click", closeMenus);
menus.forEach((id) => { const m = $("#" + id); if (m) m.addEventListener("click", (e) => e.stopPropagation()); });
/* ---- Actions ---- */
const miWol = $("#miWol");
if (miWol) miWol.addEventListener("click", () => {
const mac = prompt(
"Wake-on-LAN\n\nEnter the MAC address of the PC to wake (e.g. AA:BB:CC:DD:EE:FF).\n" +
"This computer will broadcast the wake signal on its local network, so it must be on the same LAN as the sleeping PC.");
if (mac && mac.trim()) {
send({ t: "sys.cmd", a: "wol", mac: mac.trim() });
showBanner("Sending wake signal to " + mac.trim() + "…", true, 2200);
}
});
document.querySelectorAll(".mi[data-sys]").forEach((mi) => {
mi.addEventListener("click", () => {
const q = mi.dataset.confirm;
if (q && !window.confirm(q)) return;
send({ t: "sys.cmd", a: mi.dataset.sys });
closeMenus();
});
});
const miLockEnd = $("#miLockEnd");
if (miLockEnd) miLockEnd.addEventListener("click", () => {
const next = !(S.info && S.info.lock_on_end);
S.info = Object.assign({}, S.info || {}, { lock_on_end: next });
send({ t: "set.cfg", lock_on_end: next });
const c = $("#chkLockOnEnd"); if (c) c.checked = next;
syncMenuState();
});
const miInfo = $("#miInfo");
if (miInfo) miInfo.addEventListener("click", () => {
closeMenus();
const dur = S._t0 ? Math.round((Date.now() - S._t0) / 1000) : 0;
const rows = [
["Partner ID", ($("#inId") || {}).value || "—"],
["Remote resolution", (S.dims ? S.dims.w + " × " + S.dims.h : "—")],
["Encryption", "ECDH P-256 + AES-256-GCM (end-to-end)"],
["Live stats", ($("#tbStats") || {}).textContent || "—"],
["Session time", Math.floor(dur / 60) + "m " + (dur % 60) + "s"],
["Lock on session end", (S.info && S.info.lock_on_end) ? "on" : "off"],
["Clipboard allowed", (S.info && S.info.perms && S.info.perms.clipboard === false) ? "no" : "yes"],
["File transfer allowed", (S.info && S.info.perms && S.info.perms.files === false) ? "no" : "yes"],
];
let box = $("#infoBox");
if (!box) {
box = document.createElement("div");
box.id = "infoBox"; box.className = "tmenu";
box.style.cssText = "position:fixed;top:70px;left:50%;transform:translateX(-50%);min-width:340px;z-index:80";
document.body.appendChild(box);
box.addEventListener("click", (e) => e.stopPropagation());
}
box.innerHTML = '<div class="mhead">Session information</div>' +
rows.map((r) => '<div class="mi" style="cursor:default"><span style="color:#8b96a6">' +
r[0] + "</span><span>" + String(r[1]).replace(/[<>&]/g, "") + "</span></div>").join("") +
'<div class="msep"></div><button class="mi" id="infoClose" style="justify-content:center">Close</button>';
box.hidden = false;
$("#infoClose").onclick = () => { box.hidden = true; };
});
if (!S._t0) S._t0 = Date.now();
/* ---- View: scaling ---- */
function applyScale(mode) {
S.scaleMode = mode;
try { localStorage.setItem("nd.scale", mode); } catch (e) {}
$("#stage").classList.toggle("orig", mode === "orig");
syncMenuState();
}
document.querySelectorAll("#mView .mradio").forEach((r) =>
r.addEventListener("click", () => { applyScale(r.dataset.scale); closeMenus(); }));
try { applyScale(localStorage.getItem("nd.scale") || "fit"); } catch (e) { applyScale("fit"); }
/* ---- View: remote screen resolution ---- */
function loadModes() { send({ t: "sys.cmd", a: "modes" }); }
// delegate: the #miModesLoad button is recreated each session (resetSessionState)
const modesWrap = $("#miModes");
if (modesWrap) modesWrap.addEventListener("click", (e) => {
if (e.target && e.target.id === "miModesLoad") loadModes();
});
function renderModes(m) {
window.__modesLoaded = true;
const wrap = $("#miModes");
if (!wrap) return;
wrap.textContent = "";
const cur = m.current || [];
(m.items || []).slice(0, 18).forEach(function (wh) {
// coerce to integers - never trust host-sent values into the DOM (no innerHTML)
const w = parseInt(wh[0], 10), h = parseInt(wh[1], 10);
if (!(w > 0 && h > 0)) return;
const b = document.createElement("button");
b.className = "mi mradio" + (w === cur[0] && h === cur[1] ? " on" : "");
b.textContent = w + " × " + h;
const tick = document.createElement("span");
tick.className = "mtick"; tick.textContent = "✓";
b.appendChild(tick);
b.onclick = function () {
if (!window.confirm("Change the remote screen to " + w + " × " + h + "?")) return;
send({ t: "sys.cmd", a: "setmode", w: w, h: h });
closeMenus();
};
wrap.appendChild(b);
});
if (!wrap.children.length) {
const d = document.createElement("div");
d.className = "mi"; d.style.cssText = "cursor:default;color:#8b96a6";
d.textContent = "No modes reported";
wrap.appendChild(d);
}
}
/* ---- View: wallpaper / refresh ---- */
const miWp = $("#miWallpaper");
if (miWp) miWp.addEventListener("click", () => send({ t: "sys.cmd", a: "wallpaper" }));
const miRf = $("#miRefresh");
if (miRf) miRf.addEventListener("click", () => { send({ t: "keyframe" }); showBanner("Screen refreshed", true, 1400); closeMenus(); });
/* ---- Extras ---- */
const click = (sel) => { const b = $(sel); if (b) b.click(); };
const wire = (id, fn) => { const el = $(id); if (el) el.addEventListener("click", () => { fn(); closeMenus(); }); };
wire("#miFiles", () => click("#btnFiles"));
wire("#miChat", () => click("#btnChat"));
wire("#miRec", () => click("#btnRec"));
wire("#miShot", () => {
const c = $("#canvas");
if (!c || !c.width) return showBanner("No frame yet", false, 1600);
c.toBlob(function (blob) {
if (!blob) return;
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "netdesk-" + (($("#inId") || {}).value || "shot") + "-" +
new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19) + ".png";
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
showBanner("Screenshot saved", true, 1600);
}, "image/png");
});
/* ---- results from the host ---- */
window.onSysRes = function (m) {
if (m.kind === "modes") {
if (m.ok) renderModes(m); else showBanner("Resolutions: " + (m.msg || "unavailable"), false, 2600);
return true;
}
if (m.kind === "wallpaper") {
if (miWp) miWp.classList.toggle("on", !!m.on);
showBanner(m.msg || "ok", m.ok, 1800);
return true;
}
if (m.kind === "setmode") {
showBanner(m.ok ? "Resolution changed" : "Failed: " + m.msg, m.ok, 2400);
if (m.ok) { window.__modesLoaded = false; send({ t: "keyframe" }); }
return true;
}
return false; // cad + everything else: generic banner
};
})();
/* ============== mobile on-screen keyboard (⌨ Type button) ============== */
(function () {
const osk = $("#oskInput"), btn = $("#btnOsk");
if (!osk || !btn) return;
btn.addEventListener("click", () => {
osk.value = "";
osk.style.pointerEvents = "auto";
osk.focus({ preventScroll: true });
});
const SH = "!@#$%^&*()";
const PUNCT = { "-": "Minus", "=": "Equal", "[": "BracketLeft", "]": "BracketRight",
"\\": "Backslash", ";": "Semicolon", "'": "Quote", ",": "Comma", ".": "Period",
"/": "Slash", "`": "Backquote" };
const PUNCT_SH = { "_": "Minus", "+": "Equal", "{": "BracketLeft", "}": "BracketRight",
"|": "Backslash", ":": "Semicolon", "\"": "Quote", "<": "Comma", ">": "Period",
"?": "Slash", "~": "Backquote" };
function keyOf(ch) {
if (/^[a-z]$/.test(ch)) return { c: "Key" + ch.toUpperCase(), sh: false };
if (/^[A-Z]$/.test(ch)) return { c: "Key" + ch, sh: true };
if (/^[0-9]$/.test(ch)) return { c: "Digit" + ch, sh: false };
const i = SH.indexOf(ch);
if (i >= 0) return { c: "Digit" + ((i + 1) % 10), sh: true };
if (ch === " ") return { c: "Space", sh: false };
if (PUNCT[ch]) return { c: PUNCT[ch], sh: false };
if (PUNCT_SH[ch]) return { c: PUNCT_SH[ch], sh: true };
return null;
}
function tapKey(code, sh) {
if (sh) send({ t: "i.key", c: "ShiftLeft", d: true });
send({ t: "i.key", c: code, d: true });
send({ t: "i.key", c: code, d: false });
if (sh) send({ t: "i.key", c: "ShiftLeft", d: false });
}
osk.addEventListener("beforeinput", (ev) => {
ev.preventDefault();
if (ev.inputType === "insertText" && ev.data) {
for (const ch of ev.data) { const k = keyOf(ch); if (k) tapKey(k.c, k.sh); }
} else if (ev.inputType === "deleteContentBackward") tapKey("Backspace", false);
else if (ev.inputType === "insertLineBreak") tapKey("Enter", false);
});
osk.addEventListener("keydown", (ev) => {
const pass = ["Backspace", "Enter", "Tab", "Escape", "ArrowLeft", "ArrowRight",
"ArrowUp", "ArrowDown", "Delete", "Home", "End"];
if (pass.indexOf(ev.key) >= 0) {
ev.preventDefault();
tapKey(ev.code && ev.code !== "Unidentified" ? ev.code : ev.key, false);
}
});
osk.addEventListener("blur", () => { osk.style.pointerEvents = "none"; });
})();
/* ===== reference-exact bar: monitors & quality live in the View menu ===== */
(function () {
const QLABELS = { auto: "Auto (adaptive)", low: "Low", balanced: "Balanced",
high: "High", extreme: "Extreme" };
function radio(label, on, fn) {
const b = document.createElement("button");
b.className = "mi mradio" + (on ? " on" : "");
b.textContent = label;
const t = document.createElement("span"); t.className = "mtick"; t.textContent = "✓";
b.appendChild(t); b.onclick = fn;
return b;
}
function refreshViewMenu() {
const mons = $("#miMons"), qual = $("#miQual");
if (mons) {
mons.textContent = "";
const sel = $("#selMonitor");
[...sel.options].forEach((o) => {
mons.appendChild(radio(o.textContent || ("Display " + o.value),
sel.value === o.value, () => {
sel.value = o.value;
// dispatch alone fires BOTH the property handler and listeners
sel.dispatchEvent(new Event("change"));
refreshViewMenu();
}));
});
if (!sel.options.length) {
const d = document.createElement("div");
d.className = "mi"; d.style.cssText = "cursor:default;color:#7c8898";
d.textContent = "Connect to list displays";
mons.appendChild(d);
}
}
if (qual) {
qual.textContent = "";
const q = $("#selQuality");
Object.keys(QLABELS).forEach((v) => {
qual.appendChild(radio(QLABELS[v], q.value === v, () => {
q.value = v;
q.dispatchEvent(new Event("change"));
refreshViewMenu();
}));
});
}
}
const vbtn = document.querySelector('.menu-btn[data-menu="mView"]');
if (vbtn) vbtn.addEventListener("click", refreshViewMenu);
refreshViewMenu();
// build badge: instantly tells us whether a tab runs the current UI
window.ND_BUILD = "v52";
const foot = document.querySelector("#screenConnect .foot");
if (foot) foot.textContent = "NetDesk viewer · build " + window.ND_BUILD;
const tb = $("#toolbar"); if (tb) tb.title = "NetDesk " + window.ND_BUILD;
})();
/* block remote user's input (RustDesk parity) */
(function () {
const mi = $("#miBlockInput");
if (mi) mi.addEventListener("click", () => send({ t: "sys.cmd", a: "block_input" }));
const prev = window.onSysRes;
window.onSysRes = function (m) {
if (m.kind === "block_input") {
if (mi) mi.classList.toggle("on", !!m.on);
showBanner((m.ok ? "" : "⚠ ") + (m.msg || ""), m.ok, 3000);
return true;
}
return prev ? prev(m) : false;
};
})();
/* ===== monitor quick-switch button + Communicate menu (v44) ===== */
(function () {
// --- monitor cycle: appears only when the host has 2+ displays ---
const btn = $("#btnMonCycle"), lbl = $("#monCycleLbl"), sel = $("#selMonitor");
function syncMon() {
if (!btn || !sel) return;
const n = sel.options.length;
btn.hidden = n < 2;
if (n >= 2) {
const idx = Math.max(0, [...sel.options].findIndex(o => o.value === sel.value));
lbl.textContent = (idx + 1) + "/" + n;
btn.title = "Switch monitor — now on " +
(sel.options[idx] ? sel.options[idx].textContent : "");
}
}
if (btn) btn.addEventListener("click", () => {
const n = sel.options.length;
if (n < 2) return;
const idx = [...sel.options].findIndex(o => o.value === sel.value);
sel.value = sel.options[(idx + 1) % n].value;
sel.dispatchEvent(new Event("change")); // fires property handler + listeners once
syncMon();
showBanner("🖥 " + sel.options[(idx + 1) % n].textContent, true, 1500);
});
if (sel) sel.addEventListener("change", syncMon);
setInterval(syncMon, 1200); // options arrive with session.info
syncMon();
// --- Communicate menu ---
const miMsg = $("#miMsg");
if (miMsg) miMsg.addEventListener("click", () => {
const b = $("#btnChat"); if (b) b.click();
const inp = $("#chatText"); if (inp) setTimeout(() => inp.focus(), 120);
});
const miSound = $("#miSound");
function syncSound() {
// "on" means the host is actually sending (source != 0) AND we can play it.
if (miSound) miSound.classList.toggle("on",
!!(S.audioSrc && S.audioSrc !== 0 && S.audioCtx && S.audioCtx.state === "running"));
}
if (miSound) miSound.addEventListener("click", async () => {
// This toggles REMOTE (host) sound on/off. It must command the host
// (audio.set) — not merely suspend the local context — or the host never
// captures and "on" is silent. Mirrors the 🔊 toolbar button.
if (!S.audioDecoder) setupAudio();
const turningOn = !(S.audioSrc && S.audioSrc !== 0);
try {
if (turningOn) {
S.audioSrc = 1;
if (S.audioCtx && S.audioCtx.state !== "running") await S.audioCtx.resume();
send({ t: "audio.set", src: 1 });
showBanner("Remote sound ON (system)", true, 1600);
} else {
S.audioSrc = 0;
send({ t: "audio.set", src: 0 });
if (S.audioCtx && S.audioCtx.state === "running") await S.audioCtx.suspend();
showBanner("Remote sound muted", true, 1600);
}
} catch (e) {}
const lbl = $("#audioLbl"); if (lbl) lbl.textContent = S.audioSrc === 0 ? "Audio" : "🔊 System";
const b = $("#btnAudio"); if (b) b.classList.toggle("active", S.audioSrc !== 0);
syncSound();
});
const miVoice = $("#miVoice");
if (miVoice) miVoice.addEventListener("click", () => { if (S.voiceOn) stopVoice(); else startVoice(); });
updateVoiceUi();
const cbtn = document.querySelector('.menu-btn[data-menu="mComm"]');
if (cbtn) cbtn.addEventListener("click", syncSound);
})();
/* ============ WebRTC P2P fast path (video + input direct over UDP) ============
Negotiated over the relay after the session is live. Same E2E-encrypted
bytes - only the pipe changes. The relay stays connected as fallback and
for files/clipboard/chat, so a failed P2P never costs the session. */
(function () {
function rtcTeardown() {
const r = S._rtc;
S._rtc = null; S._rtcTried = false;
if (r && r.pc) { try { r.pc.close(); } catch (e) {} }
}
const _reset = resetSessionState;
resetSessionState = function () { rtcTeardown(); _reset(); };
// ask the server for ICE servers (STUN + the nearest TURN relay)
function fetchIce() {
return new Promise((res) => {
const fallback = [{ urls: ["stun:stun.cloudflare.com:3478", "stun:stun.l.google.com:19302"] }];
const t = setTimeout(() => { S._iceWait = null; res(fallback); }, 2500);
S._iceWait = (servers) => { clearTimeout(t); S._iceWait = null; res(servers && servers.length ? servers : fallback); };
send({ t: "rtc.config.req" });
});
}
window.__onRtcConfig = (m) => { if (S._iceWait) S._iceWait(m.iceServers); };
async function rtcConnect() {
if (S._rtcTried || S.dead || S.direct || !window.RTCPeerConnection) return;
S._rtcTried = true;
try {
const iceServers = await fetchIce();
if (S.dead) return;
const pc = new RTCPeerConnection({ iceServers });
// fresh frames beat late ones: unreliable-ish, 150ms max lifetime
const media = pc.createDataChannel("media", { ordered: false, maxPacketLifeTime: 150 });
const ctl = pc.createDataChannel("ctl", { ordered: true });
// pure mouse motion: unordered + no retransmits, so one lost packet on a
// lossy link never holds the pointer back (clicks/keys stay on "ctl")
const move = pc.createDataChannel("move", { ordered: false, maxRetransmits: 0 });
media.binaryType = "arraybuffer";
S._rtc = { pc, media, ctl, move, up: false, moveOk: false };
// only engines that answer "move.ok" read the motion channel (older
// ones ignore its label, so motion must stay on "ctl" for them)
move.onmessage = (ev) => { try { if (JSON.parse(ev.data).t === "move.ok") S._rtc.moveOk = true; } catch (e) {} };
media.onmessage = (ev) => {
const bytes = new Uint8Array(ev.data);
decodeQueue = decodeQueue.then(() => handleBinary(bytes))
.catch((e) => console.error("p2p binary", e));
};
const maybeUp = () => {
const r = S._rtc;
if (r && r.media.readyState === "open" && r.ctl.readyState === "open" && !r.up) {
r.up = true;
updateStats();
showBanner("⚡ Direct P2P connection active — lowest latency path", true, 3000);
}
};
const down = () => {
const r = S._rtc;
if (r && r.up) { r.up = false; updateStats(); showBanner("P2P dropped — continuing over the relay", true, 2200); }
};
media.onopen = maybeUp; ctl.onopen = maybeUp;
media.onclose = down; ctl.onclose = down;
pc.onconnectionstatechange = () => {
if (["failed", "disconnected", "closed"].indexOf(pc.connectionState) >= 0) down();
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await new Promise((res) => { // non-trickle ICE, capped at 2.5s
if (pc.iceGatheringState === "complete") return res();
const t = setTimeout(res, 2500);
pc.addEventListener("icegatheringstatechange", () => {
if (pc.iceGatheringState === "complete") { clearTimeout(t); res(); }
});
});
send({ t: "rtc.offer", sdp: pc.localDescription.sdp, ice: iceServers });
} catch (e) { /* relay path continues untouched */ }
}
// which tier won? (direct P2P vs TURN relay) - read the selected pair
async function rtcRoute() {
const r = S._rtc;
if (!r || !r.up || !r.pc.getStats) return;
try {
const st = await r.pc.getStats();
let pairId = null;
st.forEach((v) => { if (v.type === "transport" && v.selectedCandidatePairId) pairId = v.selectedCandidatePairId; });
st.forEach((v) => {
if (!pairId && v.type === "candidate-pair" && v.nominated && v.state === "succeeded") pairId = v.id;
});
const pair = pairId && st.get(pairId);
if (!pair) return;
const lc = st.get(pair.localCandidateId), rc = st.get(pair.remoteCandidateId);
r.route = ((lc && lc.candidateType === "relay") || (rc && rc.candidateType === "relay")) ? "TURN" : "P2P";
if (pair.currentRoundTripTime) r.rttMs = Math.round(pair.currentRoundTripTime * 1000);
} catch (e) {}
}
setInterval(rtcRoute, 2000);
setInterval(() => { if (S.started && !S._rtcTried && !S.dead) rtcConnect(); }, 600);
// latency-critical traffic rides the direct channel when it's up
const _send = send;
send = function (obj) {
const r = S._rtc;
const motion = obj._mv; delete obj._mv;
if (r && r.up && r.ctl.readyState === "open" &&
(obj.t === "i.mouse" || obj.t === "i.key" || obj.t === "keyframe")) {
obj.sid = obj.sid || S.sid;
const ch = (motion && r.moveOk && r.move && r.move.readyState === "open") ? r.move : r.ctl;
try { ch.send(JSON.stringify(obj)); return true; } catch (e) {}
}
return _send(obj);
};
// link badge in the stats line
const _stats = updateStats;
updateStats = function () {
_stats();
const el = $("#tbStats");
if (el && el.textContent && S.started) {
const r = S._rtc;
el.textContent += (r && r.up)
? (" · ⚡" + (r.route || "P2P") + (r.rttMs ? " " + r.rttMs + "ms" : ""))
: " · relay";
}
};
})();
/* ===== pinch view-zoom (mobile): transform on the canvas - every coordinate
path keeps working because it reads getBoundingClientRect, which already
reflects the transform. When zoomed, the viewport follows the cursor. ===== */
const Z = { s: 1, ox: 0, oy: 0 };
function applyView() {
const st = $("#stage");
const vw = st.clientWidth, vh = st.clientHeight;
Z.s = Math.max(1, Math.min(4, Z.s));
Z.ox = Math.min(0, Math.max(vw - vw * Z.s, Z.ox));
Z.oy = Math.min(0, Math.max(vh - vh * Z.s, Z.oy));
canvas.style.transformOrigin = "0 0";
canvas.style.transform = (Z.s === 1 && !Z.ox && !Z.oy) ? "" :
"translate(" + Z.ox + "px," + Z.oy + "px) scale(" + Z.s + ")";
try { positionCursor(); } catch (e) {}
}
function zoomBy(f, cx, cy) {
const sr = $("#stage").getBoundingClientRect();
const px = cx - sr.left, py = cy - sr.top;
const ns = Math.max(1, Math.min(4, Z.s * f));
const r = ns / Z.s;
Z.ox = px - (px - Z.ox) * r;
Z.oy = py - (py - Z.oy) * r;
Z.s = ns;
if (Z.s < 1.04) { Z.s = 1; Z.ox = 0; Z.oy = 0; } // snap back to full view
applyView();
}
function zoomReset() { Z.s = 1; Z.ox = 0; Z.oy = 0; applyView(); }
function followCursor() {
if (Z.s <= 1) return;
const st = $("#stage");
const vw = st.clientWidth, vh = st.clientHeight;
const r = canvas.getBoundingClientRect(), sr = st.getBoundingClientRect();
const iw = S.dims.w || 1, ih = S.dims.h || 1;
const k = Math.min(r.width / iw, r.height / ih) || 1;
const cxv = (r.left - sr.left) + (r.width - iw * k) / 2 + T.vx * k;
const cyv = (r.top - sr.top) + (r.height - ih * k) / 2 + T.vy * k;
const mL = vw * 0.25, mR = vw * 0.75, mT = vh * 0.25, mB = vh * 0.75;
if (cxv < mL) Z.ox += mL - cxv; else if (cxv > mR) Z.ox -= cxv - mR;
if (cyv < mT) Z.oy += mT - cyv; else if (cyv > mB) Z.oy -= cyv - mB;
applyView();
}
/* SSO (SAML): reveal the "Sign in with SSO" button when the server has it enabled */
(function () {
try {
fetch("/api/auth/sso", { cache: "no-store" }).then(function (r) { return r.json(); }).then(function (d) {
if (d && d.saml) {
var w = document.getElementById("ssoWrap"); if (w) w.hidden = false;
var b = document.getElementById("ssoBtn"); if (b) b.href = (d.login_url || "/saml/login") + "?RelayState=/app";
// enforce: lead with SSO by de-emphasizing the password form
if (d.enforce) {
var sub = document.querySelector("#pane-in .auth-sub");
if (sub) sub.textContent = "Your organization uses single sign-on. Continue with SSO.";
}
}
}).catch(function () {});
} catch (e) {}
})();