2996 lines
121 KiB
JavaScript
2996 lines
121 KiB
JavaScript
/* 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) =>
|
||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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) {}
|
||
})();
|