/* 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 = ''; 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( '' + ''); 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 = ''; const REC_ICON_ACTIVE = ''; 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
.
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('' + escHtml(name) + '' +
      '' +
      '');
    d.close();
    done(true);
  } else {
    blob.text().then((txt) => {
      document.body.appendChild(frame);
      const d = frame.contentWindow.document;
      d.open();
      d.write('' + escHtml(name) + '' +
        '
' + escHtml(txt) + '
'); 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 = '
Session information
' + rows.map((r) => '
' + r[0] + "" + String(r[1]).replace(/[<>&]/g, "") + "
").join("") + '
'; 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) {} })();