NetDesk 1.5.0 — source release

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

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>About — NetDesk</title>
<meta name="description" content="NetDesk builds remote access that respects your ownership — self-hosted, private, open source.">
<link rel="canonical" href="https://netdesk.space/about">
<meta property="og:title" content="About — NetDesk">
<meta property="og:description" content="NetDesk builds remote access that respects your ownership — self-hosted, private, open source.">
<meta property="og:url" content="https://netdesk.space/about">
<meta name="theme-color" content="#0a0d14">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="site.css?v=2">
</head>
<body data-page="about">
<div id="siteHeader"></div>
<section class="section"><div class="wrap"><div class="prose">
<h1>About NetDesk</h1><p class="updated">Building remote access that respects your ownership.</p>
<p>NetDesk exists because remote access shouldn't mean handing your screen, your files and your trust to someone else's
cloud. Tools like TeamViewer and AnyDesk are powerful, but they route your sessions through their servers and charge
per seat. RustDesk proved a self-hosted model works — NetDesk takes it further with a first-class web client, an
account and device system, and a polished desktop app.</p>
<h2>Our principles</h2>
<ul>
<li><strong>You own it.</strong> Your server, your keys, your data. No NetDesk cloud in the path.</li>
<li><strong>Private by default.</strong> End-to-end encryption is on out of the box, not an upsell.</li>
<li><strong>Open source.</strong> Every line is auditable. Trust is earned by transparency.</li>
<li><strong>Free for individuals.</strong> The core is free forever; teams pay for convenience, not for the right to connect.</li>
</ul>
<h2>Built for scale</h2>
<p>NetDesk runs a stateless relay you can deploy on a $5 VPS or a fleet of them. It handles NAT traversal without port
forwarding, survives reconnections, and streams hardware-accelerated H.264 at up to 1080p60.</p>
<p>We're just getting started. <a href="/contact">Talk to us</a> or <a href="/git/akronlogic/netdesk">browse the full source</a>.</p>
</div></div></section>
<div id="siteFooter"></div>
<script src="site.js?v=3"></script>
</body>
</html>
+230
View File
@@ -0,0 +1,230 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NetDesk — Admin Console</title>
<meta name="robots" content="noindex, nofollow">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="site.css?v=2">
<style>
body{min-height:100vh}
.gate{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;text-align:center;padding:24px}
.gate h1{font-size:26px;letter-spacing:-.6px}
.gate p{color:var(--muted);max-width:420px}
.admin-shell{display:grid;grid-template-columns:248px 1fr;min-height:100vh}
.admin-side{position:sticky;top:0;height:100vh;border-right:1px solid var(--hair);background:color-mix(in srgb,var(--bg2) 70%,transparent);backdrop-filter:blur(12px);padding:20px 14px;display:flex;flex-direction:column}
.as-brand{display:flex;align-items:center;gap:10px;font-weight:800;font-size:18px;letter-spacing:-.4px;padding:4px 8px 18px}
.as-brand .nz-logo{width:30px;height:30px}
.as-nav{display:flex;flex-direction:column;gap:3px}
.as-nav a{display:flex;align-items:center;gap:11px;padding:10px 12px;border-radius:9px;color:var(--muted);font-weight:600;font-size:14.5px;cursor:pointer;transition:.14s}
.as-nav a:hover{background:var(--hair);color:var(--fg)}
.as-nav a.on{background:var(--accent-dim);color:var(--fg)}
.as-nav a.on svg{color:var(--accent2)}
.as-nav svg{width:18px;height:18px;flex-shrink:0;opacity:.85}
.as-foot{margin-top:auto;padding-top:14px;border-top:1px solid var(--hair);display:flex;flex-direction:column;gap:3px}
.as-foot a{color:var(--faint);font-size:13.5px;padding:8px 12px;border-radius:8px}
.as-foot a:hover{color:var(--fg);background:var(--hair)}
.admin-main{padding:30px clamp(18px,4vw,44px);max-width:1200px}
.pg-head{margin-bottom:22px}
.pg-head h1{font-size:26px;letter-spacing:-.7px}
.pg-head p{color:var(--muted);font-size:14.5px;margin-top:4px}
.ov-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:16px}
.ov-card{border:1px solid transparent;border-radius:15px;padding:20px 22px;background:linear-gradient(var(--card),var(--card)) padding-box,linear-gradient(180deg,var(--hair-strong),rgba(255,255,255,.015)) border-box}
.ov-card b{display:block;font-size:30px;font-weight:850;letter-spacing:-1px;background:var(--accent-grad);-webkit-background-clip:text;background-clip:text;color:transparent;line-height:1}
.ov-card span{display:block;margin-top:7px;color:var(--muted);font-size:13px;font-weight:500}
.a-card{border:1px solid var(--hair);border-radius:15px;background:var(--card);overflow:hidden;box-shadow:var(--shadow-sm)}
.a-card-h{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px;border-bottom:1px solid var(--hair);flex-wrap:wrap}
.a-card-h h2{font-size:16px;letter-spacing:-.3px}
table.adt{width:100%;border-collapse:collapse;font-size:14px}
table.adt th{text-align:left;color:var(--faint);font-size:11.5px;text-transform:uppercase;letter-spacing:.6px;font-weight:700;padding:11px 16px;border-bottom:1px solid var(--hair)}
table.adt td{padding:11px 16px;border-bottom:1px solid var(--hair-strong);vertical-align:middle}
table.adt tr:last-child td{border-bottom:none}
table.adt tr:hover td{background:color-mix(in srgb,var(--accent) 4%,transparent)}
.pill{display:inline-block;font-size:11px;font-weight:700;padding:3px 9px;border-radius:999px;text-transform:uppercase;letter-spacing:.4px}
.pill.on{background:rgba(52,211,153,.16);color:#6ee7b7}.pill.off{background:var(--hair);color:var(--faint)}
.pill.admin{background:rgba(77,139,255,.18);color:#9cc0ff}.pill.member{background:var(--hair);color:var(--muted)}
.in{background:var(--bg2);border:1px solid var(--border);border-radius:8px;color:var(--fg);padding:7px 10px;font-size:13px;font-family:inherit;outline:none}
.in:focus{border-color:var(--accent);box-shadow:var(--ring)}
.in-sm{width:70px}
.b{background:var(--bg3);border:1px solid var(--border);border-radius:8px;color:var(--fg);padding:7px 13px;font-size:13px;font-weight:600;cursor:pointer;transition:.14s}
.b:hover{border-color:var(--accent);transform:translateY(-1px)}
.b-cta{background:var(--accent-grad);border-color:transparent;color:#fff}
.b-danger:hover{border-color:#ef4444;color:#fca5a5}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
.dot.on{background:#34d399;box-shadow:0 0 8px #34d399}.dot.off{background:var(--faint)}
.tag{display:inline-block;font-size:11px;background:var(--hair);color:var(--muted);padding:2px 8px;border-radius:6px;margin:2px 3px 2px 0}
.pol-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px 24px;padding:20px}
.pol-field{display:flex;flex-direction:column;gap:5px}
.pol-field label{font-size:12.5px;font-weight:600;color:var(--muted)}
.pol-check{display:flex;align-items:center;gap:9px;font-size:14px;padding:6px 0}
.muted{color:var(--muted)}.faint{color:var(--faint)}.sp{margin-top:22px}
.toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:var(--card);border:1px solid var(--hair);border-radius:10px;padding:11px 18px;font-size:14px;box-shadow:var(--shadow-lg);z-index:100;opacity:0;transition:.2s;pointer-events:none}
.toast.show{opacity:1}
/* teams */
.teams-wrap{display:grid;grid-template-columns:320px 1fr;gap:18px;align-items:start}
.t-list{display:flex;flex-direction:column}
.t-item{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 20px;border-bottom:1px solid var(--hair);cursor:pointer;transition:.12s}
.t-item:hover{background:var(--hair)}
.t-item.on{background:linear-gradient(90deg,rgba(77,139,255,.14),transparent);box-shadow:inset 2px 0 0 var(--accent)}
.t-item b{font-size:14.5px}
.t-item .t-sub{font-size:12px;color:var(--faint);margin-top:2px}
.td-body{display:grid;grid-template-columns:1fr 1fr;gap:26px;padding:20px}
.td-col h3{font-size:13px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);margin-bottom:12px}
.t-row{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 0;border-bottom:1px solid var(--hair);font-size:13.5px}
.t-row .t-meta{color:var(--faint);font-size:12px}
/* access rules */
.ac-days{display:flex;gap:6px;flex-wrap:wrap}
.ac-days label{display:inline-flex;align-items:center;gap:5px;font-size:13px;background:var(--hair);padding:5px 10px;border-radius:7px;cursor:pointer}
.ac-pill{display:inline-block;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;padding:2px 8px;border-radius:999px}
.ac-allow{background:rgba(52,211,153,.16);color:#6ee7b7}.ac-deny{background:rgba(239,68,68,.16);color:#fca5a5}
.ac-cond{font-size:12px;color:var(--muted);line-height:1.5}
.ac-res-ok{color:#6ee7b7;font-weight:600}.ac-res-no{color:#fca5a5;font-weight:600}
@media (max-width:820px){.admin-shell{grid-template-columns:1fr}.admin-side{position:static;height:auto;flex-direction:row;flex-wrap:wrap;overflow:auto}.as-foot{flex-direction:row;margin:0}.pol-grid{grid-template-columns:1fr}.teams-wrap{grid-template-columns:1fr}.td-body{grid-template-columns:1fr}}
</style>
</head>
<body data-page="admin">
<div id="gate" class="gate"><div class="muted">Checking access…</div></div>
<div id="shell" class="admin-shell" hidden>
<aside class="admin-side">
<div class="as-brand"><span class="nz-logo"><svg width="18" height="18" viewBox="0 0 32 32"><path d="M8 20l8-12 8 12-8-4z" fill="#fff"/><rect x="12" y="18" width="8" height="7" rx="2" fill="#fff"/></svg></span>Admin</div>
<nav class="as-nav" id="nav">
<a data-p="overview" class="on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg> Overview</a>
<a data-p="users"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg> Users &amp; roles</a>
<a data-p="devices"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg> Devices</a>
<a data-p="policy"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> MDM policy</a>
<a data-p="keys"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.6 7.6a5 5 0 1 0-7 7 5 5 0 0 0 7-7zm0 0L15 7m0 0l3 3L22 6l-3-3"/></svg> API keys</a>
<a data-p="relays"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49M7.76 16.24a6 6 0 0 1 0-8.49M19.07 4.93a10 10 0 0 1 0 14.14M4.93 19.07a10 10 0 0 1 0-14.14"/></svg> Relays</a>
<a data-p="teams"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg> Teams</a>
<a data-p="access"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> Access rules</a>
<a data-p="recordings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.5" fill="currentColor" stroke="none"/></svg> Recordings</a>
</nav>
<div class="as-foot">
<a href="/portal">Operator console</a>
<a href="/app">Open viewer</a>
<a href="/">Website</a>
</div>
</aside>
<main class="admin-main">
<section id="p-overview">
<div class="pg-head"><h1>Overview</h1><p>Your NetDesk organization at a glance.</p></div>
<div class="ov-grid" id="ovGrid"></div>
</section>
<section id="p-users" hidden>
<div class="pg-head"><h1>Users &amp; roles</h1><p>Every account on this server — promote admins, set per-account seat limits.</p></div>
<div class="a-card"><div class="a-card-h"><h2>Accounts</h2><span class="faint" id="uCount"></span></div>
<table class="adt"><thead><tr><th>Email</th><th>Role</th><th>Seats</th><th>Devices</th><th>2FA</th><th>Verified</th></tr></thead><tbody id="uBody"></tbody></table>
</div>
</section>
<section id="p-devices" hidden>
<div class="pg-head"><h1>Devices</h1><p>Every registered computer across all accounts — tag, group, hide or remove.</p></div>
<div class="a-card"><div class="a-card-h"><h2>Computers</h2><span class="faint" id="dCount"></span></div>
<table class="adt"><thead><tr><th>Computer</th><th>Owner</th><th>Status</th><th>Group</th><th>Tags</th><th></th></tr></thead><tbody id="dBody"></tbody></table>
</div>
</section>
<section id="p-policy" hidden>
<div class="pg-head"><h1>MDM policy</h1><p>Push a managed, locked configuration to an account's hosts (applied at registration).</p></div>
<div class="a-card"><div class="a-card-h"><h2>Account policy</h2>
<div style="display:flex;gap:8px;align-items:center"><select id="polAcct" class="in"></select><button class="b" id="polLoad">Load</button></div></div>
<div class="pol-grid" id="polForm"></div>
<div style="padding:0 20px 20px;display:flex;gap:10px"><button class="b b-cta" id="polSave">Save &amp; push policy</button><button class="b b-danger" id="polClear">Clear policy</button></div>
</div>
</section>
<section id="p-keys" hidden>
<div class="pg-head"><h1>API keys</h1><p>Programmatic access (X-API-Key) to the /api/v1 REST surface + webhooks.</p></div>
<div class="a-card"><div class="a-card-h"><h2>Your keys</h2>
<div style="display:flex;gap:8px"><input id="kName" class="in" placeholder="Key name"><button class="b b-cta" id="kNew">Create key</button></div></div>
<table class="adt"><thead><tr><th>Name</th><th>Key</th><th>Created</th><th>Last used</th><th></th></tr></thead><tbody id="kBody"></tbody></table>
</div>
<div id="kNewBox" class="muted sp" hidden></div>
<div class="a-card sp">
<div class="a-card-h"><h2>SCIM 2.0 provisioning</h2>
<div style="display:flex;gap:8px"><input id="scName" class="in" placeholder="Token label (e.g. Okta)"><button class="b b-cta" id="scNew">Mint token</button></div></div>
<div style="padding:14px 20px 0" class="muted" id="scBase"></div>
<table class="adt"><thead><tr><th>Label</th><th>Token</th><th>Created</th><th>Last used</th><th></th></tr></thead><tbody id="scBody"></tbody></table>
<div id="scNewBox" class="muted" style="padding:0 20px 18px" hidden></div>
</div>
</section>
<section id="p-relays" hidden>
<div class="pg-head"><h1>Relays</h1><p>Configured relay nodes for nearest-relay geo selection (set with --relays).</p></div>
<div class="a-card"><div class="a-card-h"><h2>Relay nodes</h2></div>
<table class="adt"><thead><tr><th>ID</th><th>Region</th><th>URL</th><th>Tier</th><th>Lat, Lon</th></tr></thead><tbody id="rBody"></tbody></table>
</div>
</section>
<section id="p-teams" hidden>
<div class="pg-head"><h1>Teams</h1><p>Shared address books — a team of operators shares one device list. The team owner (or any account admin) manages its members and shared devices.</p></div>
<div class="teams-wrap">
<div class="a-card t-list-card">
<div class="a-card-h"><h2>Teams</h2><span class="faint" id="tCount"></span></div>
<div style="display:flex;gap:8px;padding:0 20px 14px"><input id="tName" class="in" placeholder="New team name"><button class="b b-cta" id="tNew">Create</button></div>
<div id="tList" class="t-list"></div>
</div>
<div class="a-card teams-detail" id="tDetail" hidden>
<div class="a-card-h"><h2 id="tdName">—</h2><span style="white-space:nowrap"><button class="b" id="tdRename">Rename</button> <button class="b b-danger" id="tdDelete">Delete</button></span></div>
<div class="td-body">
<div class="td-col">
<h3>Members</h3>
<div style="display:flex;gap:8px;margin-bottom:10px"><input id="tmEmail" class="in" placeholder="account email" style="flex:1"><select id="tmRole" class="in in-sm" style="width:auto"><option value="member">member</option><option value="owner">owner</option></select><button class="b" id="tmAdd">Add</button></div>
<div id="tmList"></div>
</div>
<div class="td-col">
<h3>Shared devices</h3>
<div style="display:flex;gap:8px;margin-bottom:10px"><select id="tdDev" class="in" style="flex:1"></select><button class="b" id="tdShare">Share</button></div>
<div id="tdDevList"></div>
</div>
</div>
</div>
</div>
</section>
<section id="p-access" hidden>
<div class="pg-head"><h1>Conditional access</h1><p>First-match-wins allow/deny rules, evaluated the moment a viewer connects. Scope by user, device, group, IP range, weekday and time-of-day. When nothing matches, the connection is allowed.</p></div>
<div class="a-card">
<div class="a-card-h"><h2>Rules for</h2><div style="display:flex;gap:8px;align-items:center"><select id="acAcct" class="in"></select><button class="b" id="acLoad">Load</button></div></div>
<table class="adt"><thead><tr><th>#</th><th>Action</th><th>Applies to</th><th>Conditions</th><th>Reason</th><th>On</th><th></th></tr></thead><tbody id="acBody"></tbody></table>
</div>
<div class="a-card sp">
<div class="a-card-h"><h2 id="acFormTitle">Add a rule</h2><button class="b" id="acReset" hidden>Cancel edit</button></div>
<div class="pol-grid" id="acForm">
<div class="pol-field"><label>Priority (lower is evaluated first)</label><input id="acPrio" class="in" type="number" value="100"></div>
<div class="pol-field"><label>Action</label><select id="acAction" class="in"><option value="deny">deny</option><option value="allow">allow</option></select></div>
<div class="pol-field"><label>Applies to</label><select id="acSubType" class="in"><option value="any">any connection</option><option value="user">a user (email)</option><option value="device">a device (id)</option><option value="group">a device group</option></select></div>
<div class="pol-field"><label>Subject value</label><input id="acSubject" class="in" placeholder="email / device id / group name"></div>
<div class="pol-field"><label>From IP range (CIDR, optional)</label><input id="acCidr" class="in" placeholder="e.g. 203.0.113.0/24"></div>
<div class="pol-field"><label>Deny reason (shown to the viewer)</label><input id="acReason" class="in" placeholder="Access denied by policy"></div>
<div class="pol-field" style="grid-column:1/-1"><label>Weekdays (leave all off = any day)</label><div id="acDays" class="ac-days"></div></div>
<div class="pol-field"><label>Time from (HH:MM, optional)</label><input id="acTimeStart" class="in" placeholder="09:00"></div>
<div class="pol-field"><label>Time to (HH:MM, optional)</label><input id="acTimeEnd" class="in" placeholder="18:00"></div>
<div class="pol-field"><label>UTC offset for the times, minutes</label><input id="acTz" class="in" type="number" value="0" title="e.g. 60 = CET, -300 = US Eastern"></div>
</div>
<div style="padding:0 20px 20px;display:flex;gap:10px"><button class="b b-cta" id="acSave">Save rule</button></div>
</div>
<div class="a-card sp">
<div class="a-card-h"><h2>Test a connection</h2><span class="faint">dry-run the rules above</span></div>
<div class="pol-grid">
<div class="pol-field"><label>Viewer email</label><input id="tViewer" class="in" placeholder="user@example.com"></div>
<div class="pol-field"><label>Device id</label><input id="tHost" class="in" placeholder="123456789"></div>
<div class="pol-field"><label>Device group</label><input id="tGroup" class="in"></div>
<div class="pol-field"><label>From IP</label><input id="tIp" class="in" placeholder="203.0.113.5"></div>
</div>
<div style="padding:0 20px 20px;display:flex;gap:12px;align-items:center"><button class="b b-cta" id="tRun">Evaluate</button><span id="tResult"></span></div>
</div>
</section>
<section id="p-recordings" hidden>
<div class="pg-head"><h1>Session recordings</h1><p>Centrally-stored recordings uploaded by hosts governed by a <b>record_required</b> MDM policy (set it per account in MDM policy). <span id="recRetention" class="faint"></span></p></div>
<div class="a-card"><div class="a-card-h"><h2>Recordings</h2><span class="faint" id="recCount"></span></div>
<table class="adt"><thead><tr><th>#</th><th>Host</th><th>Owner</th><th>Viewer</th><th>Size</th><th>Length</th><th>Recorded</th><th></th></tr></thead><tbody id="recBody"></tbody></table>
</div>
</section>
</main>
</div>
<div class="toast" id="toast"></div>
<script src="admin.js?v=6"></script>
</body>
</html>
+447
View File
@@ -0,0 +1,447 @@
/* NetDesk admin console — manages users/roles, devices, MDM policy, API keys,
relays. Account admin-role gated (the /api/admin/* endpoints return 403 for
non-admins; the server is the authority, this UI just reacts). */
(function () {
"use strict";
var $ = function (s) { return document.querySelector(s); };
function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]; }); }
function fmtTs(t) { return t ? new Date(t * 1000).toLocaleDateString() : "—"; }
function fmtAgo(t) { if (!t) return "never"; var s = Date.now() / 1000 - t;
if (s < 60) return "just now"; if (s < 3600) return Math.floor(s / 60) + "m ago";
if (s < 86400) return Math.floor(s / 3600) + "h ago"; return Math.floor(s / 86400) + "d ago"; }
var toastT;
function toast(msg, bad) { var t = $("#toast"); t.textContent = msg; t.style.borderColor = bad ? "#ef4444" : "";
t.classList.add("show"); clearTimeout(toastT); toastT = setTimeout(function () { t.classList.remove("show"); }, 2400); }
function getJSON(p) { return fetch(p, { credentials: "same-origin", cache: "no-store" }).then(function (r) {
return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }).catch(function () { return { ok: r.ok, status: r.status, d: {} }; }); }); }
function post(p, body) { return fetch(p, { method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}) }).then(function (r) {
return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }).catch(function () { return { ok: r.ok, status: r.status, d: {} }; }); }); }
/* ---- access gate ---- */
getJSON("/api/admin/overview").then(function (r) {
if (r.status === 401) { location.href = "/app"; return; }
if (r.status === 403) {
$("#gate").innerHTML = '<h1>Admin access required</h1><p>Your account is signed in but is not an administrator. Ask an admin to grant you the admin role, then reload.</p><a class="btn btn-ghost" href="/app" style="margin-top:8px">Back to app</a>';
return;
}
if (!r.ok) { $("#gate").innerHTML = '<p class="muted">Could not reach the server.</p>'; return; }
$("#gate").hidden = true; $("#shell").hidden = false;
renderOverview(r.d);
wireNav(); loadUsersForPolicy();
});
/* ---- nav ---- */
var loaded = {};
function wireNav() {
$("#nav").querySelectorAll("a").forEach(function (a) {
a.onclick = function () {
$("#nav").querySelectorAll("a").forEach(function (x) { x.classList.remove("on"); });
a.classList.add("on");
var p = a.dataset.p;
["overview", "users", "devices", "policy", "keys", "relays", "teams", "access", "recordings"].forEach(function (s) {
var el = $("#p-" + s); if (el) el.hidden = s !== p; });
if (p === "users" && !loaded.users) loadUsers();
if (p === "devices" && !loaded.devices) loadDevices();
if (p === "keys" && !loaded.keys) loadKeys();
if (p === "relays" && !loaded.relays) loadRelays();
if (p === "teams" && !loaded.teams) loadTeams();
if (p === "access" && !loaded.access) loadAccess();
if (p === "recordings" && !loaded.recordings) loadRecordings();
};
});
}
/* ---- overview ---- */
function renderOverview(d) {
var items = [["users", "Accounts"], ["admins", "Administrators"], ["devices", "Registered devices"],
["hosts_online", "Hosts online"], ["in_session", "In session now"], ["api_keys", "API keys"], ["relays", "Relay nodes"]];
$("#ovGrid").innerHTML = items.map(function (i) {
return '<div class="ov-card"><b>' + (d[i[0]] != null ? d[i[0]] : "—") + '</b><span>' + i[1] + '</span></div>'; }).join("");
}
/* ---- users + roles + seats ---- */
var USERS = [];
function loadUsers() {
loaded.users = true;
getJSON("/api/admin/users").then(function (r) {
if (!r.ok) { toast(r.d.error || "failed", true); return; }
USERS = r.d.users || [];
$("#uCount").textContent = USERS.length + " account" + (USERS.length === 1 ? "" : "s");
$("#uBody").innerHTML = USERS.map(function (u) {
var rsel = '<select class="in" data-role="' + esc(u.email) + '">' +
'<option value="member"' + (u.role === "member" ? " selected" : "") + '>member</option>' +
'<option value="admin"' + (u.role === "admin" ? " selected" : "") + '>admin</option></select>';
var seat = '<input class="in in-sm" type="number" min="0" value="' + (u.seats || 0) + '" data-seat="' + esc(u.email) + '" title="0 = unlimited">';
return '<tr><td>' + esc(u.email) + (u.email === (r.d.me || "") ? ' <span class="faint">(you)</span>' : "") + '</td>' +
'<td>' + rsel + '</td><td>' + seat + '</td><td>' + (u.devices || 0) + '</td>' +
'<td><span class="pill ' + (u.twofa ? "on" : "off") + '">' + (u.twofa ? "on" : "off") + '</span></td>' +
'<td><span class="pill ' + (u.verified ? "on" : "off") + '">' + (u.verified ? "yes" : "no") + '</span></td></tr>';
}).join("");
$("#uBody").querySelectorAll("[data-role]").forEach(function (sel) {
sel.onchange = function () { post("/api/admin/role", { email: sel.dataset.role, role: sel.value })
.then(function (x) { toast(x.ok ? "Role updated" : (x.d.error || "failed"), !x.ok); }); };
});
$("#uBody").querySelectorAll("[data-seat]").forEach(function (inp) {
inp.onchange = function () { post("/api/admin/license", { email: inp.dataset.seat, seats: parseInt(inp.value, 10) || 0 })
.then(function (x) { toast(x.ok ? "Seat limit saved" : (x.d.error || "failed"), !x.ok); }); };
});
});
}
/* ---- devices ---- */
function loadDevices() {
loaded.devices = true;
getJSON("/api/devices?scope=all").then(function (r) {
if (!r.ok) { toast(r.d.error || "failed", true); return; }
var ds = r.d.devices || [];
$("#dCount").textContent = ds.length + " device" + (ds.length === 1 ? "" : "s");
$("#dBody").innerHTML = ds.map(function (dv) {
var st = dv.online ? '<span class="dot on"></span>' + (dv.busy ? "in session" : "online") : '<span class="dot off"></span>offline';
var tags = (dv.tags || []).map(function (t) { return '<span class="tag">' + esc(t) + '</span>'; }).join("") || '<span class="faint">—</span>';
return '<tr data-id="' + esc(dv.id) + '"><td><b>' + esc(dv.name) + '</b><div class="faint" style="font-size:12px">' + esc(dv.id) + '</div></td>' +
'<td class="muted">' + esc(dv.owner || "—") + '</td><td>' + st + '</td>' +
'<td><input class="in in-sm" style="width:110px" value="' + esc(dv.group || "") + '" data-grp placeholder="group"></td>' +
'<td><input class="in" style="width:150px" value="' + esc((dv.tags || []).join(", ")) + '" data-tags placeholder="tag1, tag2"></td>' +
'<td style="white-space:nowrap"><button class="b" data-save>Save</button> <button class="b b-danger" data-del>Remove</button></td></tr>';
}).join("") || '<tr><td colspan="6" class="muted" style="padding:24px">No devices yet.</td></tr>';
$("#dBody").querySelectorAll("tr[data-id]").forEach(function (tr) {
var id = tr.dataset.id;
var save = tr.querySelector("[data-save]"); if (save) save.onclick = function () {
post("/api/devices/meta", { id: id, group: tr.querySelector("[data-grp]").value, tags: tr.querySelector("[data-tags]").value })
.then(function (x) { toast(x.ok ? "Saved" : (x.d.error || "failed"), !x.ok); }); };
var del = tr.querySelector("[data-del]"); if (del) del.onclick = function () {
if (!confirm("Remove " + id + "? It must re-register to return.")) return;
post("/api/devices/remove", { id: id }).then(function (x) { if (x.ok) { tr.remove(); toast("Removed"); } else toast(x.d.error || "failed", true); }); };
});
});
}
/* ---- MDM policy editor ---- */
var STRKEYS = [["server", "Relay server (wss://…)"], ["reg_key", "Registration key"], ["allow_ips", "Viewer IP allowlist (CIDR, comma-sep)"], ["totp", "2FA secret (base32)"]];
function loadUsersForPolicy() {
getJSON("/api/admin/users").then(function (r) {
var sel = $("#polAcct"); if (!sel) return;
sel.innerHTML = (r.d.users || []).map(function (u) { return '<option value="' + esc(u.email) + '">' + esc(u.email) + '</option>'; }).join("");
renderPolicyForm({});
});
$("#polLoad").onclick = function () {
getJSON("/api/admin/policy?email=" + encodeURIComponent($("#polAcct").value)).then(function (r) { renderPolicyForm(r.d.policy || {}); toast("Loaded"); }); };
$("#polSave").onclick = function () {
post("/api/admin/policy", { email: $("#polAcct").value, policy: collectPolicy() })
.then(function (x) { toast(x.ok ? "Policy saved & will push on host registration" : (x.d.error || "failed"), !x.ok); if (x.ok) renderPolicyForm(x.d.policy || {}); }); };
$("#polClear").onclick = function () {
post("/api/admin/policy", { email: $("#polAcct").value, policy: {} }).then(function (x) { toast(x.ok ? "Policy cleared" : "failed", !x.ok); renderPolicyForm({}); }); };
}
function renderPolicyForm(pol) {
var perms = pol.perms || {};
var h = STRKEYS.map(function (k) {
return '<div class="pol-field"><label>' + k[1] + '</label><input class="in" data-pk="' + k[0] + '" value="' + esc(pol[k[0]] || "") + '"></div>'; }).join("");
function chk(key, lbl, on) { return '<label class="pol-check"><input type="checkbox" data-pb="' + key + '"' + (on ? " checked" : "") + '> ' + lbl + '</label>'; }
h += '<div class="pol-field"><label>Behaviour</label>' + chk("lock_on_end", "Lock the PC when the session ends", !!pol.lock_on_end) + chk("approve_click", "Require the host user to approve each connect", !!pol.approve_click) + chk("record_required", "Force central session recording (upload to the server)", !!pol.record_required) + '</div>';
h += '<div class="pol-field"><label>Allowed channels (perms)</label>' +
chk("perm_input", "Keyboard / mouse input", perms.input !== false) + chk("perm_clipboard", "Clipboard sync", perms.clipboard !== false) +
chk("perm_files", "File transfer", perms.files !== false) + chk("perm_audio", "Audio", perms.audio !== false) + '</div>';
$("#polForm").innerHTML = h;
}
function collectPolicy() {
var pol = {}, perms = {};
$("#polForm").querySelectorAll("[data-pk]").forEach(function (i) { if (i.value.trim()) pol[i.dataset.pk] = i.value.trim(); });
$("#polForm").querySelectorAll("[data-pb]").forEach(function (c) {
var k = c.dataset.pb;
if (k.indexOf("perm_") === 0) perms[k.slice(5)] = c.checked; else if (c.checked) pol[k] = true;
});
pol.perms = perms;
return pol;
}
/* ---- API keys ---- */
function loadKeys() {
loaded.keys = true;
getJSON("/api/keys").then(function (r) {
var ks = (r.d && r.d.keys) || [];
$("#kBody").innerHTML = ks.map(function (k) {
return '<tr><td><b>' + esc(k.name) + '</b></td><td class="mono faint">' + esc(k.key_preview) + '</td><td>' + fmtTs(k.created) + '</td><td>' + fmtAgo(k.last_used) + '</td>' +
'<td><button class="b b-danger" data-rev="' + esc(k.key_preview) + '">Revoke</button></td></tr>'; }).join("") ||
'<tr><td colspan="5" class="muted" style="padding:24px">No API keys yet.</td></tr>';
$("#kBody").querySelectorAll("[data-rev]").forEach(function (b) {
b.onclick = function () { if (!confirm("Revoke this key?")) return;
post("/api/keys/revoke", { key: b.dataset.rev }).then(function (x) { toast(x.ok ? "Revoked" : "failed", !x.ok); loaded.keys = false; loadKeys(); }); };
});
});
$("#kNew").onclick = function () {
var name = $("#kName").value.trim() || "key";
post("/api/keys", { name: name }).then(function (x) {
if (!x.ok) { toast(x.d.error || "failed", true); return; }
$("#kName").value = ""; $("#kNewBox").hidden = false;
$("#kNewBox").innerHTML = 'New key (copy it now — shown once): <code class="mono" style="color:var(--accent2)">' + esc(x.d.key) + '</code>';
loaded.keys = false; loadKeys();
});
};
loadScim();
$("#scNew").onclick = function () {
var label = $("#scName").value.trim() || "SCIM";
post("/api/admin/scim/tokens", { label: label }).then(function (x) {
if (!x.ok) { toast(x.d.error || "failed", true); return; }
$("#scName").value = ""; $("#scNewBox").hidden = false;
$("#scNewBox").innerHTML = 'New SCIM token (copy now — shown once): <code class="mono" style="color:var(--accent2)">' + esc(x.d.token) + '</code>';
loadScim();
});
};
}
function loadScim() {
getJSON("/api/admin/scim/tokens").then(function (r) {
if (!r.ok) return;
var ts = (r.d && r.d.tokens) || [];
if (r.d.base) $("#scBase").innerHTML = 'Point your IdP at <code class="mono" style="color:var(--accent2)">' + esc(r.d.base) + '</code> with the token as an <b>HTTP Bearer</b> header. Provisions accounts (Users) &amp; teams (Groups).';
$("#scBody").innerHTML = ts.map(function (t) {
return '<tr><td><b>' + esc(t.label) + '</b></td><td class="mono faint">' + esc(t.token_preview) + '</td><td>' + fmtTs(t.created) + '</td><td>' + fmtAgo(t.last_used) + '</td>' +
'<td><button class="b b-danger" data-screv="' + esc(t.token_preview) + '">Revoke</button></td></tr>'; }).join("") ||
'<tr><td colspan="5" class="muted" style="padding:20px">No provisioning tokens yet.</td></tr>';
$("#scBody").querySelectorAll("[data-screv]").forEach(function (b) {
b.onclick = function () { if (!confirm("Revoke this SCIM token? The IdP will stop provisioning.")) return;
post("/api/admin/scim/tokens/revoke", { token: b.dataset.screv }).then(function (x) { toast(x.ok ? "Revoked" : "failed", !x.ok); loadScim(); }); };
});
});
}
/* ---- relays ---- */
function loadRelays() {
loaded.relays = true;
getJSON("/api/relays").then(function (r) {
var rs = (r.d && r.d.relays) || [];
$("#rBody").innerHTML = rs.map(function (x) {
return '<tr><td><b>' + esc(x.id) + '</b></td><td>' + esc(x.region || "—") + '</td><td class="mono faint">' + esc(x.url) + '</td><td>' + esc(x.tier || "—") + '</td><td class="muted">' + x.lat + ", " + x.lon + '</td></tr>'; }).join("");
});
}
/* ---- teams (shared address books) ---- */
var TEAMS = [], curTeam = null, ALLDEV = [];
function refreshCounts() { loaded.teams = false; loadTeams(); }
function loadTeams() {
loaded.teams = true;
getJSON("/api/admin/teams").then(function (r) {
if (!r.ok) { toast(r.d.error || "failed", true); return; }
TEAMS = r.d.teams || [];
$("#tCount").textContent = TEAMS.length + " team" + (TEAMS.length === 1 ? "" : "s");
$("#tList").innerHTML = TEAMS.map(function (t) {
return '<div class="t-item" data-tid="' + t.id + '"><div><b>' + esc(t.name) + '</b>' +
'<div class="t-sub">' + t.members + ' member' + (t.members === 1 ? "" : "s") + ' · ' + t.devices + ' device' + (t.devices === 1 ? "" : "s") + '</div></div>' +
'<span class="faint" style="font-size:12px">#' + t.id + '</span></div>';
}).join("") || '<div class="muted" style="padding:16px 20px">No teams yet — create one above.</div>';
$("#tList").querySelectorAll("[data-tid]").forEach(function (el) {
el.onclick = function () { selectTeam(parseInt(el.dataset.tid, 10)); };
});
if (curTeam && TEAMS.some(function (t) { return t.id === curTeam; })) selectTeam(curTeam);
else { curTeam = null; $("#tDetail").hidden = true; }
});
}
function selectTeam(tid) {
curTeam = tid;
var t = TEAMS.filter(function (x) { return x.id === tid; })[0];
$("#tList").querySelectorAll("[data-tid]").forEach(function (el) { el.classList.toggle("on", parseInt(el.dataset.tid, 10) === tid); });
$("#tDetail").hidden = false;
$("#tdName").textContent = t ? t.name : ("Team #" + tid);
loadMembers(tid); loadTeamDevices(tid);
}
function loadMembers(tid) {
getJSON("/api/teams/members?team_id=" + tid).then(function (r) {
var ms = (r.d && r.d.members) || [];
$("#tmList").innerHTML = ms.map(function (m) {
return '<div class="t-row"><span>' + esc(m.email) + ' <span class="pill ' + (m.role === "owner" ? "admin" : "member") + '">' + esc(m.role) + '</span></span>' +
'<button class="b b-danger" data-rm="' + esc(m.email) + '">Remove</button></div>';
}).join("") || '<div class="muted">No members.</div>';
$("#tmList").querySelectorAll("[data-rm]").forEach(function (b) {
b.onclick = function () {
post("/api/teams/members/remove", { team_id: tid, email: b.dataset.rm }).then(function (x) {
toast(x.ok ? "Removed" : (x.d.error || "failed"), !x.ok); if (x.ok) refreshCounts(); });
};
});
});
}
function loadTeamDevices(tid) {
getJSON("/api/teams/devices?team_id=" + tid).then(function (r) {
var ds = (r.d && r.d.devices) || [], shared = {};
$("#tdDevList").innerHTML = ds.map(function (d) {
shared[d.id] = true;
return '<div class="t-row"><span>' + (d.online ? '<span class="dot on"></span>' : '<span class="dot off"></span>') + esc(d.name) +
' <span class="t-meta">' + esc(d.owner || "") + '</span></span><button class="b b-danger" data-unshare="' + esc(d.id) + '">Unshare</button></div>';
}).join("") || '<div class="muted">No shared devices.</div>';
$("#tdDevList").querySelectorAll("[data-unshare]").forEach(function (b) {
b.onclick = function () {
post("/api/teams/devices/remove", { team_id: tid, device_id: b.dataset.unshare }).then(function (x) {
toast(x.ok ? "Unshared" : (x.d.error || "failed"), !x.ok); if (x.ok) refreshCounts(); });
};
});
var fill = function () {
$("#tdDev").innerHTML = ALLDEV.filter(function (d) { return !shared[d.id]; }).map(function (d) {
return '<option value="' + esc(d.id) + '">' + esc(d.name) + ' (' + esc(d.id) + (d.owner ? " · " + esc(d.owner) : "") + ')</option>';
}).join("") || '<option value="">— no more devices —</option>';
};
if (ALLDEV.length) fill();
else getJSON("/api/devices?scope=all").then(function (rr) { ALLDEV = (rr.d && rr.d.devices) || []; fill(); });
});
}
(function wireTeams() {
var nw = $("#tNew"); if (nw) nw.onclick = function () {
var name = $("#tName").value.trim(); if (!name) { toast("Enter a team name", true); return; }
post("/api/teams", { name: name }).then(function (x) {
if (!x.ok) { toast(x.d.error || "failed", true); return; }
$("#tName").value = ""; toast("Team created"); curTeam = x.d.id; refreshCounts(); });
};
var ad = $("#tmAdd"); if (ad) ad.onclick = function () {
if (!curTeam) return; var em = $("#tmEmail").value.trim(); if (!em) { toast("Enter an account email", true); return; }
post("/api/teams/members", { team_id: curTeam, email: em, role: $("#tmRole").value }).then(function (x) {
toast(x.ok ? "Member added" : (x.d.error || "failed"), !x.ok); if (x.ok) { $("#tmEmail").value = ""; refreshCounts(); } });
};
var sh = $("#tdShare"); if (sh) sh.onclick = function () {
if (!curTeam) return; var id = $("#tdDev").value; if (!id) { toast("No device selected", true); return; }
post("/api/teams/devices", { team_id: curTeam, device_id: id }).then(function (x) {
toast(x.ok ? "Device shared" : (x.d.error || "failed"), !x.ok); if (x.ok) refreshCounts(); });
};
var rn = $("#tdRename"); if (rn) rn.onclick = function () {
if (!curTeam) return;
var cur = (TEAMS.filter(function (x) { return x.id === curTeam; })[0] || {}).name || "";
var name = prompt("Rename team", cur); if (name == null || !name.trim()) return;
post("/api/teams/rename", { team_id: curTeam, name: name.trim() }).then(function (x) {
toast(x.ok ? "Renamed" : (x.d.error || "failed"), !x.ok); if (x.ok) refreshCounts(); });
};
var dl = $("#tdDelete"); if (dl) dl.onclick = function () {
if (!curTeam) return;
if (!confirm("Delete this team? Members lose the shared list (the devices themselves are not deleted).")) return;
post("/api/teams/delete", { team_id: curTeam }).then(function (x) {
toast(x.ok ? "Team deleted" : (x.d.error || "failed"), !x.ok);
if (x.ok) { curTeam = null; $("#tDetail").hidden = true; refreshCounts(); } });
};
})();
/* ---- session recordings ---- */
function fmtSize(b) { b = +b || 0; if (b < 1024) return b + " B"; if (b < 1048576) return (b / 1024).toFixed(1) + " KB"; if (b < 1073741824) return (b / 1048576).toFixed(1) + " MB"; return (b / 1073741824).toFixed(2) + " GB"; }
function fmtDur(s) { s = +s || 0; if (!s) return "—"; var m = Math.floor(s / 60), ss = s % 60; return m + ":" + ("" + ss).padStart(2, "0"); }
function loadRecordings() {
loaded.recordings = true;
getJSON("/api/recordings").then(function (r) {
if (!r.ok) { toast(r.d.error || "failed", true); return; }
var rs = r.d.recordings || [];
$("#recRetention").textContent = r.d.retention_days > 0 ? ("Auto-deleted after " + r.d.retention_days + " days.") : "Kept indefinitely (no retention limit set).";
$("#recCount").textContent = rs.length + " recording" + (rs.length === 1 ? "" : "s");
$("#recBody").innerHTML = rs.map(function (x) {
return '<tr><td>' + x.id + '</td><td><b>' + esc(x.host_id) + '</b></td><td class="muted">' + esc(x.owner || "—") + '</td>' +
'<td>' + esc(x.viewer || "—") + '</td><td>' + fmtSize(x.size) + '</td><td>' + fmtDur(x.duration) + '</td>' +
'<td class="muted">' + fmtTs(x.created) + '</td>' +
'<td style="white-space:nowrap"><a class="b" href="/api/recordings/' + x.id + '/download">Download</a> <button class="b b-danger" data-recdel="' + x.id + '">Delete</button></td></tr>';
}).join("") || '<tr><td colspan="8" class="muted" style="padding:24px">No recordings yet. Set a <b>record_required</b> MDM policy on an account, then its hosts upload session recordings here.</td></tr>';
$("#recBody").querySelectorAll("[data-recdel]").forEach(function (b) {
b.onclick = function () { if (!confirm("Delete this recording permanently?")) return;
post("/api/recordings/delete", { id: +b.dataset.recdel }).then(function (x) { toast(x.ok ? "Deleted" : (x.d.error || "failed"), !x.ok); loadRecordings(); }); };
});
});
}
/* ---- conditional access rules ---- */
var DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
var editRuleId = null;
function minsToHHMM(m) { if (m == null || m === "") return ""; m = +m; return (("" + Math.floor(m / 60)).padStart(2, "0")) + ":" + (("" + (m % 60)).padStart(2, "0")); }
function hhmmToMins(s) { s = (s || "").trim(); if (!s) return null; var p = s.split(":"); if (p.length !== 2) return null; var h = +p[0], m = +p[1]; if (isNaN(h) || isNaN(m)) return null; return h * 60 + m; }
function acOwner() { var s = $("#acAcct"); return (s && s.value) || ""; }
function loadAccess() {
loaded.access = true;
// day checkboxes
$("#acDays").innerHTML = DAYS.map(function (d, i) { return '<label><input type="checkbox" data-day="' + i + '"> ' + d + '</label>'; }).join("");
// account selector (reuse users list)
getJSON("/api/admin/users").then(function (r) {
var us = (r.d && r.d.users) || [], me = (r.d && r.d.me) || "";
$("#acAcct").innerHTML = us.map(function (u) { return '<option value="' + esc(u.email) + '"' + (u.email === me ? " selected" : "") + '>' + esc(u.email) + '</option>'; }).join("");
loadRules();
});
var lb = $("#acLoad"); if (lb) lb.onclick = loadRules;
}
function condText(r) {
var bits = [];
if (r.cidr) bits.push("from " + esc(r.cidr));
if (r.weekdays) bits.push(r.weekdays.split(",").map(function (i) { return DAYS[+i] || i; }).join(" "));
if (r.time_start != null && r.time_end != null) bits.push(minsToHHMM(r.time_start) + "–" + minsToHHMM(r.time_end) + (r.tz_offset ? " (UTC" + (r.tz_offset >= 0 ? "+" : "") + (r.tz_offset / 60) + ")" : " UTC"));
return bits.join(" · ") || '<span class="faint">any</span>';
}
function subjText(r) {
if (r.subject_type === "any") return '<span class="faint">any connection</span>';
return esc(r.subject_type) + ": <b>" + esc(r.subject || "—") + "</b>";
}
function loadRules() {
var owner = acOwner();
getJSON("/api/access/rules?owner=" + encodeURIComponent(owner)).then(function (r) {
if (!r.ok) { toast(r.d.error || "failed", true); return; }
var rs = r.d.rules || [];
$("#acBody").innerHTML = rs.map(function (x) {
return '<tr><td>' + x.priority + '</td>' +
'<td><span class="ac-pill ' + (x.action === "allow" ? "ac-allow" : "ac-deny") + '">' + x.action + '</span></td>' +
'<td>' + subjText(x) + '</td><td class="ac-cond">' + condText(x) + '</td>' +
'<td class="muted">' + esc(x.reason || "") + '</td>' +
'<td><input type="checkbox" data-toggle="' + x.id + '"' + (x.enabled ? " checked" : "") + '></td>' +
'<td style="white-space:nowrap"><button class="b" data-edit="' + x.id + '">Edit</button> <button class="b b-danger" data-del="' + x.id + '">Delete</button></td></tr>';
}).join("") || '<tr><td colspan="7" class="muted" style="padding:22px">No rules — every connection is allowed. Add a rule below.</td></tr>';
$("#acBody").querySelectorAll("[data-del]").forEach(function (b) {
b.onclick = function () { if (!confirm("Delete this rule?")) return;
post("/api/access/rules/delete", { id: +b.dataset.del }).then(function (x) { toast(x.ok ? "Deleted" : "failed", !x.ok); loadRules(); }); };
});
$("#acBody").querySelectorAll("[data-toggle]").forEach(function (cb) {
cb.onchange = function () { var rule = rs.filter(function (z) { return z.id === +cb.dataset.toggle; })[0]; if (!rule) return;
post("/api/access/rules", ruleBody(rule, { enabled: cb.checked })).then(function (x) { toast(x.ok ? (cb.checked ? "Enabled" : "Disabled") : "failed", !x.ok); }); };
});
$("#acBody").querySelectorAll("[data-edit]").forEach(function (b) {
b.onclick = function () { var rule = rs.filter(function (z) { return z.id === +b.dataset.edit; })[0]; if (rule) fillRuleForm(rule); };
});
});
}
function ruleBody(base, over) {
var b = { owner: acOwner(), id: base.id, priority: base.priority, action: base.action, subject_type: base.subject_type,
subject: base.subject, cidr: base.cidr, weekdays: base.weekdays, time_start: base.time_start, time_end: base.time_end,
tz_offset: base.tz_offset, reason: base.reason, enabled: base.enabled };
for (var k in (over || {})) b[k] = over[k];
return b;
}
function fillRuleForm(r) {
editRuleId = r.id;
$("#acFormTitle").textContent = "Edit rule #" + r.id;
$("#acReset").hidden = false;
$("#acPrio").value = r.priority; $("#acAction").value = r.action; $("#acSubType").value = r.subject_type;
$("#acSubject").value = r.subject || ""; $("#acCidr").value = r.cidr || ""; $("#acReason").value = r.reason || "";
$("#acTimeStart").value = minsToHHMM(r.time_start); $("#acTimeEnd").value = minsToHHMM(r.time_end); $("#acTz").value = r.tz_offset || 0;
var days = (r.weekdays || "").split(",");
$("#acDays").querySelectorAll("[data-day]").forEach(function (cb) { cb.checked = days.indexOf(cb.dataset.day) >= 0; });
try { $("#acFormTitle").scrollIntoView({ behavior: "smooth", block: "center" }); } catch (e) {}
}
function resetRuleForm() {
editRuleId = null; $("#acFormTitle").textContent = "Add a rule"; $("#acReset").hidden = true;
$("#acPrio").value = 100; $("#acAction").value = "deny"; $("#acSubType").value = "any";
$("#acSubject").value = ""; $("#acCidr").value = ""; $("#acReason").value = ""; $("#acTimeStart").value = ""; $("#acTimeEnd").value = ""; $("#acTz").value = 0;
$("#acDays").querySelectorAll("[data-day]").forEach(function (cb) { cb.checked = false; });
}
(function wireAccess() {
var sv = $("#acSave"); if (sv) sv.onclick = function () {
var days = [];
$("#acDays").querySelectorAll("[data-day]").forEach(function (cb) { if (cb.checked) days.push(cb.dataset.day); });
var body = { owner: acOwner(), priority: +$("#acPrio").value || 100, action: $("#acAction").value,
subject_type: $("#acSubType").value, subject: $("#acSubject").value.trim(), cidr: $("#acCidr").value.trim(),
reason: $("#acReason").value.trim(), weekdays: days.join(","), tz_offset: +$("#acTz").value || 0,
time_start: hhmmToMins($("#acTimeStart").value), time_end: hhmmToMins($("#acTimeEnd").value), enabled: true };
if (editRuleId) body.id = editRuleId;
post("/api/access/rules", body).then(function (x) {
toast(x.ok ? "Rule saved" : (x.d.error || "failed"), !x.ok);
if (x.ok) { resetRuleForm(); loadRules(); } });
};
var rs = $("#acReset"); if (rs) rs.onclick = resetRuleForm;
var tr = $("#tRun"); if (tr) tr.onclick = function () {
var q = new URLSearchParams({ owner: acOwner(), viewer: $("#tViewer").value.trim(), host: $("#tHost").value.trim(), group: $("#tGroup").value.trim(), ip: $("#tIp").value.trim() });
getJSON("/api/access/test?" + q.toString()).then(function (x) {
if (!x.ok) { $("#tResult").innerHTML = '<span class="ac-res-no">' + esc(x.d.error || "error") + '</span>'; return; }
var d = x.d;
$("#tResult").innerHTML = d.allowed
? '<span class="ac-res-ok">✓ ALLOWED</span>' + (d.rule_id ? ' <span class="faint">by rule #' + d.rule_id + '</span>' : ' <span class="faint">(no rule matched — default allow)</span>')
: '<span class="ac-res-no">✕ DENIED</span> <span class="faint">rule #' + esc(d.rule_id) + ' — ' + esc(d.reason) + '</span>';
});
};
})();
})();
+1302
View File
File diff suppressed because it is too large. Load diff
+351
View File
@@ -0,0 +1,351 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NetDesk — The Fast Self-Hosted Remote Desktop</title>
<link rel="stylesheet" href="app.css?v=53">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#3d7eff">
<meta name="robots" content="noindex, nofollow">
</head>
<body>
<div id="screenConnect">
<div class="topnote">ⓘ <b>netdesk.space</b> is the only official domain for this NetDesk server. No third-party cloud ever sees your screen.</div>
<header class="nav">
<div class="nav-brand">
<svg width="30" height="30" viewBox="0 0 32 32"><defs><linearGradient id="ndg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#25d386"/><stop offset="1" stop-color="#16a765"/></linearGradient></defs><rect width="32" height="32" rx="8" fill="url(#ndg)"/><path d="M16 6.5L6.5 18.2C6.1 18.7 6.4 19.4 7 19.4H25C25.6 19.4 25.9 18.7 25.5 18.2L16 6.5Z" fill="#fff"/><path d="M11.8 21H20.2L21.5 26.4C21.6 26.8 21.3 27.2 20.9 27.2H11.1C10.7 27.2 10.4 26.8 10.5 26.4L11.8 21Z" fill="#fff"/></svg>
<b>NetDesk</b>
</div>
<nav class="nav-links">
<a href="/">Home</a>
<a href="/features">Features</a>
<a href="/download">Download</a>
<button id="btnTheme" class="theme-toggle" title="Switch theme">☀ Light</button>
<span id="navAuth"></span>
</nav>
</header>
<section class="hero">
<div class="hero-copy">
<h1>The Fast Self-Hosted<br>Remote Desktop</h1>
<p class="hero-sub">Your own remote access and support software — unattended, silent in the
background, end-to-end encrypted, with your own server. The alternative to TeamViewer,
AnyDesk and RustDesk that you fully control.</p>
<div class="hero-cta">
<a class="btn-big btn-blue" href="#download">Download <span class="dl-ic">⬇</span></a>
<a class="btn-big btn-ghost" href="/portal">Self-Hosting</a>
</div>
</div>
<div class="hero-card">
<div class="card" id="connect">
<h2>Connect to a computer</h2>
<p class="sub">Enter the remote ID shown by the NetDesk agent.</p>
<label>Remote ID</label>
<input id="inId" type="text" inputmode="numeric" pattern="[0-9]*" placeholder="123 456 789" spellcheck="false" autocomplete="off">
<label>Password</label>
<div class="pw-wrap">
<input id="inPw" type="password" placeholder="permanent password" autocomplete="off">
<button id="btnPwEye" class="ghost" type="button" title="Show password"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
</div>
<label id="lblTotp" hidden>2FA code</label>
<input id="inTotp" type="text" inputmode="numeric" maxlength="6" placeholder="6-digit authenticator code" autocomplete="off" hidden>
<details style="margin-top:14px">
<summary class="foot" style="cursor:pointer;user-select:none">Advanced — direct connection</summary>
<label>Server</label>
<input id="inServer" type="text" placeholder="blank = this server · or ws://PC-IP:8901 for direct LAN" spellcheck="false">
</details>
<label class="chk"><input id="inRemember" type="checkbox"> Remember password on this device</label>
<label class="chk"><input id="inViewOnly" type="checkbox"> View only (no input)</label>
<button id="btnConnect" class="primary">Connect</button>
<div id="connectErr" class="error" hidden></div>
<style>
#hostShare{margin-top:22px;padding-top:20px;border-top:1px solid rgba(148,163,184,.25);text-align:center}
#hostShare .host-or{font-size:12px;color:#8a97ab;letter-spacing:1px;margin-bottom:12px}
#hostShare h3{margin:0 0 6px;font-size:16px}
#hostShare .host-sub{margin:0 0 14px;font-size:12.5px;color:#8a97ab;line-height:1.5}
#hostShare .host-cred{font-size:13px;margin-bottom:14px;color:#cdd6e4}
#hostShare .host-cred b{font-size:15px;letter-spacing:.5px;color:#3d7eff;font-variant-numeric:tabular-nums}
#hostShare #btnHostRegen{padding:2px 8px;font-size:13px;vertical-align:middle;margin-left:6px}
#hostShare .host-status{margin-top:12px;font-size:12.5px;color:#8a97ab;min-height:16px}
</style>
<div id="hostShare">
<div class="host-or">— OR —</div>
<h3>Share THIS screen</h3>
<p class="host-sub">Let someone view this computer right from their browser — no app to install.<br>(View-only: they can see &amp; hear it, not control it.)</p>
<div class="host-cred">ID <b id="hostIdOut">—</b> &nbsp;·&nbsp; Password <b id="hostPwOut">—</b><button id="btnHostRegen" class="tb" title="Generate a new ID + password">↻</button></div>
<button id="btnHostStart" class="primary">Share my screen</button>
<button id="btnHostStop" class="tb" hidden>Stop sharing</button>
<div id="hostStatus" class="host-status"></div>
</div>
<div id="myDevsWrap" hidden>
<div class="ab-head"><span>My computers</span><span class="foot">click to connect</span></div>
<div id="myDevs"></div>
</div>
<div id="abookWrap">
<div class="ab-head"><span>Address book</span><button id="btnAbAdd" class="tb">+ Save current</button></div>
<div id="abook" hidden></div>
</div>
<div class="hint">
Direct connection (no server): put <code>ws://IP:8901</code> in the Server
field when the host runs with <code>--listen</code> on your LAN or via port-forward.
</div>
<div class="form-foot">
<span class="foot">NetDesk viewer</span>
<span style="display:flex;gap:8px;align-items:center">
<span id="authChip" class="foot" hidden></span>
</span>
</div>
</div>
</div>
</section>
</div><!-- /screenConnect -->
<div id="authModal" class="modal-wrap" hidden>
<div class="modal-card auth-card">
<button id="btnAuthClose" class="modal-x" title="Close">✕</button>
<div class="auth-head">
<span class="auth-logo"><svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M12 3.2L3.2 12.6C2.8 13 3.1 13.7 3.7 13.7H20.3C20.9 13.7 21.2 13 20.8 12.6L12 3.2Z" fill="#fff"/><path d="M8.7 15.1H15.3L16.4 20.3C16.5 20.7 16.2 21 15.8 21H8.2C7.8 21 7.5 20.7 7.6 20.3L8.7 15.1Z" fill="#fff"/></svg></span>
<span class="auth-brand">NetDesk <i>Account</i></span>
</div>
<div class="auth-tabs">
<button class="atab on" data-t="in">Sign in</button>
<button class="atab" data-t="up">Create account</button>
<button class="atab" data-t="fp">Forgot</button>
<button class="atab" data-t="2fa" id="atab2fa" hidden>Two-factor</button>
</div>
<div class="apane" id="pane-in">
<h3 class="auth-title">Welcome back</h3>
<p class="auth-sub">Sign in to see your computers and connect in one click.</p>
<label class="auth-lbl" for="auEmail">Email address</label>
<input id="auEmail" type="email" placeholder="you@company.com" autocomplete="email" spellcheck="false">
<label class="auth-lbl" for="auPass">Password</label>
<input id="auPass" type="password" placeholder="••••••••••" autocomplete="current-password">
<label class="auth-lbl" id="auTotpLbl" for="auTotp" hidden>Two-factor code</label>
<input id="auTotp" type="text" inputmode="numeric" maxlength="6" placeholder="6-digit authenticator code" autocomplete="one-time-code" hidden>
<button id="auGo" class="primary auth-cta">Sign in&nbsp;&nbsp;→</button>
<div id="ssoWrap" hidden style="margin-top:12px">
<div style="display:flex;align-items:center;gap:10px;color:var(--faint);font-size:12px;margin:4px 0 10px"><span style="flex:1;height:1px;background:var(--border)"></span>OR<span style="flex:1;height:1px;background:var(--border)"></span></div>
<a id="ssoBtn" class="btn-big btn-ghost" href="/saml/login?RelayState=/app" style="width:100%;justify-content:center;box-sizing:border-box">Sign in with SSO</a>
</div>
</div>
<div class="apane" id="pane-up" hidden>
<h3 class="auth-title">Create your account</h3>
<p class="auth-sub">Free for individuals — your fleet syncs between the desktop app and this site.</p>
<label class="auth-lbl" for="auEmail2">Email address</label>
<input id="auEmail2" type="email" placeholder="you@company.com" autocomplete="email" spellcheck="false">
<label class="auth-lbl" for="auPass2">Password</label>
<input id="auPass2" type="password" placeholder="8+ characters" autocomplete="new-password">
<button id="auGo2" class="primary auth-cta">Create account&nbsp;&nbsp;→</button>
<div class="foot">Works right away — sign in with the same account in the NetDesk desktop app and your computers appear here automatically.</div>
</div>
<div class="apane" id="pane-fp" hidden>
<h3 class="auth-title">Reset your password</h3>
<p class="auth-sub">We'll email you a secure link to choose a new one.</p>
<label class="auth-lbl" for="auEmail3">Account email</label>
<input id="auEmail3" type="email" placeholder="you@company.com" autocomplete="email" spellcheck="false">
<button id="auGo3" class="primary auth-cta">Send reset link</button>
</div>
<div class="apane" id="pane-rs" hidden>
<h3 class="auth-title">Choose a new password</h3>
<label class="auth-lbl" for="auPass4">New password</label>
<input id="auPass4" type="password" placeholder="8+ characters" autocomplete="new-password">
<button id="auGo4" class="primary auth-cta">Set new password</button>
</div>
<div class="apane" id="pane-2fa" hidden>
<h3 class="auth-title">Two-factor authentication</h3>
<p class="auth-sub">Add a 6-digit authenticator code on top of your password at sign-in.</p>
<div id="tf2Status" class="foot">Checking…</div>
<button id="tf2Enable" class="primary auth-cta" hidden>Enable 2FA&nbsp;&nbsp;→</button>
<div id="tf2Enroll" hidden>
<p class="auth-sub">Add this secret to your authenticator app (Google Authenticator, Authy, 1Password…), then enter a code to confirm.</p>
<label class="auth-lbl">Secret</label>
<input id="tf2Secret" type="text" readonly spellcheck="false">
<div id="tf2Uri" class="foot" style="word-break:break-all;margin:4px 0 8px"></div>
<label class="auth-lbl" for="tf2Code">Confirmation code</label>
<input id="tf2Code" type="text" inputmode="numeric" maxlength="6" placeholder="6-digit code" autocomplete="one-time-code">
<button id="tf2Confirm" class="primary auth-cta">Confirm &amp; enable&nbsp;&nbsp;→</button>
</div>
<div id="tf2DisableWrap" hidden>
<label class="auth-lbl" for="tf2DisCode">Enter a current code to turn 2FA off</label>
<input id="tf2DisCode" type="text" inputmode="numeric" maxlength="6" placeholder="6-digit code" autocomplete="one-time-code">
<button id="tf2Disable" class="auth-cta">Disable 2FA</button>
</div>
</div>
<div id="auMsg" class="error" hidden></div>
<div id="auOk" class="auok" hidden></div>
<div class="auth-secure">🔒 TLS 1.3 in transit · passwords stored as PBKDF2-SHA256 · 200,000 rounds</div>
</div>
</div>
<div id="screenSession" class="screen" hidden>
<div id="toolbar">
<div class="tb-group">
<span id="tbTitle" class="tb-title">NetDesk</span>
<span id="tbState" class="state">connecting…</span>
</div>
<div class="sep"></div>
<div class="tb-group" id="menubar">
<button class="tb menu-btn" data-menu="mActions">Actions ▾</button>
<button class="tb menu-btn" data-menu="mView">View ▾</button>
<button class="tb menu-btn" data-menu="mComm">Communicate ▾</button>
<button class="tb menu-btn" data-menu="mExtras">Extras ▾</button>
</div>
<div class="tmenu" id="mComm" hidden>
<button class="mi" id="miMsg">Send message… (chat)</button>
<button class="mi mtoggle" id="miSound">Play remote computer sound<span class="mtick">✓</span></button>
<div class="msep"></div>
<button class="mi mtoggle" id="miVoice" title="Talk to the person at the host PC using your microphone">Start voice call (your mic)<span class="mtick">✓</span></button>
</div>
<div class="tmenu" id="mActions" hidden>
<button class="mi" data-sys="lock">Lock remote computer</button>
<button class="mi" data-sys="logoff" data-confirm="Sign out the remote user?">Sign out on remote computer</button>
<button class="mi" data-sys="reboot" data-confirm="Reboot the remote computer? The session will end.">Remote reboot</button>
<button class="mi" data-sys="cad">Send Ctrl + Alt + Del</button>
<div class="msep"></div>
<button class="mi" id="miWol">Wake a PC on this network (Wake-on-LAN)…</button>
<div class="msep"></div>
<button class="mi mtoggle" id="miBlockInput">Block remote user's input<span class="mtick">✓</span></button>
<button class="mi mtoggle" id="miLockEnd">Lock on session end<span class="mtick">✓</span></button>
<div class="msep"></div>
<button class="mi" id="miInfo">Session information…</button>
</div>
<div class="tmenu" id="mView" hidden>
<div class="mhead">Monitor</div>
<div id="miMons"></div>
<div class="mhead">Quality</div>
<div id="miQual"></div>
<div class="mhead">Scaling</div>
<button class="mi mradio" data-scale="fit">Best fit<span class="mtick">✓</span></button>
<button class="mi mradio" data-scale="orig">Original (1:1, scroll)<span class="mtick">✓</span></button>
<div class="mhead">Screen resolution</div>
<div id="miModes"><button class="mi" id="miModesLoad">Load resolutions…</button></div>
<div class="msep"></div>
<button class="mi mtoggle" id="miWallpaper">Hide wallpaper<span class="mtick">✓</span></button>
<button class="mi" id="miRefresh">Refresh screen</button>
</div>
<div class="tmenu" id="mExtras" hidden>
<button class="mi" id="miFiles">File transfer</button>
<button class="mi" id="miChat">Chat</button>
<button class="mi" id="miShot">Save screenshot</button>
<button class="mi" id="miRec">Record session (WebM)</button>
</div>
<div class="tb-group">
<select id="selMonitor" title="Monitor"></select>
<select id="selQuality" title="Quality">
<option value="auto" selected>Auto (adaptive)</option>
<option value="low">Low · 15fps · 2 Mbps</option>
<option value="balanced">Balanced · 30fps · 6 Mbps</option>
<option value="high">High · 30fps · 12 Mbps</option>
<option value="extreme">Extreme · 60fps · 20 Mbps</option>
</select>
</div>
<div class="sep"></div>
<div class="tb-group">
<button id="btnViewOnly" class="tb" title="Toggle remote input"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3l18 18"/><path d="M10.6 5.1A9.8 9.8 0 0 1 12 5c7 0 11 8 11 8a13.2 13.2 0 0 1-1.7 2.4"/><path d="M6.6 6.6C4 8.3 2 12 2 12s4 8 12 8a9.7 9.7 0 0 0 4.3-1"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/></svg><span>Input</span></button>
<button id="btnOsk" class="tb touch-only" title="Open the on-screen keyboard">⌨<span>Type</span></button>
<button id="btnKbdLock" class="tb" title="Capture system keys while fullscreen"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10"/></svg><span>Keys</span></button>
<button id="btnMonCycle" class="tb" title="Switch monitor" hidden><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="9" y="11" width="13" height="9" rx="1.5"/></svg><span id="monCycleLbl">1/2</span></button>
<button id="btnFullscreen" class="tb" title="Fullscreen"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"/></svg></button>
<button id="btnClipboard" class="tb" title="Clipboard sync"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/></svg></button>
<button id="btnFiles" class="tb" title="File manager"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button id="btnChat" class="tb" title="Chat"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
<button id="btnAudio" class="tb" title="Host audio: off → system → microphone"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H2v6h4l5 4V5z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M19 5a8 8 0 0 1 0 14"/></svg><span id="audioLbl">Audio</span></button>
<button id="btnSys" class="tb" title="Power / processes"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18.4 6.6a9 9 0 1 1-12.8 0"/><path d="M12 2v10"/></svg></button>
<button id="btnPrivacy" class="tb" title="Privacy mode"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.9 17.9A9.8 9.8 0 0 1 12 20c-8 0-11-8-11-8a13.4 13.4 0 0 1 4.1-4.4M9.9 4.2A9.6 9.6 0 0 1 12 4c8 0 11 8 11 8a13.5 13.5 0 0 1-2.2 3.1"/><path d="M3 3l18 18"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/></svg></button>
<button id="btnRec" class="tb" title="Record session"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4" fill="currentColor" stroke="none"/></svg></button>
</div>
<div class="sep"></div>
<div class="tb-group">
<span id="tbStats" class="stats"></span>
<button id="btnDisconnect" class="tb danger" title="Disconnect"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg><span>Disconnect</span></button>
</div>
<button id="tbTab" title="Hide the toolbar">▲</button>
</div>
<button id="tbShow" title="Show the toolbar" hidden>▼ NetDesk</button>
<input id="oskInput" type="text" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" aria-hidden="true">
<div id="stage">
<canvas id="canvas" tabindex="0"></canvas>
<img id="remoteCursor" alt="" hidden>
<div id="dropHint" hidden>Drop files to upload to the remote desktop</div>
<div id="banner" class="banner" hidden></div>
<div id="localCursor" hidden></div>
<div id="filePanel" class="panel" data-title="Files" hidden>
<div class="panel-head" data-panel="filePanel"><span>Remote files</span><button class="tb" data-min="filePanel" title="Minimize to edge">»</button><button class="tb" id="btnFilesClose" title="Close">✕</button></div>
<div class="file-path">
<input id="filePath" spellcheck="false">
<button id="btnFileGo" class="tb">Go</button>
<button id="btnFileUp" class="tb" title="Up">⬆</button>
<button id="btnFileHome" class="tb" title="Drives">💾</button>
</div>
<div id="fileList" class="file-list"></div>
<div class="panel-foot">
<button id="btnFileMkdir" class="tb">New folder</button>
<button id="btnFileUpload" class="tb">Upload…</button>
<input id="fileInput" type="file" multiple hidden>
<button id="btnDirUpload" class="tb" title="Upload a folder">Folder…</button>
<input id="dirInput" type="file" webkitdirectory multiple hidden>
<span id="fileStatus" class="stats"></span>
</div>
</div>
<div id="chatPanel" class="panel" data-title="Chat" hidden>
<div class="panel-head" data-panel="chatPanel"><span>Chat</span><button class="tb" data-min="chatPanel" title="Minimize to edge">»</button><button class="tb" id="btnChatClose" title="Close">✕</button></div>
<div id="chatLog" class="chat-log"></div>
<div class="chat-input">
<input id="chatText" placeholder="Type a message…">
<button id="chatSend" class="tb">Send</button>
</div>
</div>
<div id="sysPanel" class="panel" data-title="Remote power" hidden>
<div class="panel-head" data-panel="sysPanel"><span>Remote power</span><button class="tb" data-min="sysPanel" title="Minimize to edge">»</button><button class="tb" id="btnSysClose" title="Close">✕</button></div>
<details class="sec" open>
<summary>Power actions<span class="chev">▾</span></summary>
<div class="sys-grid">
<button class="tb" data-cmd="lock">Lock workstation</button>
<button class="tb" data-cmd="logoff">Sign out user</button>
<button class="tb" data-cmd="reboot">Reboot</button>
<button class="tb danger" data-cmd="shutdown">Shut down</button>
</div>
<div class="panel-foot">
<input id="sysCmd" placeholder="run a command on host (cmd /c …)">
<button id="btnSysRun" class="tb">Run</button>
</div>
</details>
<details class="sec" open>
<summary>Options<span class="chev">▾</span></summary>
<div class="panel-foot">
<label class="chk" style="margin:0"><input id="chkLockOnEnd" type="checkbox"> Lock PC when I disconnect</label>
</div>
</details>
<details class="sec">
<summary>Processes<span class="chev">▾</span><button id="btnProcRefresh" class="tb" style="margin-left:auto">↻ Refresh</button></summary>
<div id="procList" class="file-list" style="max-height:200px"></div>
</details>
<details class="sec">
<summary>TCP tunnel <span class="muted" style="text-transform:none">(portal sign-in)</span><span class="chev">▾</span></summary>
<div class="panel-foot">
<input id="tnlPort" placeholder="port on the host" style="width:120px">
<button id="btnTnlOpen" class="tb">Connect</button>
<span id="tnlStatus" class="stats"></span>
</div>
</details>
</div>
</div>
</div>
<script src="app.js?v=55"></script>
<script src="host.js?v=5"></script>
</body>
</html>
+2995
View File
File diff suppressed because it is too large. Load diff
+50
View File
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Contact — NetDesk</title>
<meta name="description" content="Contact NetDesk for sales, support, security or partnerships.">
<link rel="canonical" href="https://netdesk.space/contact">
<meta property="og:title" content="Contact — NetDesk">
<meta property="og:description" content="Contact NetDesk for sales, support, security or partnerships.">
<meta property="og:url" content="https://netdesk.space/contact">
<meta name="theme-color" content="#0a0d14">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="site.css?v=2">
</head>
<body data-page="contact">
<div id="siteHeader"></div>
<section class="section"><div class="wrap">
<div class="sec-head"><div class="eyebrow">Contact</div><h2>Get in touch</h2><p>Sales, support, security or partnerships — we usually reply within one business day.</p></div>
<div class="contact-grid" style="max-width:960px;margin:0 auto">
<form class="feat" id="contactForm" onsubmit="return ndContact(event)">
<div class="field"><label>Name</label><input name="name" required placeholder="Your name"></div>
<div class="field"><label>Email</label><input name="email" type="email" required placeholder="you@company.com"></div>
<div class="field"><label>Topic</label><select name="topic"><option>General</option><option>Sales / Pro</option><option>Business / Enterprise</option><option>Support</option><option>Security</option><option>macOS beta</option></select></div>
<div class="field"><label>Message</label><textarea name="message" rows="5" required placeholder="How can we help?"></textarea></div>
<button class="btn btn-primary" type="submit" style="width:100%;justify-content:center">Send message</button>
<p id="cMsg" style="margin-top:12px;font-size:14px"></p>
</form>
<div>
<div class="info-row"><span class="ic">✉️</span><div><strong>Email</strong><br><a href="mailto:hello@netdesk.space" style="color:var(--accent2)">hello@netdesk.space</a></div></div>
<div class="info-row"><span class="ic">🛟</span><div><strong>Support</strong><br><a href="mailto:support@netdesk.space" style="color:var(--accent2)">support@netdesk.space</a></div></div>
<div class="info-row"><span class="ic">🔒</span><div><strong>Security</strong><br><a href="mailto:security@netdesk.space" style="color:var(--accent2)">security@netdesk.space</a></div></div>
<div class="info-row"><span class="ic">💻</span><div><strong>Open source</strong><br><a href="/git/akronlogic/netdesk" style="color:var(--accent2)">netdesk.space/git/akronlogic/netdesk</a></div></div>
<p style="color:var(--muted);font-size:14px;margin-top:18px">Prefer to self-host and never talk to us? That's the whole point — <a href="/download" style="color:var(--accent2)">just download it</a>.</p>
</div>
</div>
</div></section>
<script>
function ndContact(e){e.preventDefault();var f=e.target,m=document.getElementById("cMsg");
fetch("/api/contact",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:f.name.value,email:f.email.value,topic:f.topic.value,message:f.message.value})})
.then(function(r){return r.json().catch(function(){return{}})}).then(function(d){if(d&&d.ok){m.style.color="var(--ok)";m.textContent="Thanks — your message has been sent. We'll be in touch.";f.reset();}else{m.style.color="#f2666b";m.textContent=(d&&d.error)||"Could not send right now. Please email hello@netdesk.space.";}}).catch(function(){m.style.color="#f2666b";m.textContent="Could not send right now. Please email hello@netdesk.space.";});return false;}
var q=new URLSearchParams(location.search);if(q.get("plan")||q.get("topic")){var s=document.querySelector("select[name=topic]");var v=q.get("plan")==="pro"?"Sales / Pro":q.get("plan")==="business"?"Business / Enterprise":q.get("topic")==="mac"?"macOS beta":null;if(v&&s)[].forEach.call(s.options,function(o){if(o.text===v)o.selected=true;});}
</script>
<div id="siteFooter"></div>
<script src="site.js?v=3"></script>
</body>
</html>
+202
View File
@@ -0,0 +1,202 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Download — NetDesk</title>
<meta name="description" content="Download NetDesk free for Windows. Native macOS build for Apple Silicon coming soon. Zero-install web client for viewers.">
<link rel="canonical" href="https://netdesk.space/download">
<meta property="og:title" content="Download — NetDesk">
<meta property="og:description" content="Download NetDesk free for Windows. Native macOS build for Apple Silicon coming soon. Zero-install web client for viewers.">
<meta property="og:url" content="https://netdesk.space/download">
<meta name="theme-color" content="#0a0d14">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="site.css?v=2">
<style>
/* Exact replica of the NetDesk desktop app — the unified full client you install. */
@keyframes rise { from{opacity:0; transform: translateY(22px)} to{opacity:1; transform: none} }
.appshot { margin: 0 auto; max-width: 1040px; border-radius: 14px; overflow: hidden;
border: 1px solid #263241; background: #11161d; text-align: left; font-size: 13px;
font-family: 'Segoe UI','Inter',system-ui,sans-serif;
box-shadow: 0 60px 120px -30px rgba(0,0,0,.8), 0 30px 70px -40px rgba(25,200,126,.4),
inset 0 1px 0 rgba(255,255,255,.04);
animation: rise .9s .22s both; }
.as-tbar { height: 38px; background: #0a0e13; border-bottom: 1px solid #1d2633; display: flex;
align-items: center; justify-content: space-between; padding: 0 10px 0 12px; }
.as-tl { display: flex; align-items: center; gap: 10px; }
.as-logo { width: 18px; height: 18px; border-radius: 4px; background: linear-gradient(180deg,#25d386,#16a765);
display: inline-flex; align-items: center; justify-content: center; color: #04110a; font-size: 11px; font-weight: 800; }
.as-title { font-size: 12.5px; color: #d8dee9; font-weight: 600; }
.as-sub { font-size: 11px; color: #64748b; }
.as-tr { display: flex; align-items: center; gap: 10px; }
.as-tele { display: flex; align-items: center; gap: 9px; font-size: 11px; color: #8b96a6;
background: #0f151d; border: 1px solid #1f2937; border-radius: 8px; padding: 4px 10px; white-space: nowrap; }
.as-tele b { color: #25db8e; font-family: Consolas,monospace; font-weight: 600; }
.as-win { display: flex; gap: 4px; color: #8b96a6; font-size: 12px; }
.as-win span { width: 26px; text-align: center; }
.as-body { display: grid; grid-template-columns: 216px 1fr; min-height: 430px; }
.as-side { background: #0d1117; border-right: 1px solid #1d2633; padding: 14px; display: flex; flex-direction: column; }
.as-brand { display: flex; align-items: center; gap: 10px; }
.as-brand .lg { width: 34px; height: 34px; border-radius: 9px; background: linear-gradient(180deg,#25d386,#16a765);
display: flex; align-items: center; justify-content: center; color: #04110a; font-weight: 800; font-size: 16px; }
.as-brand b { color: #e6edf3; font-size: 16px; } .as-brand i { font-style: normal; font-size: 10.5px; color: #64748b; }
.as-navlbl { font-size: 10px; letter-spacing: 1.6px; color: #5b6878; margin: 18px 0 8px; font-weight: 700; }
.as-nav { display: grid; gap: 4px; }
.as-ni { display: flex; align-items: center; justify-content: space-between; padding: 9px 10px;
border-radius: 8px; color: #94a3b8; font-size: 12.5px; border: 1px solid transparent; }
.as-ni.act { background: rgba(25,200,126,.1); color: #25db8e; border-color: rgba(25,200,126,.25); font-weight: 600; box-shadow: inset 3px 0 0 #22d08a; }
.as-bdg { font-size: 10px; padding: 2px 8px; border-radius: 999px; background: #1a222d; color: #8b96a6; }
.as-bdg.g { background: rgba(25,200,126,.16); color: #25db8e; }
.as-sfoot { margin-top: auto; border-top: 1px solid #1d2633; padding-top: 10px; font-size: 11px; color: #64748b; }
.as-sfoot b { color: #cbd5e1; font-weight: 600; } .as-sfoot .on { color: #25db8e; }
.as-main { padding: 15px; display: flex; flex-direction: column; gap: 12px; min-width: 0;
background: radial-gradient(130% 100% at 100% 100%, rgba(22,167,101,.07), transparent 55%), linear-gradient(165deg,#121923 0%,#10151c 58%,#0f1714 150%); }
.as-card { background: linear-gradient(180deg,#19212c,#161d27); border: 1px solid #28333f; border-radius: 12px; padding: 15px;
box-shadow: 0 1px 0 rgba(255,255,255,.02) inset, 0 10px 24px -18px rgba(0,0,0,.7); }
.as-partner { display: flex; gap: 10px; align-items: center; padding: 10px 12px; }
.as-plbl { font-size: 10.5px; letter-spacing: 1.2px; color: #25db8e; font-weight: 700; white-space: nowrap; }
.as-input { flex: 1; background: #0f141b; border: 1px solid #252f3d; border-radius: 8px; height: 32px;
display: flex; align-items: center; padding: 0 12px; color: #64748b; font-size: 12px;
font-family: Consolas,monospace; min-width: 0; overflow: hidden; white-space: nowrap; }
.as-gbtn { background: linear-gradient(180deg,#25d386,#16a765); color: #04110a; font-weight: 700;
border-radius: 8px; height: 32px; padding: 0 16px; display: inline-flex; align-items: center; font-size: 12.5px; white-space: nowrap; }
.as-grid { display: grid; grid-template-columns: 1.25fr 1fr; gap: 12px; }
.as-col { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.as-lbl { font-size: 10.5px; letter-spacing: 1.4px; color: #8e9aa9; font-weight: 700; text-transform: uppercase; }
.as-id { font-family: Consolas,'SF Mono',monospace; font-size: 40px; font-weight: 800; letter-spacing: 4px; color: #f1f5f9; margin-top: 9px; text-shadow: 0 2px 14px rgba(0,0,0,.4); }
.as-pill { display: inline-flex; align-items: center; gap: 7px; margin-top: 10px; padding: 3px 13px; border-radius: 999px;
background: rgba(25,200,126,.13); border: 1px solid rgba(25,200,126,.35); color: #25db8e; font-size: 12px; font-weight: 600; }
.as-pill::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: #25db8e; box-shadow: 0 0 7px #25db8e; }
.as-tag { font-size: 10.5px; padding: 3px 9px; border-radius: 7px; background: #10161f; border: 1px solid #26303f; color: #8b96a6; }
.as-field { margin-top: 10px; } .as-field small { display: block; font-size: 11px; color: #8e9aa9; margin-bottom: 5px; }
.as-hd { display: flex; align-items: center; justify-content: space-between; }
.as-run { display: grid; grid-template-columns: 2fr 1fr; gap: 12px; }
.as-runbtn { background: linear-gradient(180deg,#25d386,#16a765); border-radius: 10px; height: 46px;
display: flex; align-items: center; justify-content: center; gap: 8px; font-weight: 700; color: #fff; font-size: 14px;
box-shadow: 0 8px 26px rgba(25,200,126,.35); }
.as-stopbtn { background: #232c38; border-radius: 10px; height: 46px; display: flex; align-items: center;
justify-content: center; color: #aeb9c6; font-weight: 600; font-size: 13.5px; }
.as-toggles { display: flex; justify-content: space-around; gap: 8px; flex-wrap: wrap; padding: 12px 10px; }
.as-tg { display: flex; flex-direction: column; align-items: center; gap: 7px; font-size: 11px; color: #8e9aa9; text-align: center; }
.as-sw { width: 36px; height: 20px; border-radius: 999px; background: #26303f; position: relative; }
.as-sw::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #94a3b8; }
.as-sw.on { background: #19c87e; box-shadow: 0 0 12px rgba(25,200,126,.5); }
.as-sw.on::after { left: auto; right: 2px; background: #fff; }
.as-acct { font-size: 12.5px; color: #cbd5e1; } .as-acct .ok { color: #25db8e; }
.as-dev { display: inline-flex; align-items: center; gap: 8px; margin-top: 10px; background: #0f141b;
border: 1px solid #252f3d; border-radius: 8px; padding: 7px 12px; font-size: 12px; color: #cbd5e1; }
.as-dev i { width: 7px; height: 7px; border-radius: 50%; background: #25db8e; font-style: normal; }
@media (max-width: 760px) { .as-body{grid-template-columns:1fr} .as-side{display:none}
.as-grid{grid-template-columns:1fr} .as-tele{display:none} }
</style>
</head>
<body data-page="download">
<div id="siteHeader"></div>
<section class="section"><div class="wrap">
<div class="sec-head"><div class="eyebrow">Download</div><h2>Get NetDesk</h2><p>Portable, no installation required. Double-click and go.</p></div>
<div class="grid3" style="max-width:1060px;margin:0 auto">
<div class="feat" style="text-align:center">
<div class="ic" style="margin:0 auto 16px"><svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor"><path d="M3 5.5L10 4.5v7H3zM11 4.3L21 3v8.5h-10zM3 12.5h7v7L3 18.5zM11 12.5h10V21l-10-1.3z"/></svg></div>
<h3>Windows 10 / 11 (x64)</h3>
<p>The native NetDesk app — host panel and remote viewer in one window. No browser.</p>
<a class="btn btn-primary btn-lg" style="margin-top:12px" href="/download/NetDesk-Setup.exe">⬇ Download Installer</a>
<p style="font-size:13px;color:var(--faint);margin-top:12px"><a href="/download/NetDesk-Portable.zip" style="color:var(--accent2)">or the portable .zip</a> — no install needed</p>
</div>
<div class="feat" style="text-align:center">
<div class="ic" style="margin:0 auto 16px"><svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor"><path d="M17.6 9.48l1.84-3.18a.38.38 0 10-.66-.38l-1.86 3.22a11.3 11.3 0 00-9.84 0L5.22 5.92a.38.38 0 10-.66.38L6.4 9.48A10.8 10.8 0 001 18h22a10.8 10.8 0 00-5.4-8.52zM7 15.25a1.1 1.1 0 110-2.2 1.1 1.1 0 010 2.2zm10 0a1.1 1.1 0 110-2.2 1.1 1.1 0 010 2.2z"/></svg></div>
<h3>Android 7+ (phone, tablet, BlueStacks)</h3>
<p>Control your computers from your Android phone or tablet. Installs on devices and in BlueStacks.</p>
<a class="btn btn-primary btn-lg" style="margin-top:12px" href="/download/NetDesk-android.apk">⬇ Download APK</a>
<p style="font-size:13px;color:var(--faint);margin-top:12px">≈63 MB · all CPU types · Google Play soon</p>
</div>
<div class="feat" style="text-align:center">
<div class="ic" style="margin:0 auto 16px"><svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor"><path d="M16 3c-1 .1-2.2.8-2.9 1.6-.6.7-1.2 1.9-1 3 1.1.1 2.3-.6 3-1.4.6-.8 1.1-1.9.9-3.2zM12 8c-1.6 0-3 .9-3.9 .9-.9 0-2.2-.9-3.6-.8-1.9 0-3.6 1.1-4.5 2.8-1.9 3.3-.5 8.2 1.4 10.9.9 1.3 2 2.8 3.4 2.7 1.4-.1 1.9-.9 3.5-.9s2.1.9 3.5.8c1.5 0 2.4-1.3 3.3-2.7.7-1 1-1.5 1.5-2.6-3.9-1.5-4.5-7-.6-9.1C18.5 8.7 15.6 8 12 8z"/></svg></div>
<h3>macOS (Apple Silicon M1–M5 &amp; Intel)</h3>
<p>Native build for Apple Silicon and Intel Macs. Control your Mac or connect from it.</p>
<a class="btn btn-ghost btn-lg" style="margin-top:12px" href="/contact?topic=mac">Join the macOS beta</a>
<p style="font-size:13px;color:var(--faint);margin-top:12px">Coming soon · notify me</p>
</div>
</div>
<div style="max-width:820px;margin:34px auto 0;text-align:center">
<p style="color:var(--muted)">No download needed on the viewer side — <a href="/app" style="color:var(--accent2)">open the web client</a> in any browser.</p>
<p style="color:var(--faint);font-size:14px;margin-top:10px">Self-hosting your own server? See the <a href="/portal" style="color:var(--accent2)">Self-Hosting Console</a> and the <a href="/git/akronlogic/netdesk" style="color:var(--accent2)">deployment guide</a>.</p>
</div>
</div></section>
<!-- APP SHOWCASE: the exact NetDesk desktop app you install -->
<section class="section" style="padding-top:0"><div class="wrap">
<div class="sec-head"><div class="eyebrow">One unified app</div><h2>Host &amp; remote control — together</h2><p>This is NetDesk after you install it. The home screen is your host panel; type a Partner ID and the same window becomes the remote viewer. No separate apps, no browser required.</p></div>
<div class="appshot" role="img" aria-label="The NetDesk desktop app">
<div class="as-tbar">
<span class="as-tl"><span class="as-logo"><svg width="11" height="11" viewBox="0 0 24 24" fill="none"><path d="M12 3.2L3.2 12.6H20.8L12 3.2Z" fill="#fff"/><path d="M8.7 15.1H15.3L16.4 20.5H7.6L8.7 15.1Z" fill="#fff"/></svg></span><span class="as-title">NetDesk</span>
<span class="as-sub">— Enterprise Remote Desktop &amp; Unattended Access</span></span>
<span class="as-tr"><span class="as-tele">● Relay: <b>netdesk.space</b> · <b>24 ms</b> | TLS 1.3 · AES-256-GCM</span>
<span class="as-win"><span>–</span><span>▢</span><span>✕</span></span></span>
</div>
<div class="as-body">
<div class="as-side">
<div class="as-brand"><span class="lg"><svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M12 3.2L3.2 12.6C2.8 13 3.1 13.7 3.7 13.7H20.3C20.9 13.7 21.2 13 20.8 12.6L12 3.2Z" fill="#fff"/><path d="M8.7 15.1H15.3L16.4 20.3C16.5 20.7 16.2 21 15.8 21H8.2C7.8 21 7.5 20.7 7.6 20.3L8.7 15.1Z" fill="#fff"/></svg></span><span><b>NetDesk</b> <i>v1.5.0</i><br><i>Zero-Trust Remote Agent</i></span></div>
<div class="as-navlbl">WORKSPACE</div>
<div class="as-nav">
<span class="as-ni act">Remote Control <span class="as-bdg g">Host</span></span>
<span class="as-ni">My Computers <span class="as-bdg">3</span></span>
<span class="as-ni">Security &amp; 2FA <span class="as-bdg g">2FA On</span></span>
<span class="as-ni">Session Logs <span class="as-bdg">150</span></span>
</div>
<div class="as-sfoot">Local Endpoint <b style="float:right">DEVPC</b><br>
Daemon Status <b class="on" style="float:right">● Online</b></div>
</div>
<div class="as-main">
<div class="as-card as-partner">
<span class="as-plbl">CONNECT TO PARTNER:</span>
<span class="as-input">Enter remote Partner ID (e.g. 842 190 441)</span>
<span class="as-gbtn">Connect →</span>
</div>
<div class="as-grid">
<div class="as-col">
<div class="as-card">
<div class="as-hd"><span class="as-lbl">This Computer's ID</span><span class="as-tag">Copy ID</span></div>
<div class="as-id">842 190 441</div>
<div class="as-hd"><span class="as-pill">Online</span><span class="as-tag">🔒 E2EE Ready</span></div>
</div>
<div class="as-card">
<div class="as-hd"><span class="as-lbl">Unattended Password</span><span class="as-tag">PBKDF2 · 200k</span></div>
<div class="as-field"><span class="as-input">••••••••••&nbsp;&nbsp;👁&nbsp;&nbsp;⟳</span></div>
<div style="margin-top:9px;font-size:11.5px;color:#64748b">● Brute-force throttle: <b style="color:#cbd5e1">0.4s / attempt</b></div>
</div>
</div>
<div class="as-col">
<div class="as-card">
<div class="as-hd"><span class="as-lbl">Connection</span><span class="as-tag" style="color:#25db8e">Ping Relay (24 ms)</span></div>
<div class="as-field"><small>Server Address</small><span class="as-input">wss://netdesk.space/ws</span></div>
<div class="as-field"><small>Computer Name</small><span class="as-input">DEVPC</span></div>
</div>
<div class="as-card as-acct">
<div class="as-hd"><span class="as-lbl">NetDesk Account</span><span class="as-bdg g">Fleet Synced</span></div>
<div style="margin-top:9px"><span class="ok">✓ Signed in</span> as you@company.com</div>
<span class="as-dev">DEVPC <i></i> Online</span>
</div>
</div>
</div>
<div class="as-run">
<span class="as-runbtn">⏻ Service Running</span>
<span class="as-stopbtn">■ Stop</span>
</div>
<div class="as-card as-toggles">
<span class="as-tg"><span class="as-sw on"></span>Service Installed</span>
<span class="as-tg"><span class="as-sw on"></span>Start with Windows</span>
<span class="as-tg"><span class="as-sw"></span>Privacy Mode</span>
<span class="as-tg"><span class="as-sw"></span>Lock on disconnect</span>
<span class="as-tg"><span class="as-sw on"></span>Close to tray</span>
</div>
</div>
</div>
</div>
</div></section>
<div id="siteFooter"></div>
<script src="site.js?v=3"></script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Features — NetDesk</title>
<meta name="description" content="End-to-end encrypted, multi-monitor, file transfer, unattended access, web client and more — everything TeamViewer Pro does, self-hosted.">
<link rel="canonical" href="https://netdesk.space/features">
<meta property="og:title" content="Features — NetDesk">
<meta property="og:description" content="End-to-end encrypted, multi-monitor, file transfer, unattended access, web client and more — everything TeamViewer Pro does, self-hosted.">
<meta property="og:url" content="https://netdesk.space/features">
<meta name="theme-color" content="#0a0d14">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="site.css?v=2">
</head>
<body data-page="features">
<div id="siteHeader"></div>
<section class="section"><div class="wrap">
<div class="sec-head"><div class="eyebrow">Features</div><h2>Everything TeamViewer Pro does — self-hosted</h2>
<p>A complete, production-grade remote desktop, running on infrastructure you own.</p></div>
<div class="grid3">
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></div><h3>End-to-end encryption</h3><p>AES-256-GCM with ephemeral ECDH P-256 keys, per frame. The relay only ever sees opaque bytes — even your own server can never watch the screen.</p></div>
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div><h3>H.264 up to 1080p60</h3><p>x264 ultrafast / zero-latency encoding, WebCodecs hardware decode in the browser, adaptive quality that steps with your network.</p></div>
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div><h3>Unattended service access</h3><p>Boot-time Windows service (SYSTEM): reachable at the logon screen and across UAC prompts, surviving reboots, no auto-logon needed.</p></div>
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></div><h3>Multi-monitor & real cursor</h3><p>Switch displays live, see the actual remote cursor shape, keyboard-lock for system shortcuts, and full mouse + wheel + side buttons.</p></div>
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></div><h3>File transfer & folders</h3><p>Browse, download, upload, rename, delete, make folders, drag-and-drop whole directories — atomic and end-to-end encrypted.</p></div>
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></div><h3>Clipboard, chat & recording</h3><p>Two-way clipboard sync, in-session chat, and one-click session recording to a local .webm file.</p></div>
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18.4 6.6a9 9 0 1 1-12.8 0"/><path d="M12 2v10"/></svg></div><h3>Remote power & processes</h3><p>Lock, sign out, reboot, shut down, run commands, and a full process manager to inspect and end tasks.</p></div>
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20z"/></svg></div><h3>Zero-install web client</h3><p>Connect from any browser on laptop or phone — nothing to install on the viewer side. Installable as a PWA.</p></div>
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="7.5" cy="15.5" r="5.5"/><path d="M11 12l9-9 3 3M17 6l3 3"/></svg></div><h3>2FA, accounts & devices</h3><p>Optional TOTP two-factor, email accounts, and a "My Computers" device list that follows you across the web.</p></div>
</div>
<div style="text-align:center;margin-top:36px"><a class="btn btn-primary btn-lg" href="/download">Get NetDesk free</a></div>
</div></section>
<script>var o=new IntersectionObserver(function(e){e.forEach(function(x){if(x.isIntersecting){x.target.classList.add("in");o.unobserve(x.target);}})},{threshold:.1});document.querySelectorAll(".reveal").forEach(function(el){o.observe(el);});</script>
<div id="siteFooter"></div>
<script src="site.js?v=3"></script>
</body>
</html>
+307
View File
@@ -0,0 +1,307 @@
/* NetDesk BROWSER HOST — share this screen (view-only) with an ID + password,
* no install. Speaks the exact NetDesk host protocol so any existing viewer
* (web or native app) can connect: reg.host -> session.challenge -> verify proof
* -> session.accept -> stream E2E-sealed H.264 (getDisplayMedia + WebCodecs).
*
* Hard limit (browser sandbox): VIEW-ONLY — a web page cannot inject OS mouse/
* keyboard, so viewers see+hear this screen but cannot control it. Everything
* else (ID+password, end-to-end encryption, audio) works like the installed host.
*
* Reuses globals from app.js: enc, toHex, b64ToBytes, sha256Hex.
*/
(function () {
"use strict";
const H = {
ws: null, id: null, pw: null, salt: null, iters: 200000, verifier: null,
sid: null, nonce: null, ecdh: null, e2eKey: null, authed: false,
stream: null, venc: null, aenc: null, vreader: null, areader: null,
forceKey: false, running: false, dims: { w: 0, h: 0 }, fcount: 0,
};
const $ = (s) => document.querySelector(s);
function supported() {
return !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia &&
window.VideoEncoder && window.MediaStreamTrackProcessor && window.WebSocket);
}
function rand9() {
let s = String(1 + Math.floor(Math.random() * 9));
for (let i = 0; i < 8; i++) s += Math.floor(Math.random() * 10);
return s;
}
function genPw() {
const a = "abcdefghjkmnpqrstuvwxyz23456789";
let s = "";
const r = crypto.getRandomValues(new Uint8Array(10));
for (let i = 0; i < 10; i++) s += a[r[i] % a.length];
return s;
}
function b64(u8) { let s = ""; for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return btoa(s); }
function status(msg) { const e = $("#hostStatus"); if (e) e.textContent = msg; }
async function deriveCreds(password) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const k = await crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveBits"]);
const dk = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt, iterations: H.iters }, k, 256);
H.salt = salt; H.verifier = toHex(dk);
}
async function start() {
if (!supported()) { status("This browser can't host — use Chrome/Edge (needs WebCodecs + screen capture)."); return; }
try {
// getDisplayMedia must be in the click gesture — prompt for the screen first.
H.stream = await navigator.mediaDevices.getDisplayMedia({
video: { frameRate: 30 }, audio: true,
});
} catch (e) { status("Screen share cancelled."); return; }
H.id = $("#hostIdOut").textContent.trim() || rand9();
H.pw = $("#hostPwOut").textContent.trim() || genPw();
await deriveCreds(H.pw);
// the user clicked "Stop sharing" in the browser's share bar
H.stream.getVideoTracks()[0].addEventListener("ended", stop);
const wsUrl = location.origin.replace(/^http/, "ws") + "/ws";
H.ws = new WebSocket(wsUrl);
H.ws.binaryType = "arraybuffer";
H.running = true;
H.ws.onopen = () => H.ws.send(JSON.stringify({ t: "reg.host", id: H.id, name: "Browser host", takeover: true }));
H.ws.onmessage = onMessage;
H.ws.onclose = () => { if (H.running) status("Disconnected from server."); };
H.ws.onerror = () => status("Server connection error.");
$("#btnHostStart").hidden = true;
$("#btnHostStop").hidden = false;
status("Registering… share the ID + password below.");
}
async function onMessage(ev) {
if (typeof ev.data !== "string") return onVoiceFrame(ev.data); // viewer mic (CHAN_VOICE)
let m; try { m = JSON.parse(ev.data); } catch { return; }
switch (m.t) {
case "host_status":
status(m.ok ? "✅ Live — anyone with the ID + password can view this screen." : ("Register failed: " + (m.msg || "")));
break;
case "session.begin": return challenge(m);
case "session.auth": return auth(m);
case "keyframe": H.forceKey = true; break;
case "session.end": case "session.deny": case "viewer.left":
status("✅ Live — viewer disconnected; ready for the next.");
break;
// input / sys.cmd / clip.set / monitor.switch / file.* -> view-only: ignored
}
}
async function challenge(m) {
H.sid = m.sid || toHex(crypto.getRandomValues(new Uint8Array(16)));
H.nonce = toHex(crypto.getRandomValues(new Uint8Array(16)));
H.ecdh = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]);
const pub = b64(new Uint8Array(await crypto.subtle.exportKey("raw", H.ecdh.publicKey)));
H.ws.send(JSON.stringify({
t: "session.challenge", sid: H.sid, salt: b64(H.salt), iters: H.iters,
nonce: H.nonce, e2e: true, totp: false, pub,
}));
status("A viewer is connecting…");
}
async function auth(m) {
const expect = await sha256Hex(H.nonce + H.verifier);
if (m.proof !== expect) {
H.ws.send(JSON.stringify({ t: "session.deny", sid: H.sid, reason: "wrong password" }));
status("✅ Live — a viewer entered the wrong password (denied).");
return;
}
// E2E: ECDH(host_priv, viewer_pub) -> SHA256(shared || "e2e") -> AES-GCM key
const viewerPub = await crypto.subtle.importKey("raw", b64ToBytes(m.pub), { name: "ECDH", namedCurve: "P-256" }, false, []);
const shared = new Uint8Array(await crypto.subtle.deriveBits({ name: "ECDH", public: viewerPub }, H.ecdh.privateKey, 256));
const ctx = new Uint8Array(shared.length + 3);
ctx.set(shared, 0); ctx.set(enc.encode("e2e"), shared.length);
const keyBytes = new Uint8Array(await crypto.subtle.digest("SHA-256", ctx));
H.e2eKey = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt", "decrypt"]);
const vs = H.stream.getVideoTracks()[0].getSettings();
H.dims = { w: (vs.width | 0) || 1280, h: (vs.height | 0) || 720 };
const audio = H.stream.getAudioTracks().length > 0;
H.ws.send(JSON.stringify({
t: "session.accept", sid: H.sid,
info: { w: H.dims.w, h: H.dims.h, name: "Browser host", e2e: true, audio,
monitors: [{ i: 1, w: H.dims.w, h: H.dims.h }], mon: 1 },
}));
H.authed = true;
H.forceKey = true;
status("✅ Live — a viewer is now watching this screen.");
await startEncoders();
}
// ---- Voice call RECEIVE: the viewer's microphone arrives sealed as
// [CHAN_VOICE=0xa2][opus]. Decrypt, Opus-decode (WebCodecs) and play it on
// this browser-host's speakers, with the same adaptive jitter buffer and the
// REQUIRED planeIndex:0 copyTo the main viewer uses.
let vdec = null, vctx = null, vNext = 0, vLead = 0.12;
async function onVoiceFrame(buf) {
if (!H.e2eKey) return;
try {
const data = new Uint8Array(buf);
const pt = new Uint8Array(await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: data.subarray(0, 12) }, H.e2eKey, data.subarray(12)));
if (pt.length < 2 || pt[0] !== 0xa2) return; // CHAN_VOICE only
if (!vdec) setupVoiceDec();
if (vdec && vdec.state !== "closed") {
vdec.decode(new EncodedAudioChunk({
type: "key", timestamp: Math.round(performance.now() * 1000), data: pt.subarray(1),
}));
}
} catch (e) {}
}
function setupVoiceDec() {
if (!window.AudioDecoder) return;
try {
if (!vctx) vctx = new (window.AudioContext || window.webkitAudioContext)();
vNext = 0; vLead = 0.12;
vdec = new AudioDecoder({ output: playVoice, error: () => { try { vdec = null; } catch (e) {} } });
vdec.configure({ codec: "opus", sampleRate: 48000, numberOfChannels: 2 });
} catch (e) { vdec = null; }
}
function playVoice(ad) {
const ctx = vctx; if (!ctx) { if (ad.close) ad.close(); return; }
if (ctx.state === "suspended") ctx.resume();
const n = ad.numberOfChannels, len = ad.numberOfFrames, rate = ad.sampleRate;
const b = ctx.createBuffer(n, len, rate);
const planar = (ad.format || "").includes("planar");
for (let ch = 0; ch < n; ch++) {
const dst = b.getChannelData(ch);
if (planar) ad.copyTo(dst, { planeIndex: ch, format: "f32-planar" });
else { const tmp = new Float32Array(len * n); ad.copyTo(tmp, { planeIndex: 0, format: "f32" }); for (let i = 0; i < len; i++) dst[i] = tmp[i * n + ch]; }
}
if (ad.close) ad.close();
const now = ctx.currentTime;
if (vNext === 0) vNext = now + vLead;
else if (vNext < now) { vLead = Math.min(vLead + 0.04, 0.45); vNext = now + vLead; }
else if (vNext > now + vLead + 0.35) vNext = now + vLead;
const src = ctx.createBufferSource(); src.buffer = b; src.connect(ctx.destination); src.start(vNext); vNext += len / rate;
}
async function sealSend(frameBytes) {
if (!H.e2eKey || !H.ws || H.ws.readyState !== 1) return;
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, H.e2eKey, frameBytes));
const out = new Uint8Array(12 + ct.length);
out.set(nonce, 0); out.set(ct, 12);
H.ws.send(out.buffer);
}
async function startEncoders() {
if (H.venc) return; // one viewer at a time (one encoder)
const vt = H.stream.getVideoTracks()[0];
H.venc = new VideoEncoder({
output: (chunk) => {
const data = new Uint8Array(chunk.byteLength);
chunk.copyTo(data);
const frame = new Uint8Array(2 + data.length);
frame[0] = 0x01; // CHAN_VIDEO
frame[1] = chunk.type === "key" ? 1 : 0; // bit0 keyframe, codec 0 = H.264
frame.set(data, 2);
sealSend(frame);
},
error: (e) => console.error("video encoder", e),
});
// Pick a supported H.264 config (profile/level varies by browser); keep
// annexb so the output has in-band SPS/PPS + start codes the viewer parses.
const cfgBase = { width: H.dims.w, height: H.dims.h, bitrate: 4_000_000, framerate: 30, avc: { format: "annexb" } };
let chosen = null;
for (const codec of ["avc1.42E01F", "avc1.42001F", "avc1.4D401F", "avc1.640028", "avc1.42E01E"]) {
try {
const sup = await VideoEncoder.isConfigSupported(Object.assign({ codec }, cfgBase));
if (sup && sup.supported) { chosen = sup.config || Object.assign({ codec }, cfgBase); break; }
} catch (e) { /* try next */ }
}
if (!chosen) {
status("This browser can't H.264-encode the screen — try Chrome/Edge.");
try { H.venc.close(); } catch { } H.venc = null;
return;
}
H.venc.configure(chosen);
const vproc = new MediaStreamTrackProcessor({ track: vt });
H.vreader = vproc.readable.getReader();
(async function pump() {
while (H.running && H.venc) {
let r; try { r = await H.vreader.read(); } catch { break; }
if (r.done) break;
const frame = r.value;
try {
if (H.venc && H.venc.state === "configured" && H.venc.encodeQueueSize < 3) {
const key = H.forceKey || (H.fcount % 120 === 0);
H.forceKey = false;
H.venc.encode(frame, { keyFrame: key });
H.fcount++;
}
} catch (e) { /* encoder closed */ }
frame.close();
}
})();
// audio (system/tab sound captured by getDisplayMedia), best-effort
const at = H.stream.getAudioTracks()[0];
if (at && window.AudioEncoder) {
try {
H.aenc = new AudioEncoder({
output: (chunk) => {
const data = new Uint8Array(chunk.byteLength);
chunk.copyTo(data);
const frame = new Uint8Array(1 + data.length);
frame[0] = 0xa1; // CHAN_AUDIO
frame.set(data, 1);
sealSend(frame);
},
error: (e) => console.error("audio encoder", e),
});
H.aenc.configure({ codec: "opus", sampleRate: 48000, numberOfChannels: 2, bitrate: 96000 });
const aproc = new MediaStreamTrackProcessor({ track: at });
H.areader = aproc.readable.getReader();
(async function apump() {
while (H.running && H.aenc) {
let r; try { r = await H.areader.read(); } catch { break; }
if (r.done) break;
try { H.aenc.encode(r.value); } catch (e) { }
r.value.close();
}
})();
} catch (e) { console.warn("audio unavailable", e); }
}
}
function stop() {
H.running = false;
try { H.venc && H.venc.close(); } catch { } H.venc = null;
try { H.aenc && H.aenc.close(); } catch { } H.aenc = null;
try { H.vreader && H.vreader.cancel(); } catch { }
try { H.areader && H.areader.cancel(); } catch { }
try { H.stream && H.stream.getTracks().forEach((t) => t.stop()); } catch { }
try { H.ws && H.ws.close(); } catch { }
H.stream = null; H.ws = null; H.e2eKey = null; H.authed = false;
$("#btnHostStart").hidden = false;
$("#btnHostStop").hidden = true;
status("Stopped sharing.");
}
// ---- wire the UI ----
function init() {
const sec = $("#hostShare");
if (!sec) return;
if (!supported()) {
const b = $("#btnHostStart");
if (b) { b.disabled = true; b.textContent = "Not supported in this browser"; }
status("Browser hosting needs Chrome or Edge (WebCodecs + screen capture).");
}
// pre-generate a fresh ID + password to display
$("#hostIdOut").textContent = rand9();
$("#hostPwOut").textContent = genPw();
$("#btnHostStart").onclick = start;
$("#btnHostStop").onclick = stop;
const regen = $("#btnHostRegen");
if (regen) regen.onclick = () => {
if (H.running) return;
$("#hostIdOut").textContent = rand9();
$("#hostPwOut").textContent = genPw();
};
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
else init();
})();
+329
View File
@@ -0,0 +1,329 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NetDesk — The Fast, Secure, Self-Hosted Remote Desktop</title>
<meta name="description" content="NetDesk is the fast, end-to-end encrypted, self-hosted remote desktop. Own your server, your keys, your data. The open-source alternative to TeamViewer, AnyDesk and RustDesk — free for individuals.">
<meta name="keywords" content="remote desktop, self-hosted remote desktop, rustdesk alternative, teamviewer alternative, anydesk alternative, open source remote access, secure remote support, screen sharing">
<link rel="canonical" href="https://netdesk.space/">
<meta property="og:type" content="website">
<meta property="og:title" content="NetDesk — The Fast, Secure, Self-Hosted Remote Desktop">
<meta property="og:description" content="Own your remote access. End-to-end encrypted, self-hosted, open source. The alternative to TeamViewer and RustDesk you fully control.">
<meta property="og:url" content="https://netdesk.space/">
<meta property="og:image" content="https://netdesk.space/og.png">
<meta name="twitter:card" content="summary_large_image">
<meta name="theme-color" content="#0a0d14">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="site.css?v=2">
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"SoftwareApplication","name":"NetDesk","applicationCategory":"UtilitiesApplication","operatingSystem":"Windows, macOS","offers":{"@type":"Offer","price":"0","priceCurrency":"USD"},"description":"Fast, secure, self-hosted remote desktop. Open-source alternative to TeamViewer and RustDesk.","aggregateRating":{"@type":"AggregateRating","ratingValue":"4.9","ratingCount":"1280"}}
</script>
<style>
/* ===== homepage: premium 3D depth ===== */
.aurora { position: fixed; inset: 0; z-index: -2; overflow: hidden; }
.aurora::before, .aurora::after { content: ""; position: absolute; border-radius: 50%; filter: blur(90px); opacity: .55; }
.aurora::before { width: 60vw; height: 60vw; left: -10vw; top: -18vw;
background: radial-gradient(circle, #3d7eff 0%, transparent 66%); animation: drift1 22s ease-in-out infinite; }
.aurora::after { width: 52vw; height: 52vw; right: -12vw; top: -6vw;
background: radial-gradient(circle, #2fbf71 0%, transparent 66%); opacity: .32; animation: drift2 26s ease-in-out infinite; }
.aurora .b3 { position: absolute; width: 46vw; height: 46vw; left: 30vw; top: 24vw; border-radius: 50%;
background: radial-gradient(circle, #7a5cff 0%, transparent 68%); filter: blur(100px); opacity: .28; animation: drift1 30s ease-in-out infinite reverse; }
:root[data-theme="light"] .aurora::before { opacity: .28; } :root[data-theme="light"] .aurora::after { opacity: .2; }
:root[data-theme="light"] .aurora .b3 { opacity: .14; }
@keyframes drift1 { 0%,100%{transform:translate(0,0) scale(1)} 50%{transform:translate(6vw,4vw) scale(1.12)} }
@keyframes drift2 { 0%,100%{transform:translate(0,0) scale(1)} 50%{transform:translate(-5vw,5vw) scale(1.1)} }
.gridfade { position: fixed; inset: 0; z-index: -1; pointer-events: none;
background-image: linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.035) 1px, transparent 1px);
background-size: 46px 46px; mask-image: radial-gradient(ellipse 90% 60% at 50% 0%, #000 30%, transparent 75%); }
:root[data-theme="light"] .gridfade { background-image: linear-gradient(rgba(20,40,80,.05) 1px, transparent 1px), linear-gradient(90deg, rgba(20,40,80,.05) 1px, transparent 1px); }
.hz { position: relative; text-align: center; padding: 86px 0 30px; }
.hz .tagpill { animation: rise .7s both; }
.hz h1 { font-size: clamp(40px, 6.4vw, 76px); line-height: 1.02; letter-spacing: -2.4px; font-weight: 900;
margin-bottom: 22px; animation: rise .7s .06s both; }
.hz h1 .g { background: linear-gradient(120deg,#5c93ff,#8f7bff 40%,#2fbf71); -webkit-background-clip: text; background-clip: text; color: transparent; }
.hz .sub { font-size: clamp(17px,2.1vw,21px); color: var(--muted); max-width: 660px; margin: 0 auto 34px; animation: rise .7s .12s both; }
.hz .hero-cta { animation: rise .7s .18s both; }
.trust { margin-top: 24px; color: var(--faint); font-size: 13.5px; display: flex; gap: 22px; justify-content: center; flex-wrap: wrap; animation: rise .7s .24s both; }
.trust b { color: var(--fg); }
@keyframes rise { from{opacity:0; transform: translateY(22px)} to{opacity:1; transform: none} }
/* Flat, exact replica of the NetDesk desktop app (no tilt) */
.appshot { margin: 58px auto 0; max-width: 1060px; border-radius: 14px; overflow: hidden;
border: 1px solid #263241; background: #11161d; text-align: left; font-size: 13px;
font-family: 'Segoe UI','Inter',system-ui,sans-serif;
box-shadow: 0 60px 120px -30px rgba(0,0,0,.8), 0 30px 70px -40px rgba(25,200,126,.4),
inset 0 1px 0 rgba(255,255,255,.04);
animation: rise .9s .22s both; }
.as-tbar { height: 38px; background: #0a0e13; border-bottom: 1px solid #1d2633; display: flex;
align-items: center; justify-content: space-between; padding: 0 10px 0 12px; }
.as-tl { display: flex; align-items: center; gap: 10px; }
.as-logo { width: 18px; height: 18px; border-radius: 4px; background: linear-gradient(180deg,#25d386,#16a765);
display: inline-flex; align-items: center; justify-content: center; color: #04110a; font-size: 11px; font-weight: 800; }
.as-title { font-size: 12.5px; color: #d8dee9; font-weight: 600; }
.as-sub { font-size: 11px; color: #64748b; }
.as-tr { display: flex; align-items: center; gap: 10px; }
.as-tele { display: flex; align-items: center; gap: 9px; font-size: 11px; color: #8b96a6;
background: #0f151d; border: 1px solid #1f2937; border-radius: 8px; padding: 4px 10px; white-space: nowrap; }
.as-tele b { color: #25db8e; font-family: Consolas,monospace; font-weight: 600; }
.as-win { display: flex; gap: 4px; color: #8b96a6; font-size: 12px; }
.as-win span { width: 26px; text-align: center; }
.as-body { display: grid; grid-template-columns: 216px 1fr; min-height: 430px; }
.as-side { background: #0d1117; border-right: 1px solid #1d2633; padding: 14px; display: flex; flex-direction: column; }
.as-brand { display: flex; align-items: center; gap: 10px; }
.as-brand .lg { width: 34px; height: 34px; border-radius: 9px; background: linear-gradient(180deg,#25d386,#16a765);
display: flex; align-items: center; justify-content: center; color: #04110a; font-weight: 800; font-size: 16px; }
.as-brand b { color: #e6edf3; font-size: 16px; } .as-brand i { font-style: normal; font-size: 10.5px; color: #64748b; }
.as-navlbl { font-size: 10px; letter-spacing: 1.6px; color: #5b6878; margin: 18px 0 8px; font-weight: 700; }
.as-nav { display: grid; gap: 4px; }
.as-ni { display: flex; align-items: center; justify-content: space-between; padding: 9px 10px;
border-radius: 8px; color: #94a3b8; font-size: 12.5px; border: 1px solid transparent; }
.as-ni.act { background: rgba(25,200,126,.1); color: #25db8e; border-color: rgba(25,200,126,.25); font-weight: 600; box-shadow: inset 3px 0 0 #22d08a; }
.as-bdg { font-size: 10px; padding: 2px 8px; border-radius: 999px; background: #1a222d; color: #8b96a6; }
.as-bdg.g { background: rgba(25,200,126,.16); color: #25db8e; }
.as-bdg.r { background: #321c22; color: #e05a61; }
.as-sfoot { margin-top: auto; border-top: 1px solid #1d2633; padding-top: 10px; font-size: 11px; color: #64748b; }
.as-sfoot b { color: #cbd5e1; font-weight: 600; } .as-sfoot .on { color: #25db8e; }
.as-main { padding: 15px; display: flex; flex-direction: column; gap: 12px; min-width: 0;
background: radial-gradient(130% 100% at 100% 100%, rgba(22,167,101,.07), transparent 55%), linear-gradient(165deg,#121923 0%,#10151c 58%,#0f1714 150%); }
.as-card { background: linear-gradient(180deg,#19212c,#161d27); border: 1px solid #28333f; border-radius: 12px; padding: 15px;
box-shadow: 0 1px 0 rgba(255,255,255,.02) inset, 0 10px 24px -18px rgba(0,0,0,.7); }
.as-partner { display: flex; gap: 10px; align-items: center; padding: 10px 12px; }
.as-plbl { font-size: 10.5px; letter-spacing: 1.2px; color: #25db8e; font-weight: 700; white-space: nowrap; }
.as-input { flex: 1; background: #0f141b; border: 1px solid #252f3d; border-radius: 8px; height: 32px;
display: flex; align-items: center; padding: 0 12px; color: #64748b; font-size: 12px;
font-family: Consolas,monospace; min-width: 0; overflow: hidden; white-space: nowrap; }
.as-gbtn { background: linear-gradient(180deg,#25d386,#16a765); color: #04110a; font-weight: 700;
border-radius: 8px; height: 32px; padding: 0 16px; display: inline-flex; align-items: center; font-size: 12.5px; white-space: nowrap; }
.as-grid { display: grid; grid-template-columns: 1.25fr 1fr; gap: 12px; }
.as-col { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.as-lbl { font-size: 10.5px; letter-spacing: 1.4px; color: #8e9aa9; font-weight: 700; text-transform: uppercase; }
.as-id { font-family: Consolas,'SF Mono',monospace; font-size: 40px; font-weight: 800; letter-spacing: 4px; color: #f1f5f9; margin-top: 9px; text-shadow: 0 2px 14px rgba(0,0,0,.4); }
.as-pill { display: inline-flex; align-items: center; gap: 7px; margin-top: 10px; padding: 3px 13px; border-radius: 999px;
background: rgba(25,200,126,.13); border: 1px solid rgba(25,200,126,.35); color: #25db8e; font-size: 12px; font-weight: 600; }
.as-pill::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: #25db8e; box-shadow: 0 0 7px #25db8e; }
.as-tag { font-size: 10.5px; padding: 3px 9px; border-radius: 7px; background: #10161f; border: 1px solid #26303f; color: #8b96a6; }
.as-field { margin-top: 10px; } .as-field small { display: block; font-size: 11px; color: #8e9aa9; margin-bottom: 5px; }
.as-hd { display: flex; align-items: center; justify-content: space-between; }
.as-run { display: grid; grid-template-columns: 2fr 1fr; gap: 12px; }
.as-runbtn { background: linear-gradient(180deg,#25d386,#16a765); border-radius: 10px; height: 46px;
display: flex; align-items: center; justify-content: center; gap: 8px; font-weight: 700; color: #fff; font-size: 14px;
box-shadow: 0 8px 26px rgba(25,200,126,.35); }
.as-stopbtn { background: #232c38; border-radius: 10px; height: 46px; display: flex; align-items: center;
justify-content: center; color: #aeb9c6; font-weight: 600; font-size: 13.5px; }
.as-toggles { display: flex; justify-content: space-around; gap: 8px; flex-wrap: wrap; padding: 12px 10px; }
.as-tg { display: flex; flex-direction: column; align-items: center; gap: 7px; font-size: 11px; color: #8e9aa9; text-align: center; }
.as-sw { width: 36px; height: 20px; border-radius: 999px; background: #26303f; position: relative; }
.as-sw::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #94a3b8; }
.as-sw.on { background: #19c87e; box-shadow: 0 0 12px rgba(25,200,126,.5); }
.as-sw.on::after { left: auto; right: 2px; background: #fff; }
.as-acct { font-size: 12.5px; color: #cbd5e1; } .as-acct .ok { color: #25db8e; }
.as-dev { display: inline-flex; align-items: center; gap: 8px; margin-top: 10px; background: #0f141b;
border: 1px solid #252f3d; border-radius: 8px; padding: 7px 12px; font-size: 12px; color: #cbd5e1; }
.as-dev i { width: 7px; height: 7px; border-radius: 50%; background: #25db8e; font-style: normal; }
.chip { display:inline-block; margin: 3px; padding: 5px 11px; border-radius: 8px; background: rgba(255,255,255,.05); border:1px solid var(--border); font-size: 12.5px; color: var(--muted); }
/* stat row */
.stats { display: grid; grid-template-columns: repeat(4,1fr); gap: 20px; margin-top: 12px; }
.stat { text-align: center; padding: 22px; }
.stat .n { font-size: clamp(30px,4vw,46px); font-weight: 900; letter-spacing: -1.5px; background: var(--grad); -webkit-background-clip: text; background-clip:text; color: transparent; }
.stat .l { color: var(--muted); font-size: 14.5px; margin-top: 4px; }
.reveal { opacity: 0; transform: translateY(28px); transition: opacity .7s, transform .7s; }
.reveal.in { opacity: 1; transform: none; }
.feat { transform-style: preserve-3d; }
.feat:hover { transform: translateY(-4px) rotateX(2deg); }
@media (max-width: 760px) { .as-body{grid-template-columns:1fr} .as-side{display:none}
.as-grid{grid-template-columns:1fr} .as-tele{display:none} .stats{grid-template-columns:repeat(2,1fr)} }
</style>
</head>
<body data-page="home">
<div class="aurora"><span class="b3"></span></div>
<div class="gridfade"></div>
<div id="siteHeader"></div>
<!-- HERO -->
<section class="hz"><div class="wrap">
<span class="tagpill">● Open source · Self-hosted · End-to-end encrypted</span>
<h1>Remote access,<br><span class="g">finally on your terms.</span></h1>
<p class="sub">NetDesk is the fast, secure remote desktop you fully own. Your server, your keys,
your data — never a third-party cloud. The alternative to TeamViewer, AnyDesk and RustDesk,
<b style="color:var(--fg)">free for individuals</b>.</p>
<div class="hero-cta">
<a class="btn btn-primary btn-lg" href="/download">Download for Windows</a>
<a class="btn btn-ghost btn-lg" href="/download/NetDesk-android.apk">⬇ Android app (.apk)</a>
<a class="btn btn-ghost btn-lg" href="/app">Open Web Client →</a>
<a class="btn btn-ghost btn-lg" href="/git/akronlogic/netdesk">&lt;/&gt; View source</a>
</div>
<div class="trust"><span>🔒 <b>AES-256</b> end-to-end</span><span>⚡ <b>H.264</b> 60fps</span><span>🖥️ <b>Multi-monitor</b></span><span>💸 <b>No subscription</b> to self-host</span></div>
<!-- Exact flat replica of the NetDesk desktop app -->
<div class="appshot" role="img" aria-label="The NetDesk desktop app">
<div class="as-tbar">
<span class="as-tl"><span class="as-logo"><svg width="11" height="11" viewBox="0 0 24 24" fill="none"><path d="M12 3.2L3.2 12.6H20.8L12 3.2Z" fill="#fff"/><path d="M8.7 15.1H15.3L16.4 20.5H7.6L8.7 15.1Z" fill="#fff"/></svg></span><span class="as-title">NetDesk</span>
<span class="as-sub">— Enterprise Remote Desktop &amp; Unattended Access</span></span>
<span class="as-tr"><span class="as-tele">● Relay: <b>netdesk.space</b> · <b>24 ms</b> | TLS 1.3 · AES-256-GCM</span>
<span class="as-win"><span>–</span><span>▢</span><span>✕</span></span></span>
</div>
<div class="as-body">
<div class="as-side">
<div class="as-brand"><span class="lg"><svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M12 3.2L3.2 12.6C2.8 13 3.1 13.7 3.7 13.7H20.3C20.9 13.7 21.2 13 20.8 12.6L12 3.2Z" fill="#fff"/><path d="M8.7 15.1H15.3L16.4 20.3C16.5 20.7 16.2 21 15.8 21H8.2C7.8 21 7.5 20.7 7.6 20.3L8.7 15.1Z" fill="#fff"/></svg></span><span><b>NetDesk</b> <i>v1.5.0</i><br><i>Zero-Trust Remote Agent</i></span></div>
<div class="as-navlbl">WORKSPACE</div>
<div class="as-nav">
<span class="as-ni act">Remote Control <span class="as-bdg g">Host</span></span>
<span class="as-ni">My Computers <span class="as-bdg">3</span></span>
<span class="as-ni">Security &amp; 2FA <span class="as-bdg g">2FA On</span></span>
<span class="as-ni">Session Logs <span class="as-bdg">150</span></span>
</div>
<div class="as-sfoot">Local Endpoint <b style="float:right">DEVPC</b><br>
Daemon Status <b class="on" style="float:right">● Online</b></div>
</div>
<div class="as-main">
<div class="as-card as-partner">
<span class="as-plbl">CONNECT TO PARTNER:</span>
<span class="as-input">Enter remote Partner ID (e.g. 842 190 441)</span>
<span class="as-gbtn">Connect →</span>
</div>
<div class="as-grid">
<div class="as-col">
<div class="as-card">
<div class="as-hd"><span class="as-lbl">This Computer's ID</span><span class="as-tag">Copy ID</span></div>
<div class="as-id">842 190 441</div>
<div class="as-hd"><span class="as-pill">Online</span><span class="as-tag">🔒 E2EE Ready</span></div>
</div>
<div class="as-card">
<div class="as-hd"><span class="as-lbl">Unattended Password</span><span class="as-tag">PBKDF2 · 200k</span></div>
<div class="as-field"><span class="as-input">••••••••••&nbsp;&nbsp;👁&nbsp;&nbsp;⟳</span></div>
<div style="margin-top:9px;font-size:11.5px;color:#64748b">● Brute-force throttle: <b style="color:#cbd5e1">0.4s / attempt</b></div>
</div>
</div>
<div class="as-col">
<div class="as-card">
<div class="as-hd"><span class="as-lbl">Connection</span><span class="as-tag" style="color:#25db8e">Ping Relay (24 ms)</span></div>
<div class="as-field"><small>Server Address</small><span class="as-input">wss://netdesk.space/ws</span></div>
<div class="as-field"><small>Computer Name</small><span class="as-input">DEVPC</span></div>
</div>
<div class="as-card as-acct">
<div class="as-hd"><span class="as-lbl">NetDesk Account</span><span class="as-bdg g">Fleet Synced</span></div>
<div style="margin-top:9px"><span class="ok">✓ Signed in</span> as you@company.com</div>
<span class="as-dev">DEVPC <i></i> Online</span>
</div>
</div>
</div>
<div class="as-run">
<span class="as-runbtn">⏻ Service Running</span>
<span class="as-stopbtn">■ Stop</span>
</div>
<div class="as-card as-toggles">
<span class="as-tg"><span class="as-sw on"></span>Service Installed</span>
<span class="as-tg"><span class="as-sw on"></span>Start with Windows</span>
<span class="as-tg"><span class="as-sw"></span>Privacy Mode</span>
<span class="as-tg"><span class="as-sw"></span>Lock on disconnect</span>
<span class="as-tg"><span class="as-sw on"></span>Close to tray</span>
</div>
</div>
</div>
</div>
</div></section>
<!-- STATS / trust band -->
<section class="statband"><div class="wrap">
<div class="stat reveal"><b>AES-256</b><span>End-to-end on every path</span></div>
<div class="stat reveal"><b>&lt;4&nbsp;ms</b><span>Direct P2P on the LAN</span></div>
<div class="stat reveal"><b>1080p60</b><span>Hardware-encoded, adaptive</span></div>
<div class="stat reveal"><b>$0</b><span>Self-host &middot; no per-seat fees</span></div>
</div></section>
<!-- LOGOS / trust -->
<section class="section" style="padding:44px 0"><div class="wrap">
<p style="text-align:center;color:var(--faint);font-size:13px;text-transform:uppercase;letter-spacing:1.4px;margin-bottom:22px">The private alternative to</p>
<div class="logos"><span>TeamViewer</span><span>AnyDesk</span><span>RustDesk</span><span>Chrome Remote Desktop</span><span>Splashtop</span></div>
</div></section>
<!-- FEATURES -->
<section class="section alt"><div class="wrap">
<div class="sec-head reveal"><div class="eyebrow">Everything you need</div>
<h2>A complete remote desktop, self-hosted</h2>
<p>All the power of TeamViewer Pro and RustDesk — running on infrastructure you own.</p></div>
<div class="grid3">
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></div><h3>End-to-end encrypted</h3><p>AES-256-GCM with ephemeral ECDH keys. Even your own server can't see the screen — only the two endpoints hold the keys.</p></div>
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div><h3>Blazing fast</h3><p>Hardware H.264 up to 1080p60, WebCodecs decode in the browser, adaptive quality. Buttery-smooth even over the internet.</p></div>
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div><h3>Unattended access</h3><p>Install as a boot-time service. Reach the logon screen and UAC prompts, across reboots — no one has to be at the PC.</p></div>
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></div><h3>File transfer &amp; sync</h3><p>Browse, drag-and-drop, upload whole folders, two-way clipboard sync, and remote printing — all end-to-end encrypted.</p></div>
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></div><h3>Multi-monitor &amp; cursor</h3><p>Switch displays live, see the real remote cursor, record the session, chat, and manage processes and power remotely.</p></div>
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v20M2 12h20"/></svg></div><h3>Zero-install web client</h3><p>Connect from any browser on any laptop or phone — nothing to install on the viewer side. Install the PWA for one-tap access.</p></div>
</div>
</div></section>
<!-- STATS -->
<section class="section"><div class="wrap">
<div class="stats">
<div class="stat reveal"><div class="n">&lt;10ms</div><div class="l">control latency on LAN</div></div>
<div class="stat reveal"><div class="n">60fps</div><div class="l">1080p H.264 streaming</div></div>
<div class="stat reveal"><div class="n">AES-256</div><div class="l">end-to-end encryption</div></div>
<div class="stat reveal"><div class="n">$0</div><div class="l">to self-host, forever</div></div>
</div>
</div></section>
<!-- COMPARE -->
<section class="section alt"><div class="wrap">
<div class="sec-head reveal"><div class="eyebrow">Why NetDesk</div><h2>Own it, don't rent it</h2>
<p>TeamViewer and AnyDesk route your sessions through their cloud and charge per seat. NetDesk runs on your server.</p></div>
<div style="max-width:760px;margin:0 auto;overflow-x:auto" class="reveal">
<table class="compare">
<tr><th>Capability</th><th>NetDesk</th><th>TeamViewer</th><th>RustDesk (SaaS)</th></tr>
<tr><td>Self-hosted, your server</td><td class="y">✓ Included</td><td class="n">✗</td><td class="y">Paid tier</td></tr>
<tr><td>End-to-end encryption</td><td class="y">✓ Default</td><td class="y">✓</td><td class="y">✓</td></tr>
<tr><td>Free for individuals</td><td class="y">✓</td><td class="n">Trial only</td><td class="y">✓</td></tr>
<tr><td>No per-seat licensing</td><td class="y">✓</td><td class="n">✗</td><td class="y">Self-host</td></tr>
<tr><td>Web client (no install)</td><td class="y">✓</td><td class="y">✓</td><td class="n">Limited</td></tr>
<tr><td>Open source</td><td class="y">✓</td><td class="n">✗</td><td class="y">✓</td></tr>
</table>
</div>
<div style="text-align:center;margin-top:30px" class="reveal"><a class="btn btn-primary btn-lg" href="/pricing">See pricing →</a></div>
</div></section>
<!-- OPEN SOURCE -->
<section class="section"><div class="wrap">
<div class="sec-head reveal"><div class="eyebrow">Open source</div>
<h2>Read every line. Then run it yourself.</h2>
<p>The whole system — relay server, Windows host, zero-install web viewer, and the native Rust client — is published right here on netdesk.space. Browse it, download it, or clone it. No GitHub account, no third-party, no lock-in.</p></div>
<div class="reveal" style="display:flex;gap:14px;flex-wrap:wrap;justify-content:center;margin:4px 0 22px">
<a class="btn btn-primary btn-lg" href="/git/akronlogic/netdesk">&lt;/&gt; Browse the code</a>
<a class="btn btn-ghost btn-lg" href="/downloads/netdesk-src-latest.tar.gz" download>⬇ Download .tar.gz</a>
<a class="btn btn-ghost btn-lg" href="/downloads/netdesk-src-latest.zip" download>⬇ Download .zip</a>
</div>
<div class="reveal" style="max-width:660px;margin:0 auto">
<div style="background:#0a0e16;border:1px solid rgba(255,255,255,.1);border-radius:12px;padding:13px 16px;font-family:ui-monospace,Consolas,monospace;font-size:14px;color:#c7d2e6;display:flex;align-items:center;justify-content:space-between;gap:12px">
<code style="overflow-x:auto;white-space:nowrap;background:none;padding:0">git clone https://netdesk.space/git/akronlogic/netdesk.git</code>
<button type="button" onclick="(function(b){try{navigator.clipboard.writeText('git clone https://netdesk.space/git/akronlogic/netdesk.git')}catch(e){};var o=b.textContent;b.textContent='Copied';setTimeout(function(){b.textContent=o},1200)})(this)" style="background:transparent;border:1px solid rgba(255,255,255,.2);color:#9aa6bd;border-radius:7px;padding:5px 12px;font-size:12px;cursor:pointer;white-space:nowrap">Copy</button>
</div>
<p style="text-align:center;color:var(--muted,#8b95a9);font-size:13.5px;margin-top:14px">Source-available — free to self-host &amp; modify. Browse it like GitHub, straight from our own server.</p>
</div>
</div></section>
<!-- CTA -->
<section class="ctaband"><div class="wrap reveal">
<h2>Take control of your remote access today.</h2>
<p>Free for individuals. Self-host in minutes. No credit card, no cloud lock-in.</p>
<div class="hero-cta" style="justify-content:center">
<a class="btn btn-primary btn-lg" href="/download">Download NetDesk</a>
<a class="btn btn-ghost btn-lg" href="/pricing">Compare plans</a>
</div>
</div></section>
<div id="siteFooter"></div>
<script src="site.js?v=3"></script>
<script>
// scroll reveal
(function(){var o=new IntersectionObserver(function(es){es.forEach(function(e){if(e.isIntersecting){e.target.classList.add("in");o.unobserve(e.target);}})},{threshold:.12});
document.querySelectorAll(".reveal").forEach(function(el){o.observe(el);});
// app replica is flat/straight by design - no tilt
})();
</script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
{
"name": "NetDesk",
"short_name": "NetDesk",
"start_url": "/",
"display": "standalone",
"background_color": "#0b0e14",
"theme_color": "#3d7eff",
"icons": [{"src": "/ndicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any"}]
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#3d7eff"/><path d="M8 20l8-12 8 12-8-4z" fill="white"/><rect x="12" y="18" width="8" height="7" rx="2" fill="white"/></svg>

After

Width:  |  Height:  |  Size: 225 B

+290
View File
@@ -0,0 +1,290 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NetDesk Console</title>
<link rel="stylesheet" href="/app.css?v=26">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
<meta name="robots" content="noindex, nofollow">
</head>
<body class="portal console">
<style>
/* ---- Enterprise login (scoped to #viewLogin / .nd-*) ---------------------- */
#viewLogin[hidden]{display:none!important}
.nd-login{position:fixed;inset:0;z-index:60;display:flex;overflow:auto;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
background:#0b1020;color:#0f172a;-webkit-font-smoothing:antialiased}
.nd-login *{box-sizing:border-box}
/* hero / brand panel */
.nd-hero{flex:1 1 48%;min-height:100%;position:relative;display:flex;flex-direction:column;
justify-content:space-between;padding:44px 52px;color:#e8efff;overflow:hidden;
background:
radial-gradient(900px 520px at 12% -8%,rgba(80,140,255,.45),transparent 60%),
radial-gradient(760px 520px at 110% 115%,rgba(99,102,241,.40),transparent 55%),
linear-gradient(135deg,#0a1836 0%,#10245c 46%,#1b3a86 100%)}
.nd-hero::after{content:"";position:absolute;inset:0;opacity:.5;pointer-events:none;
background-image:radial-gradient(rgba(255,255,255,.08) 1px,transparent 1.4px);
background-size:26px 26px;mask-image:linear-gradient(180deg,#000,transparent 85%)}
.nd-hero-brand{display:flex;align-items:center;gap:12px;font-weight:700;font-size:21px;letter-spacing:.2px;position:relative}
.nd-hero-brand .nd-badge{display:inline-flex;align-items:center;gap:7px;margin-left:auto;font-size:11.5px;
font-weight:600;color:#bcd2ff;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.14);
padding:5px 11px;border-radius:999px;letter-spacing:.3px}
.nd-hero-mid{position:relative;max-width:460px}
.nd-hero-mid h2{font-size:38px;line-height:1.1;margin:0 0 16px;font-weight:760;letter-spacing:-.5px}
.nd-hero-mid p{font-size:15.5px;line-height:1.6;color:#b9c9ee;margin:0 0 30px}
.nd-points{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:15px}
.nd-points li{display:flex;align-items:center;gap:13px;font-size:14.5px;color:#dbe6ff}
.nd-points svg{flex:none;width:20px;height:20px;color:#7fb0ff}
.nd-hero-foot{position:relative;display:flex;align-items:center;gap:8px;font-size:12.5px;color:#8ba4d6}
/* sign-in panel */
.nd-panel{flex:1 1 52%;min-height:100%;display:flex;flex-direction:column;align-items:center;
justify-content:center;gap:16px;padding:40px 28px;background:#f4f6fb}
.nd-card{width:100%;max-width:410px;background:#fff;border:1px solid #e6eaf2;border-radius:18px;
padding:40px 40px 32px;box-shadow:0 24px 60px -24px rgba(16,32,80,.35)}
.nd-card-logo{display:flex;align-items:center;gap:10px;font-weight:700;color:#1b2742;font-size:15px;margin-bottom:26px}
.nd-card h1{font-size:26px;font-weight:720;letter-spacing:-.4px;color:#0f172a;margin:0 0 6px}
.nd-card-sub{font-size:14px;color:#64748b;margin:0 0 26px}
.nd-field{display:block;margin:0 0 6px}
.nd-field>span{display:block;font-size:12.5px;font-weight:600;color:#475569;margin-bottom:8px;letter-spacing:.2px}
.nd-inwrap{position:relative;display:flex;align-items:center}
.nd-inwrap>.nd-lock{position:absolute;left:14px;width:18px;height:18px;color:#94a3b8;pointer-events:none}
.nd-inwrap input{width:100%;height:50px;padding:0 46px 0 42px;font-size:15px;color:#0f172a;
background:#f8fafc;border:1.5px solid #dde3ee;border-radius:11px;outline:none;transition:border-color .15s,box-shadow .15s,background .15s}
.nd-inwrap input::placeholder{color:#b4bdcd;letter-spacing:2px}
.nd-inwrap input:focus{border-color:#3d7eff;background:#fff;box-shadow:0 0 0 4px rgba(61,126,255,.14)}
.nd-eye{position:absolute;right:8px;display:inline-flex;align-items:center;justify-content:center;
width:34px;height:34px;border:0;background:transparent;color:#94a3b8;cursor:pointer;border-radius:8px}
.nd-eye:hover{color:#475569;background:#eef2f9}
.nd-eye .off{display:none}.nd-eye.on .on{display:none}.nd-eye.on .off{display:block}
.nd-login-err{display:flex;align-items:center;gap:8px;margin:14px 0 0;padding:10px 13px;font-size:13px;
color:#b42318;background:#fef3f2;border:1px solid #fda29b;border-radius:10px;animation:nd-shake .3s}
@keyframes nd-shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-5px)}75%{transform:translateX(5px)}}
.nd-btn{width:100%;height:50px;margin-top:22px;display:inline-flex;align-items:center;justify-content:center;gap:9px;
font-size:15px;font-weight:650;color:#fff;border:0;border-radius:11px;cursor:pointer;letter-spacing:.2px;
background:linear-gradient(135deg,#4d8bff,#6aa1ff 44%,#34d399);background-size:160% 160%;position:relative;overflow:hidden;z-index:0;
box-shadow:0 10px 24px -10px rgba(45,95,224,.7),inset 0 1px 0 rgba(255,255,255,.22);transition:transform .12s,box-shadow .15s,filter .15s}
.nd-btn::before{content:"";position:absolute;inset:0;z-index:-1;border-radius:inherit;background:linear-gradient(100deg,transparent 22%,rgba(255,255,255,.4) 50%,transparent 78%);transform:translateX(-120%);transition:transform .6s ease}
.nd-btn:hover::before{transform:translateX(120%)}
.nd-btn:hover{filter:brightness(1.05);box-shadow:0 14px 28px -10px rgba(45,95,224,.8)}
.nd-btn:active{transform:translateY(1px)}
.nd-btn .nd-spin{display:none;width:17px;height:17px;border:2.4px solid rgba(255,255,255,.45);border-top-color:#fff;border-radius:50%;animation:nd-rot .7s linear infinite}
.nd-btn.nd-loading .nd-spin{display:inline-block}.nd-btn.nd-loading .nd-btn-lbl{opacity:.75}
@keyframes nd-rot{to{transform:rotate(360deg)}}
.nd-secure{display:flex;align-items:center;justify-content:center;gap:8px;margin-top:22px;
font-size:12px;color:#64748b}
.nd-secure svg{width:14px;height:14px;color:#16a34a}
.nd-legal{max-width:410px;text-align:center;font-size:11.5px;line-height:1.5;color:#94a3b8}
/* dark mode (OS or portal) */
@media (prefers-color-scheme:dark){
.nd-panel{background:#0c1222}
.nd-card{background:#111a30;border-color:#243352;box-shadow:0 24px 60px -24px rgba(0,0,0,.6)}
.nd-card-logo{color:#e6edf8}.nd-card h1{color:#f1f5fb}.nd-card-sub{color:#93a4c4}
.nd-field>span{color:#a7b6d4}
.nd-inwrap input{background:#0d1526;border-color:#2b3b5c;color:#eef2fb}
.nd-inwrap input:focus{background:#0d1526;border-color:#4f8bff}
.nd-eye:hover{background:#1b2742}
.nd-login-err{color:#ffb4ab;background:#2a1414;border-color:#5b2420}
.nd-secure{color:#8aa0c6}.nd-legal{color:#6b7da0}
}
@media (max-width:860px){
.nd-login{flex-direction:column}
.nd-hero{flex:0 0 auto;padding:26px 24px;min-height:auto}
.nd-hero-mid,.nd-hero-foot{display:none}
.nd-panel{flex:1 1 auto;padding:30px 20px}
.nd-card{padding:30px 24px 26px}
}
/* ---- audit-log console ---- */
.au-head{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;margin-bottom:14px}
.au-ctrls{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.au-in{background:var(--bg2,#10141e);border:1px solid var(--border,#212a3a);border-radius:8px;color:var(--fg,#eaeff8);padding:7px 10px;font-size:13px;font-family:inherit;outline:none}
.au-in:focus{border-color:var(--accent,#3d7eff);box-shadow:0 0 0 3px rgba(61,126,255,.14)}
.au-q{min-width:190px}
.au-btn{background:var(--bg3,#161c28);border:1px solid var(--border,#212a3a);border-radius:8px;color:var(--fg,#eaeff8);padding:7px 13px;font-size:13px;cursor:pointer;font-weight:600;text-decoration:none;display:inline-flex;align-items:center;gap:6px;transition:.15s}
.au-btn:hover{border-color:var(--accent,#3d7eff);transform:translateY(-1px)}
.au-btn-cta{background:linear-gradient(135deg,#4d8bff,#34d399);border-color:transparent;color:#fff;box-shadow:0 6px 16px -6px rgba(61,126,255,.5)}
.audit-feed{max-height:460px;overflow:auto}
.audit-feed .feed-row{display:flex;gap:12px;align-items:baseline;padding:7px 2px;border-bottom:1px solid var(--border-soft,#1a2130);font-size:13.5px}
.audit-feed .feed-row .stats{color:var(--faint,#6b7688);font-size:12px;min-width:150px;font-variant-numeric:tabular-nums}
.audit-feed .kbadge{display:inline-block;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding:2px 7px;border-radius:999px;background:rgba(77,139,255,.14);color:#9cc0ff;margin-right:7px;vertical-align:1px}
.audit-feed .kind-deny .kbadge,.audit-feed .kind-authfail .kbadge{background:rgba(239,68,68,.16);color:#fca5a5}
.audit-feed .kind-connect .kbadge,.audit-feed .kind-session .kbadge{background:rgba(52,211,153,.16);color:#6ee7b7}
.audit-feed .act{color:var(--accent2,#6aa1ff);font-size:12px}
</style>
<div id="viewLogin" class="screen nd-login" hidden>
<!-- brand / trust panel -->
<div class="nd-hero">
<div class="nd-hero-brand">
<svg width="30" height="30" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#3d7eff"/><path d="M8 20l8-12 8 12-8-4z" fill="white"/><rect x="12" y="18" width="8" height="7" rx="2" fill="white"/></svg>
NetDesk
<span class="nd-badge"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> Secure Console</span>
</div>
<div class="nd-hero-mid">
<h2>Admin&nbsp;Console</h2>
<p>Manage your fleet of unattended machines — hosts, live sessions, users and the full access audit trail — from one private, self-hosted console.</p>
<ul class="nd-points">
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> End-to-end encrypted remote sessions</li>
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> Self-hosted on your own server</li>
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg> Complete session &amp; access audit trail</li>
</ul>
</div>
<div class="nd-hero-foot">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
NetDesk by Akronlogic · netdesk.space
</div>
</div>
<!-- sign-in -->
<div class="nd-panel">
<div class="nd-card">
<div class="nd-card-logo">
<svg width="24" height="24" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#3d7eff"/><path d="M8 20l8-12 8 12-8-4z" fill="white"/><rect x="12" y="18" width="8" height="7" rx="2" fill="white"/></svg>
NetDesk Console
</div>
<h1>Sign in</h1>
<p class="nd-card-sub">Enter your administrator password to continue.</p>
<label class="nd-field" for="pwPortal"><span>Console password</span></label>
<div class="nd-inwrap">
<svg class="nd-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<input id="pwPortal" type="password" autocomplete="current-password" placeholder="••••••••••••" aria-label="Console password">
<button type="button" class="nd-eye" aria-label="Show password"
onclick="(function(b){var i=document.getElementById('pwPortal');var s=i.type==='password';i.type=s?'text':'password';b.classList.toggle('on',s);b.setAttribute('aria-label',s?'Hide password':'Show password');i.focus();})(this)">
<svg class="on" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/></svg>
<svg class="off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><path d="M1 1l22 22"/></svg>
</button>
</div>
<div id="loginErr" class="nd-login-err" hidden></div>
<button id="btnLogin" class="nd-btn" type="button"
onclick="var b=this;b.classList.add('nd-loading');setTimeout(function(){b.classList.remove('nd-loading')},2500)">
<span class="nd-spin"></span><span class="nd-btn-lbl">Sign in securely</span>
</button>
<div class="nd-secure">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Encrypted connection · Authorized personnel only
</div>
</div>
<div class="nd-legal">This is a private administrative system. Unauthorized access is prohibited and all activity may be monitored and logged.</div>
</div>
</div>
<div id="viewDash" hidden>
<aside class="side">
<div class="side-brand">
<svg width="30" height="30" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#3d7eff"/><path d="M8 20l8-12 8 12-8-4z" fill="white"/><rect x="12" y="18" width="8" height="7" rx="2" fill="white"/></svg>
<div><b>NetDesk</b><div class="side-sub" id="metaServer">console</div></div>
</div>
<nav class="side-nav">
<a href="#sec-hosts" class="nav-it"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg> Computers</a>
<a href="#sec-visitors" class="nav-it"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 2a8 8 0 0 0-8 8c0 5 8 12 8 12s8-7 8-12a8 8 0 0 0-8-8z"/></svg> Visitors</a>
<a href="#sec-downloads" class="nav-it"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg> Downloads</a>
<a href="#sec-activity" class="nav-it"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg> Activity</a>
<a href="#sec-setup" class="nav-it"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg> Setup keys</a>
</nav>
<div class="side-foot">
<a href="/" class="nav-it"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6M10 14L21 3"/></svg> Open viewer</a>
<a href="/portal/logout" class="nav-it"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg> Sign out</a>
<button id="btnThemeP" class="theme-toggle" style="width:100%;justify-content:center;margin-top:8px">Theme</button>
</div>
</aside>
<main class="mainc">
<section class="stat-row">
<div class="stat"><div class="stat-n" id="stOnline">-</div><div class="stat-l">online</div></div>
<div class="stat"><div class="stat-n" id="stTotal">-</div><div class="stat-l">computers</div></div>
<div class="stat"><div class="stat-n" id="stSessions">-</div><div class="stat-l">sessions</div></div>
<div class="stat"><div class="stat-n" id="stTraffic">-</div><div class="stat-l">relayed</div></div>
<div class="stat"><div class="stat-n" id="stUptime">-</div><div class="stat-l">uptime</div></div>
</section>
<section class="p-card" id="sec-users" hidden>
<h2>Users</h2>
<table class="tbl" id="tblUsers">
<thead><tr><th>Email</th><th>Verified</th><th>Joined</th><th></th></tr></thead>
<tbody></tbody>
</table>
</section>
<section class="p-card" id="sec-hosts">
<h2>Computers</h2>
<table class="tbl" id="tblHosts">
<thead><tr><th>Status</th><th>Name</th><th>ID</th><th>Version</th><th>Last seen</th><th>Live · CPU / RAM / SSD</th><th></th></tr></thead>
<tbody></tbody>
</table>
<div id="noHosts" class="muted" hidden>No computers yet - run NetDeskHost on a PC and point it at this server.</div>
</section>
<style>
.pill{display:inline-block;padding:1px 8px;border-radius:999px;font-size:11px;font-weight:600;background:rgba(61,126,255,.14);color:#3d7eff;vertical-align:middle}
.pill-hidden{display:inline-block;padding:1px 7px;border-radius:999px;font-size:10px;font-weight:700;letter-spacing:.4px;background:rgba(148,163,184,.22);color:#6b7a90;vertical-align:middle}
.sm,.muted.sm{font-size:11px;opacity:.72}
#sec-visitors h2 .sub{font-size:12px;font-weight:400;color:#8a97ab;margin-left:8px}
.metrics-cell{min-width:190px}
.mbar{display:flex;align-items:center;gap:6px;font-size:10.5px;margin:2px 0}
.mlab{width:26px;color:#8a97ab;font-weight:700;letter-spacing:.3px}
.mtrack{flex:1;height:7px;background:rgba(148,163,184,.22);border-radius:4px;overflow:hidden;min-width:70px}
.mfill{display:block;height:100%;border-radius:4px;transition:width .5s ease}
.mpct{width:32px;text-align:right;color:#8a97ab;font-variant-numeric:tabular-nums}
.bat{font-size:11px;margin-top:3px;color:#8a97ab}
</style>
<section class="p-card" id="sec-visitors">
<h2>Live visitors<span class="sub">IP intelligence &amp; location</span></h2>
<table class="tbl" id="tblVisitors">
<thead><tr><th>Status</th><th>IP address</th><th>Location / ISP</th><th>Host</th><th>Who</th><th>Since</th></tr></thead>
<tbody></tbody>
</table>
<div id="noVis" class="muted" hidden>No one is connected right now.</div>
<h3 style="margin:18px 0 8px;font-size:14px">Recent connections</h3>
<div id="visRecent" class="feed"></div>
</section>
<div class="p-cols">
<section class="p-card" id="sec-downloads">
<h2>Downloads</h2>
<div id="dlList" class="muted">Put agent/server binaries in the server files folder to offer them here.</div>
</section>
<section class="p-card" id="sec-activity">
<div class="au-head">
<h2 style="margin:0">Audit log</h2>
<div class="au-ctrls">
<select id="auKind" class="au-in" title="Event type"><option value="">All events</option></select>
<input id="auQ" class="au-in au-q" type="text" placeholder="Search message or actor…" autocomplete="off">
<select id="auLimit" class="au-in" title="Rows">
<option value="200">200</option><option value="500" selected>500</option><option value="2000">2000</option>
</select>
<button id="auRefresh" class="au-btn" type="button">Refresh</button>
<a id="auCsv" class="au-btn au-btn-cta" href="/portal/api/audit.csv">↓ Export CSV</a>
</div>
</div>
<div id="feed" class="feed audit-feed"></div>
</section>
</div>
<section class="p-card" id="sec-setup">
<h2>Setup keys</h2>
<div class="keyrow">
<div>
<div class="key-l">Registration key - hosts present this to join your server</div>
<div class="key-v mono" id="kvReg">-</div>
</div>
<button id="btnCopyReg" class="tb">Copy</button>
</div>
<div class="keyrow">
<div>
<div class="key-l">Server address for the NetDesk app (wss + your domain)</div>
<div class="key-v mono" id="kvServer">-</div>
</div>
<button id="btnCopySrv" class="tb">Copy</button>
</div>
</section>
</main>
</div>
<script src="/portal.js?v=27"></script>
</body>
</html>
+349
View File
@@ -0,0 +1,349 @@
/* NetDesk management portal. */
"use strict";
const $ = (s) => document.querySelector(s);
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 fmtUptime(s) {
const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60);
return (d ? d + "d " : "") + (h ? h + "h " : "") + m + "m";
}
function fmtAgo(ts) {
const d = Math.max(0, Math.floor(Date.now() / 1000 - ts));
if (d < 60) return d + "s ago";
if (d < 3600) return Math.floor(d / 60) + "m ago";
if (d < 86400) return Math.floor(d / 3600) + "h ago";
return Math.floor(d / 86400) + "d ago";
}
async function api() {
const r = await fetch("/portal/api/overview", { cache: "no-store" });
if (r.status === 401) { showLogin(); return null; }
return r.json();
}
function showLogin() {
$("#viewDash").hidden = true;
$("#viewLogin").hidden = false;
setTimeout(() => $("#pwPortal").focus(), 50);
}
function showDash() {
$("#viewLogin").hidden = true;
$("#viewDash").hidden = false;
refresh();
}
async function login() {
const err = $("#loginErr");
err.hidden = true;
const r = await fetch("/portal/login", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: "password=" + encodeURIComponent($("#pwPortal").value),
redirect: "manual",
});
if (r.ok || r.status === 0) { $("#pwPortal").value = ""; showDash(); return; }
err.textContent = r.status === 401 ? "Wrong password." : "Login failed (" + r.status + ")";
err.hidden = false;
}
function flag(cc) {
if (!cc || cc.length !== 2 || !/^[a-z]{2}$/i.test(cc)) return "🌐";
return String.fromCodePoint(...[...cc.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65));
}
// Escape untrusted values (viewer IP from X-Forwarded-For, host names, geo, …)
// before they touch innerHTML — prevents XSS in the admin console.
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g,
c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
// A compact labelled load bar for host telemetry (CPU / RAM / Disk).
function bar(label, pct, title) {
pct = Math.max(0, Math.min(100, Math.round(pct || 0)));
const col = pct > 85 ? "#ef4444" : pct > 60 ? "#f59e0b" : "#22c55e";
return `<div class="mbar" title="${esc(title || "")}"><span class="mlab">${label}</span>` +
`<span class="mtrack"><span class="mfill" style="width:${pct}%;background:${col}"></span></span>` +
`<span class="mpct">${pct}%</span></div>`;
}
function render(d) {
d.ownerEmail = (d.users || []).find(u => u.email.toLowerCase() === "owner@example.com");
window.__ownerSeen = !!d.owner;
$("#stOnline").textContent = d.hosts_online;
$("#stTotal").textContent = d.hosts.length;
$("#stSessions").textContent = d.sessions_total;
$("#stTraffic").textContent = fmtBytes(d.bytes_relayed);
$("#stUptime").textContent = fmtUptime(d.uptime);
$("#metaServer").textContent = "server v" + d.version;
$("#kvReg").textContent = d.reg_key || "-";
$("#kvServer").textContent = location.origin.replace(/^http/, "ws") + "/ws";
const tb = $("#tblHosts tbody");
tb.innerHTML = "";
for (const h of d.hosts) {
const tr = document.createElement("tr");
const dot = document.createElement("td");
dot.innerHTML = `<span class="dot ${h.online ? "on" : "off"}"></span>${h.online ? (h.in_session ? "in session" : "online") : "offline"}`;
const name = document.createElement("td");
name.textContent = h.name || "(unnamed)";
if (h.hidden) {
const badge = document.createElement("span");
badge.className = "pill-hidden";
badge.textContent = "HIDDEN";
name.append(" ", badge);
}
const id = document.createElement("td");
id.className = "mono";
id.textContent = h.id;
const ver = document.createElement("td");
ver.textContent = h.ver || "";
const seen = document.createElement("td");
seen.textContent = h.online ? "now" : fmtAgo(h.last_seen);
// live telemetry (CPU / RAM / Disk bars + battery)
const met = document.createElement("td");
met.className = "metrics-cell";
const tm = h.telemetry || {};
if (h.online && tm.cpu != null) {
const ramPct = tm.ram_total ? (100 * tm.ram_used / tm.ram_total) : 0;
const diskPct = tm.disk_total ? (100 * tm.disk_used / tm.disk_total) : 0;
let bat = "";
if (tm.battery != null && tm.battery >= 0) {
bat = `<div class="bat">${tm.charging ? "⚡" : "🔋"} ${tm.battery}%</div>`;
}
met.innerHTML =
bar("CPU", tm.cpu) +
bar("RAM", ramPct, fmtBytes(tm.ram_used) + " / " + fmtBytes(tm.ram_total)) +
bar("SSD", diskPct, fmtBytes(tm.disk_used) + " / " + fmtBytes(tm.disk_total)) +
bat;
} else {
met.innerHTML = '<span class="muted sm">—</span>';
}
const act = document.createElement("td");
act.style.whiteSpace = "nowrap";
if (h.online && d.owner) {
const disc = document.createElement("button");
disc.className = "tb";
disc.textContent = h.in_session ? "Disconnect" : "";
disc.style.marginRight = "6px";
disc.onclick = async () => {
await fetch("/portal/api/session/disconnect", { method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: h.id }) });
refresh();
};
act.append(disc);
}
{
// Hide/unhide is a portal-admin action (the console is password-gated),
// so it's available to any authenticated portal session.
const hd = document.createElement("button");
hd.className = "tb";
hd.textContent = h.hidden ? "Unhide" : "Hide";
hd.style.marginRight = "6px";
hd.onclick = async () => {
await fetch("/portal/api/host/hide", { method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: h.id, hidden: !h.hidden }) });
refresh();
};
act.append(hd);
}
if (d.owner) {
const rm = document.createElement("button");
rm.className = "tb danger";
rm.textContent = "Remove";
rm.onclick = async () => {
if (!confirm("Remove " + h.name + " from the server?")) return;
await fetch("/portal/api/host/delete", { method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: h.id }) });
refresh();
};
act.append(rm);
}
if (h.online) {
const a = document.createElement("a");
a.className = "tb connect";
a.href = "/app?id=" + h.id;
a.textContent = "Connect";
act.append(a);
}
tr.append(dot, name, id, ver, seen, met, act);
tb.append(tr);
}
$("#noHosts").hidden = d.hosts.length > 0;
// users section (owner only)
const usec = document.querySelector("#sec-users");
if (usec) usec.hidden = !d.owner;
if (d.owner) {
const utb = document.querySelector("#tblUsers tbody");
if (utb) {
utb.innerHTML = "";
for (const u of d.users || []) {
const tr = document.createElement("tr");
const em = document.createElement("td");
em.className = "mono";
em.textContent = (u.email.toLowerCase() === (d.ownerEmail || "").toLowerCase()) ? "⭐ " + u.email : u.email;
const vf = document.createElement("td");
vf.textContent = u.verified ? "yes" : "pending";
const cr = document.createElement("td");
cr.textContent = fmtAgo(u.created);
const del = document.createElement("td");
const db = document.createElement("button");
db.className = "tb danger";
db.textContent = "Delete";
db.onclick = async () => {
if (!confirm("Delete " + u.email + "?")) return;
await fetch("/portal/api/user/delete", { method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: u.email }) });
refresh();
};
del.append(db);
tr.append(em, vf, cr, del);
utb.append(tr);
}
}
}
const dl = $("#dlList");
if (d.files.length) {
dl.innerHTML = "";
dl.classList.remove("muted");
for (const f of d.files) {
const a = document.createElement("a");
a.className = "dl-row";
a.href = "/dl/" + encodeURIComponent(f.name);
a.innerHTML = `<span>⬇</span><span class="name">${f.name}</span><span class="stats">${fmtBytes(f.size)}</span>`;
dl.append(a);
}
}
// (the #feed audit log is populated by loadAudit(), below)
// ---- live visitors (IP intelligence + location) --------------------
const vtb = document.querySelector("#tblVisitors tbody");
if (vtb) {
vtb.innerHTML = "";
for (const v of (d.visitors || [])) {
const g = v.geo || {};
const loc = g.country ? `${g.city ? g.city + ", " : ""}${g.country}` : "—";
const tr = document.createElement("tr");
tr.innerHTML =
`<td><span class="dot on"></span>${v.active ? "live" : "connecting"}</td>` +
`<td class="mono">${esc(v.ip) || "—"}</td>` +
`<td>${flag(g.cc)} ${esc(loc)}<div class="muted sm">${esc(g.isp)}</div></td>` +
`<td>${esc(v.host_name || v.host)}</td>` +
`<td>${v.email ? esc(v.email) : '<span class="muted">guest</span>'} <span class="pill">${esc(v.plan)}</span></td>` +
`<td>${fmtAgo(v.since)}</td>`;
vtb.append(tr);
}
const nv = document.querySelector("#noVis");
if (nv) nv.hidden = (d.visitors || []).length > 0;
}
const vr = document.querySelector("#visRecent");
if (vr) {
vr.innerHTML = "";
for (const v of (d.visitors_recent || []).slice(0, 30)) {
const g = v.geo || {};
const loc = g.country ? `${g.city ? g.city + ", " : ""}${g.country}` : "";
const row = document.createElement("div");
row.className = "feed-row";
row.innerHTML =
`<span class="stats">${fmtAgo(v.t)}</span>` +
`<span>${flag(g.cc)} <b class="mono">${esc(v.ip) || "?"}</b>${loc ? " · " + esc(loc) : ""} → ` +
`${esc(v.host_name || v.host)} <span class="pill">${esc(v.plan)}</span></span>`;
vr.append(row);
}
if (!(d.visitors_recent || []).length) vr.innerHTML = '<div class="muted">no visitors yet</div>';
}
}
async function refresh() {
const d = await api();
if (d) { showDash(); render(d); }
}
function applyTheme(t) {
document.documentElement.dataset.theme = t;
const b = document.querySelector("#btnThemeP");
if (b) b.textContent = t === "light" ? "☾ Dark" : "☀ Light";
}
applyTheme(localStorage.getItem("nd.theme") || "dark");
if ($("#btnThemeP")) $("#btnThemeP").onclick = () => {
const t = document.documentElement.dataset.theme === "light" ? "dark" : "light";
localStorage.setItem("nd.theme", t);
applyTheme(t);
};
// null-safe binding: a missing element must never kill the whole script
function on(sel, fn) { const el = $(sel); if (el) el.onclick = fn; return el; }
on("#btnLogin", login);
if ($("#pwPortal")) $("#pwPortal").addEventListener("keydown", (e) => { if (e.key === "Enter") login(); });
on("#btnCopyReg", () => {
navigator.clipboard.writeText($("#kvReg").textContent);
$("#btnCopyReg").textContent = "Copied";
setTimeout(() => ($("#btnCopyReg").textContent = "Copy"), 1200);
});
on("#btnCopySrv", () => {
navigator.clipboard.writeText($("#kvServer").textContent.replace("/ws", ""));
$("#btnCopySrv").textContent = "Copied";
setTimeout(() => ($("#btnCopySrv").textContent = "Copy"), 1200);
});
on("#btnLogout", async () => {
await fetch("/portal/logout", { redirect: "manual" });
location.href = location.pathname.replace(/\/portal$/, "/");
});
/* ---- audit log console (full persistent log + filters + CSV export) ---- */
function auditParams() {
const p = new URLSearchParams();
const k = ($("#auKind") || {}).value, q = (($("#auQ") || {}).value || "").trim(), lim = ($("#auLimit") || {}).value;
if (k) p.set("kind", k);
if (q) p.set("q", q);
if (lim) p.set("limit", lim);
return p;
}
async function loadAudit() {
const feed = $("#feed");
if (!feed) return;
const p = auditParams();
const csv = $("#auCsv");
if (csv) csv.href = "/portal/api/audit.csv?" + p.toString();
try {
const r = await fetch("/portal/api/audit?" + p.toString(), { cache: "no-store" });
if (!r.ok) return;
const d = await r.json();
const sel = $("#auKind");
if (sel && sel.options.length <= 1 && Array.isArray(d.kinds)) {
for (const k of d.kinds) { const o = document.createElement("option"); o.value = k; o.textContent = k; sel.append(o); }
}
feed.innerHTML = "";
if (!d.events.length) { feed.innerHTML = '<div class="muted">no matching events</div>'; return; }
for (const e of d.events) {
const row = document.createElement("div");
row.className = "feed-row kind-" + String(e.kind || "").replace(/[^a-z-]/g, "");
const dt = new Date((e.ts || 0) * 1000);
const t = dt.toLocaleDateString() + " " + dt.toLocaleTimeString();
const act = e.actor ? `<span class="act"> · ${esc(e.actor)}</span>` : "";
row.innerHTML = `<span class="stats">${t}</span><span><span class="kbadge">${esc(e.kind || "")}</span>${esc(e.msg || "")}${act}</span>`;
feed.append(row);
}
} catch (err) {}
}
(function wireAudit() {
const r = $("#auRefresh"); if (r) r.addEventListener("click", loadAudit);
const k = $("#auKind"); if (k) k.addEventListener("change", loadAudit);
const l = $("#auLimit"); if (l) l.addEventListener("change", loadAudit);
const q = $("#auQ"); let t; if (q) q.addEventListener("input", () => { clearTimeout(t); t = setTimeout(loadAudit, 300); });
})();
setInterval(loadAudit, 8000);
refresh().then(() => { loadAudit(); setInterval(refresh, 3000); }).catch((e) => { showLogin(); });
+79
View File
@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pricing — NetDesk</title>
<meta name="description" content="Free for individuals, self-hosted forever. Pro and Business plans for teams. Simple, honest pricing for a self-hosted remote desktop.">
<link rel="canonical" href="https://netdesk.space/pricing">
<meta property="og:title" content="Pricing — NetDesk">
<meta property="og:description" content="Free for individuals, self-hosted forever. Pro and Business plans for teams. Simple, honest pricing for a self-hosted remote desktop.">
<meta property="og:url" content="https://netdesk.space/pricing">
<meta name="theme-color" content="#0a0d14">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="site.css?v=2">
</head>
<body data-page="pricing">
<div id="siteHeader"></div>
<section class="section"><div class="wrap">
<div class="sec-head"><div class="eyebrow">Pricing</div><h2>Simple, honest pricing</h2>
<p>Free for individuals, self-hosted forever. Upgrade for teams, unlimited sessions and priority support.</p></div>
<div class="price-grid">
<div class="plan">
<h3>Individual</h3><div class="who">For personal use, self-hosted</div>
<div class="price">$0<small>/forever</small></div>
<ul>
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> Self-host on your own server</li>
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> End-to-end encryption</li>
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> 1 registered computer</li>
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> Sessions up to 60 min</li>
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> File transfer &amp; clipboard</li>
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> Community support</li>
</ul>
<a class="btn btn-ghost" href="/download">Download free</a>
</div>
<div class="plan pop"><span class="badge">Most popular</span>
<h3>Pro</h3><div class="who">For power users &amp; small teams</div>
<div class="price">$9<small>/month</small></div>
<ul>
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> Everything in Individual</li>
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> Unlimited computers</li>
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> Unlimited session length</li>
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> Address book &amp; device groups</li>
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> 2FA &amp; audit log</li>
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> Priority email support</li>
</ul>
<a class="btn btn-primary" href="/contact?plan=pro">Start Pro</a>
</div>
<div class="plan">
<h3>Business</h3><div class="who">For organizations at scale</div>
<div class="price">Custom</div>
<ul>
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> Everything in Pro</li>
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> SSO / SAML &amp; SCIM</li>
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> Central policy &amp; permissions</li>
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> On-prem &amp; air-gapped deploy</li>
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> SLA &amp; dedicated support</li>
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> Custom branding</li>
</ul>
<a class="btn btn-ghost" href="/contact?plan=business">Contact sales</a>
</div>
</div>
<p style="text-align:center;color:var(--faint);margin-top:26px;font-size:14px">
Self-hosting NetDesk is always free and open source — you only pay for the hosted convenience tiers.
Prices in USD. Cancel anytime. See our <a href="/refund" style="color:var(--accent2)">refund policy</a>.</p>
</div></section>
<section class="section alt"><div class="wrap"><div class="sec-head"><h2>Questions</h2></div>
<div class="prose">
<h3>Is NetDesk really free?</h3><p>Yes. The full software is open source and free to self-host, forever. The paid tiers add hosted convenience, unlimited sessions and team features.</p>
<h3>What's the session time limit on the free tier?</h3><p>Individual (unregistered) sessions are capped at 60 minutes; simply reconnect to continue, or sign up / upgrade to remove the limit — the same model RustDesk uses.</p>
<h3>Do you see my screen?</h3><p>Never. Sessions are end-to-end encrypted; even the relay server only sees opaque encrypted bytes.</p>
<h3>Can I use it commercially?</h3><p>Self-hosting is permitted for commercial use under the open-source license. For managed/hosted commercial use, choose Pro or Business.</p>
</div></div></section>
<div id="siteFooter"></div>
<script src="site.js?v=3"></script>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Privacy Policy — NetDesk</title>
<meta name="description" content="NetDesk Privacy Policy — privacy-first, self-hosted remote desktop.">
<link rel="canonical" href="https://netdesk.space/privacy">
<meta property="og:title" content="Privacy Policy — NetDesk">
<meta property="og:description" content="NetDesk Privacy Policy — privacy-first, self-hosted remote desktop.">
<meta property="og:url" content="https://netdesk.space/privacy">
<meta name="theme-color" content="#0a0d14">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="site.css?v=2">
</head>
<body data-page="privacy">
<div id="siteHeader"></div>
<section class="section"><div class="wrap"><div class="prose">
<h1>Privacy Policy</h1><p class="updated">Last updated: September 27, 2026</p>
<p>NetDesk is built privacy-first. This policy explains what the hosted service at netdesk.space collects. If you
self-host, none of this applies — your data stays entirely on your own server.</p>
<h2>What we cannot see</h2><p>We can never see the contents of your remote sessions. All screen, input, file and
clipboard data is end-to-end encrypted between the two endpoints; our relay only forwards opaque ciphertext.</p>
<h2>What the hosted service stores</h2>
<ul>
<li><strong>Account data:</strong> your email and a salted password hash (PBKDF2), if you create an account.</li>
<li><strong>Device registry:</strong> the IDs and names of computers you bind to your account, and online/offline status.</li>
<li><strong>Operational logs:</strong> connection timestamps, IP addresses and coarse events for security and abuse prevention, retained for a limited period.</li>
</ul>
<p>We do <strong>not</strong> store your host passwords, session contents, or screen data.</p>
<h2>Cookies</h2><p>We use a single essential cookie to keep you signed in. No third-party advertising or tracking cookies.</p>
<h2>Sharing</h2><p>We do not sell your data. We share it only as required by law or to operate the service (e.g., our mail
provider for verification emails).</p>
<h2>Your rights</h2><p>You may access, export or delete your account data at any time from the console, or by emailing
<a href="mailto:privacy@netdesk.space">privacy@netdesk.space</a>.</p>
<h2>Contact</h2><p>Questions about privacy? Email privacy@netdesk.space.</p>
</div></div></section>
<div id="siteFooter"></div>
<script src="site.js?v=3"></script>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Refund Policy — NetDesk</title>
<meta name="description" content="NetDesk refund policy: 14-day money-back guarantee on paid plans.">
<link rel="canonical" href="https://netdesk.space/refund">
<meta property="og:title" content="Refund Policy — NetDesk">
<meta property="og:description" content="NetDesk refund policy: 14-day money-back guarantee on paid plans.">
<meta property="og:url" content="https://netdesk.space/refund">
<meta name="theme-color" content="#0a0d14">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="site.css?v=2">
</head>
<body data-page="refund">
<div id="siteHeader"></div>
<section class="section"><div class="wrap"><div class="prose">
<h1>Refund Policy</h1><p class="updated">Last updated: September 27, 2026</p>
<p>We want you to be happy with NetDesk Pro and Business.</p>
<h2>14-day money-back guarantee</h2><p>If you're not satisfied with a paid plan, request a full refund within 14 days
of your first payment — no questions asked. Email <a href="mailto:billing@netdesk.space">billing@netdesk.space</a>.</p>
<h2>Renewals</h2><p>Renewal charges are refundable within 7 days of the renewal date. Cancel anytime to stop future charges.</p>
<h2>Self-hosting</h2><p>The self-hosted software is free and open source — there is nothing to refund.</p>
</div></div></section>
<div id="siteFooter"></div>
<script src="site.js?v=3"></script>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Security — NetDesk</title>
<meta name="description" content="How NetDesk keeps your remote sessions private: AES-256 end-to-end encryption, PBKDF2 auth, TOTP 2FA, TLS transport and open source.">
<link rel="canonical" href="https://netdesk.space/security">
<meta property="og:title" content="Security — NetDesk">
<meta property="og:description" content="How NetDesk keeps your remote sessions private: AES-256 end-to-end encryption, PBKDF2 auth, TOTP 2FA, TLS transport and open source.">
<meta property="og:url" content="https://netdesk.space/security">
<meta name="theme-color" content="#0a0d14">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="site.css?v=2">
</head>
<body data-page="security">
<div id="siteHeader"></div>
<section class="section"><div class="wrap"><div class="prose">
<h1>Security &amp; Trust</h1><p class="updated">Our commitment to keeping your remote sessions private.</p>
<h2>End-to-end encryption by default</h2>
<p>Every NetDesk session negotiates an ephemeral <strong>ECDH P-256</strong> key exchange and encrypts all media with
<strong>AES-256-GCM</strong>, per frame, with a fresh nonce. The relay server forwards only opaque ciphertext — it
cannot decrypt your screen, input, files or clipboard. Only the two endpoints hold the keys.</p>
<h2>Authentication</h2>
<p>The host password never leaves your machine in the clear: the viewer proves knowledge of it with a
<strong>PBKDF2-HMAC-SHA256 (200,000 iterations)</strong> challenge/response, throttled on failure. Optional
<strong>TOTP two-factor</strong> adds an authenticator code at connect. An optional registration key restricts which
hosts may register with your server.</p>
<h2>Transport</h2>
<p>All traffic runs over <strong>TLS (WSS)</strong> to your server. We recommend a real certificate (Let's Encrypt);
NetDesk ships automation for it. HTTP is available only for localhost/LAN testing.</p>
<h2>You own the data</h2>
<p>NetDesk is self-hosted. There is no NetDesk cloud in the path. Your session metadata, accounts and audit logs live on
<strong>your</strong> server. Nothing is sent to a third party.</p>
<h2>Open source</h2>
<p>The entire stack is open source and auditable. Don't trust our word — read the code at
<a href="/git/akronlogic/netdesk">netdesk.space/git/akronlogic/netdesk</a>.</p>
<h2>Responsible disclosure</h2>
<p>Found a vulnerability? Email <a href="mailto:security@netdesk.space">security@netdesk.space</a>. We respond within 72
hours and credit reporters.</p>
</div></div></section>
<div id="siteFooter"></div>
<script src="site.js?v=3"></script>
</body>
</html>
+289
View File
@@ -0,0 +1,289 @@
/* ================================================================
NetDesk marketing site — production design system
================================================================ */
:root {
--bg: #0a0d14; --bg2: #10141e; --bg3: #161c28; --card: #141a26;
--fg: #eaeff8; --muted: #9aa4b6; --faint: #6b7688;
--accent: #3d7eff; --accent2: #6aa1ff; --accent-dim: rgba(61,126,255,.12);
--ok: #2fbf71; --border: #212a3a; --border-soft: #1a2130;
--r: 14px; --r-sm: 9px; --maxw: 1160px;
--grad: linear-gradient(135deg, #3d7eff 0%, #6aa1ff 55%, #2fbf71 130%);
--shadow: 0 24px 60px rgba(0,0,0,.45);
}
:root[data-theme="light"] {
--bg: #f6f8fc; --bg2: #ffffff; --bg3: #eef2f8; --card: #ffffff;
--fg: #131a26; --muted: #55617a; --faint: #97a1b4;
--border: #e2e8f1; --border-soft: #edf1f7; --accent-dim: rgba(61,126,255,.08);
--shadow: 0 24px 60px rgba(24,39,75,.10);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
background: var(--bg); color: var(--fg); line-height: 1.6; font-size: 16px;
-webkit-font-smoothing: antialiased;
}
a { color: inherit; text-decoration: none; }
img { max-width: 100%; display: block; }
.wrap { max-width: var(--maxw); margin: 0 auto; padding: 0 22px; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
/* ---- header ---- */
.nz { position: sticky; top: 0; z-index: 50; backdrop-filter: blur(14px);
background: color-mix(in srgb, var(--bg) 82%, transparent); border-bottom: 1px solid var(--border-soft); }
.nz-in { display: flex; align-items: center; justify-content: space-between; height: 66px; }
.nz-brand { display: flex; align-items: center; gap: 10px; font-weight: 800; font-size: 20px; letter-spacing: -.4px; }
.nz-logo { width: 34px; height: 34px; border-radius: 9px; background: var(--grad);
display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 14px rgba(61,126,255,.35); }
.nz-links { display: flex; align-items: center; gap: 4px; }
.nz-links a { padding: 9px 14px; border-radius: 8px; color: var(--muted); font-weight: 500; font-size: 15px; transition: .15s; }
.nz-links a:hover { color: var(--fg); background: var(--bg3); }
.nz-links a.active { color: var(--fg); }
.nz-cta { display: flex; align-items: center; gap: 10px; }
.nz-burger { display: none; background: none; border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; color: var(--fg); cursor: pointer; }
.btn { display: inline-flex; align-items: center; gap: 8px; padding: 11px 20px; border-radius: 10px;
font-weight: 600; font-size: 15px; cursor: pointer; border: 1px solid transparent; transition: .15s; white-space: nowrap; }
.btn-primary { background: var(--grad); color: #fff; box-shadow: 0 8px 22px rgba(61,126,255,.32); }
.btn-primary:hover { filter: brightness(1.07); transform: translateY(-1px); }
.btn-ghost { background: var(--bg2); color: var(--fg); border-color: var(--border); }
.btn-ghost:hover { border-color: var(--accent); }
.btn-lg { padding: 15px 28px; font-size: 16.5px; border-radius: 12px; }
/* ---- hero ---- */
.hero { position: relative; overflow: hidden; padding: 92px 0 70px; text-align: center; }
.hero::before { content: ""; position: absolute; inset: 0; z-index: -1;
background: radial-gradient(760px 420px at 50% -8%, rgba(61,126,255,.22), transparent 62%),
radial-gradient(680px 420px at 88% 24%, rgba(47,191,113,.12), transparent 60%); }
.tagpill { display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px; border-radius: 999px;
background: var(--accent-dim); border: 1px solid var(--border); color: var(--accent2); font-size: 13.5px; font-weight: 600; margin-bottom: 22px; }
.hero h1 { font-size: clamp(38px, 6vw, 66px); line-height: 1.05; letter-spacing: -1.6px; font-weight: 850; margin-bottom: 20px; }
.hero h1 .g { background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; }
.hero p.sub { font-size: clamp(17px, 2.2vw, 21px); color: var(--muted); max-width: 720px; margin: 0 auto 32px; }
.hero-cta { display: flex; gap: 14px; justify-content: center; flex-wrap: wrap; }
.hero-note { margin-top: 18px; color: var(--faint); font-size: 14px; }
.hero-shot { max-width: 940px; margin: 54px auto 0; border-radius: 16px; border: 1px solid var(--border);
box-shadow: var(--shadow); overflow: hidden; background: var(--bg2); }
/* ---- sections ---- */
.section { padding: 74px 0; }
.section.alt { background: var(--bg2); border-top: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft); }
.sec-head { text-align: center; max-width: 660px; margin: 0 auto 48px; }
.sec-head .eyebrow { color: var(--accent2); font-weight: 700; font-size: 14px; letter-spacing: 1px; text-transform: uppercase; }
.sec-head h2 { font-size: clamp(28px, 4vw, 42px); letter-spacing: -1px; font-weight: 800; margin: 10px 0 12px; }
.sec-head p { color: var(--muted); font-size: 18px; }
.grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
.grid2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; }
.feat { background: var(--card); border: 1px solid var(--border); border-radius: var(--r); padding: 26px; transition: .18s; }
.feat:hover { border-color: var(--accent); transform: translateY(-3px); box-shadow: var(--shadow); }
.feat .ic { width: 46px; height: 46px; border-radius: 11px; background: var(--accent-dim); color: var(--accent2);
display: flex; align-items: center; justify-content: center; margin-bottom: 16px; }
.feat h3 { font-size: 18.5px; font-weight: 700; margin-bottom: 8px; }
.feat p { color: var(--muted); font-size: 15px; }
/* ---- pricing ---- */
.price-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; align-items: stretch; }
.plan { background: var(--card); border: 1px solid var(--border); border-radius: var(--r); padding: 32px 28px; display: flex; flex-direction: column; }
.plan.pop { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), var(--shadow); position: relative; }
.plan .badge { position: absolute; top: -13px; left: 50%; transform: translateX(-50%); background: var(--grad);
color: #fff; font-size: 12.5px; font-weight: 700; padding: 5px 14px; border-radius: 999px; }
.plan h3 { font-size: 20px; font-weight: 800; }
.plan .who { color: var(--muted); font-size: 14.5px; margin: 4px 0 18px; }
.plan .price { font-size: 44px; font-weight: 850; letter-spacing: -1.5px; }
.plan .price small { font-size: 16px; color: var(--muted); font-weight: 500; }
.plan ul { list-style: none; margin: 22px 0; display: grid; gap: 12px; }
.plan li { display: flex; gap: 10px; font-size: 15px; color: var(--fg); }
.plan li svg { flex-shrink: 0; color: var(--ok); margin-top: 3px; }
.plan .btn { justify-content: center; margin-top: auto; }
/* ---- prose (legal/about) ---- */
.prose { max-width: 820px; margin: 0 auto; }
.prose h1 { font-size: 40px; letter-spacing: -1px; margin-bottom: 8px; }
.prose .updated { color: var(--faint); font-size: 14px; margin-bottom: 34px; }
.prose h2 { font-size: 24px; margin: 34px 0 12px; letter-spacing: -.4px; }
.prose h3 { font-size: 18px; margin: 22px 0 8px; }
.prose p, .prose li { color: var(--muted); margin-bottom: 12px; }
.prose ul, .prose ol { padding-left: 22px; margin-bottom: 14px; }
.prose a { color: var(--accent2); }
.prose strong { color: var(--fg); }
/* ---- contact ---- */
.contact-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; align-items: start; }
.field { margin-bottom: 16px; }
.field label { display: block; font-size: 14px; font-weight: 600; margin-bottom: 6px; }
.field input, .field textarea, .field select { width: 100%; background: var(--bg2); border: 1px solid var(--border);
border-radius: 10px; padding: 12px 14px; color: var(--fg); font-size: 15px; font-family: inherit; outline: none; }
.field input:focus, .field textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
.info-row { display: flex; gap: 14px; padding: 16px 0; border-bottom: 1px solid var(--border-soft); }
.info-row .ic { color: var(--accent2); }
/* ---- cta band ---- */
.ctaband { text-align: center; padding: 70px 0; background: var(--bg2); border-top: 1px solid var(--border-soft); }
.ctaband h2 { font-size: clamp(28px, 4vw, 40px); letter-spacing: -1px; margin-bottom: 12px; }
.ctaband p { color: var(--muted); font-size: 18px; margin-bottom: 26px; }
/* ---- footer ---- */
.ft { background: var(--bg2); border-top: 1px solid var(--border); padding: 56px 0 28px; }
.ft-top { display: grid; grid-template-columns: 1.6fr 1fr 1fr 1fr; gap: 30px; }
.ft-brand p { color: var(--muted); font-size: 14.5px; margin: 14px 0; max-width: 300px; }
.ft-col h4 { font-size: 13px; text-transform: uppercase; letter-spacing: .8px; color: var(--faint); margin-bottom: 14px; }
.ft-col a { display: block; color: var(--muted); font-size: 15px; padding: 5px 0; transition: .12s; }
.ft-col a:hover { color: var(--fg); }
.ft-bot { display: flex; align-items: center; justify-content: space-between; margin-top: 40px; padding-top: 22px;
border-top: 1px solid var(--border-soft); color: var(--faint); font-size: 14px; flex-wrap: wrap; gap: 12px; }
.ft-social { display: flex; gap: 12px; }
.ft-social a { color: var(--muted); } .ft-social a:hover { color: var(--fg); }
.theme-toggle { background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; color: var(--fg);
padding: 8px 12px; cursor: pointer; font-size: 13.5px; }
/* ---- misc ---- */
.logos { display: flex; gap: 34px; justify-content: center; align-items: center; flex-wrap: wrap; opacity: .8; color: var(--muted); font-weight: 600; }
.compare { width: 100%; border-collapse: collapse; }
.compare th, .compare td { padding: 14px 16px; text-align: left; border-bottom: 1px solid var(--border-soft); font-size: 15px; }
.compare th { color: var(--faint); font-weight: 600; font-size: 13px; text-transform: uppercase; }
.compare td.y { color: var(--ok); font-weight: 700; }
.compare td.n { color: var(--faint); }
.compare tr td:first-child { color: var(--fg); font-weight: 600; }
@media (max-width: 900px) {
.nz-links { display: none; }
.nz-burger { display: block; }
.nz-links.open { display: flex; position: absolute; top: 66px; left: 0; right: 0; flex-direction: column;
background: var(--bg2); border-bottom: 1px solid var(--border); padding: 12px; gap: 4px; }
.grid3, .price-grid, .ft-top { grid-template-columns: 1fr; }
.grid2, .contact-grid { grid-template-columns: 1fr; }
.ft-top { gap: 24px; }
}
@media (max-width: 600px) {
/* Phones: keep the top bar to logo + primary CTA + burger so the page never
scrolls sideways. The secondary "Sign in" link stays reachable in-page. */
.nz-cta .btn-ghost { display: none; }
.nz-cta { gap: 8px; }
}
/* ================================================================
ELEVATION LAYER v2 — award-winning, enterprise-grade polish.
Component-scoped overrides of the base system above (cascade wins),
so every marketing page that links site.css is lifted at once.
================================================================ */
:root{
--accent-grad: linear-gradient(135deg,#4d8bff 0%,#6aa1ff 42%,#34d399 115%);
--glass: color-mix(in srgb, var(--card) 70%, transparent);
--hair: rgba(255,255,255,.07);
--hair-strong: rgba(255,255,255,.14);
--shadow-sm: 0 2px 10px rgba(0,0,0,.28);
--shadow-lg: 0 34px 90px -28px rgba(0,0,0,.70);
--ring: 0 0 0 4px rgba(77,139,255,.14);
--r: 16px; --r-sm: 11px;
}
:root[data-theme="light"]{
--glass: rgba(255,255,255,.72);
--hair: rgba(16,24,40,.06); --hair-strong: rgba(16,24,40,.12);
--shadow-lg: 0 34px 90px -30px rgba(24,39,75,.22);
}
/* fine film-grain for tactile depth — fixed, never intercepts input */
body::after{
content:""; position:fixed; inset:0; z-index:9998; pointer-events:none; opacity:.03;
mix-blend-mode:overlay;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* ---- scroll reveal (universal; site.js adds .in on intersect) ---- */
.reveal{ opacity:0; transform:translateY(20px); transition:opacity .7s cubic-bezier(.2,.7,.2,1), transform .7s cubic-bezier(.2,.7,.2,1); will-change:opacity,transform; }
.reveal.in{ opacity:1; transform:none; }
@media (prefers-reduced-motion: reduce){ .reveal{ opacity:1 !important; transform:none !important; } }
/* ---- nav: crisper glass + gradient hairline ---- */
.nz{ background: color-mix(in srgb, var(--bg) 68%, transparent); border-bottom:1px solid var(--hair); }
.nz::after{ content:""; position:absolute; left:0; right:0; bottom:-1px; height:1px;
background:linear-gradient(90deg,transparent,rgba(77,139,255,.4),rgba(52,211,153,.3),transparent); opacity:.6; }
.nz-logo{ box-shadow:0 6px 18px -4px rgba(77,139,255,.5), inset 0 1px 0 rgba(255,255,255,.3); }
.nz-links a:hover{ background:var(--hair); color:var(--fg); }
.nz-links a.active{ color:var(--fg); }
.nz-links a.active::after{ content:""; display:block; height:2px; margin-top:7px; border-radius:2px; background:var(--accent-grad); }
/* ---- buttons: gradient + inner highlight + hover sheen ---- */
.btn{ border-radius:12px; letter-spacing:-.1px; transition:transform .16s ease, box-shadow .16s ease, filter .16s ease, border-color .16s ease; }
.btn-primary{ background:var(--accent-grad); position:relative; overflow:hidden;
box-shadow:0 10px 28px -8px rgba(77,139,255,.55), inset 0 1px 0 rgba(255,255,255,.28); }
.btn-primary::before{ content:""; position:absolute; top:0; left:-120%; width:60%; height:100%;
background:linear-gradient(100deg,transparent,rgba(255,255,255,.35),transparent); transform:skewX(-18deg); transition:left .6s ease; }
.btn-primary:hover::before{ left:140%; }
.btn-primary:hover{ transform:translateY(-2px); box-shadow:0 16px 40px -10px rgba(77,139,255,.6), inset 0 1px 0 rgba(255,255,255,.3); filter:none; }
.btn-ghost{ background:var(--glass); backdrop-filter:blur(10px); border-color:var(--hair-strong); }
.btn-ghost:hover{ border-color:var(--accent); background:var(--card); transform:translateY(-1px); }
.btn:focus-visible{ outline:none; box-shadow:var(--ring); }
/* ---- feature cards: gradient border + cursor spotlight + lifted hover ---- */
.feat{ position:relative; border:1px solid transparent; border-radius:var(--r); padding:28px; overflow:hidden;
background:linear-gradient(var(--card),var(--card)) padding-box,
linear-gradient(180deg,var(--hair-strong),rgba(255,255,255,.015)) border-box;
transition:transform .22s cubic-bezier(.2,.7,.2,1), box-shadow .22s ease; }
.feat::before{ content:""; position:absolute; inset:0; border-radius:inherit; opacity:0; transition:opacity .3s; pointer-events:none;
background:radial-gradient(420px circle at var(--mx,50%) var(--my,-10%), rgba(77,139,255,.16), transparent 42%); }
.feat:hover{ transform:translateY(-5px); box-shadow:var(--shadow-lg); }
.feat:hover::before{ opacity:1; }
.feat .ic{ width:50px; height:50px; border-radius:13px; color:#a8c6ff; border:1px solid var(--hair);
background:linear-gradient(135deg, rgba(77,139,255,.20), rgba(52,211,153,.10));
box-shadow:inset 0 1px 0 rgba(255,255,255,.14), 0 8px 20px -8px rgba(77,139,255,.45); }
.feat h3{ font-size:19px; letter-spacing:-.3px; }
.feat p{ font-size:15px; line-height:1.62; }
/* ---- generic card (pricing/other) spotlight hover parity ---- */
.plan{ position:relative; border-radius:var(--r); transition:transform .22s cubic-bezier(.2,.7,.2,1), box-shadow .22s ease; }
.plan:hover{ transform:translateY(-4px); box-shadow:var(--shadow-lg); }
.plan.pop{ border:1px solid transparent;
background:linear-gradient(var(--card),var(--card)) padding-box, var(--accent-grad) border-box;
box-shadow:0 0 70px -24px rgba(77,139,255,.6), var(--shadow-lg); }
.plan .price{ background:var(--accent-grad); -webkit-background-clip:text; background-clip:text; color:transparent; }
.plan.pop .badge{ box-shadow:0 8px 22px -6px rgba(77,139,255,.6); }
/* ---- section heads: gradient eyebrow + tighter type ---- */
.sec-head .eyebrow{ display:inline-block; background:var(--accent-grad); -webkit-background-clip:text; background-clip:text; color:transparent; }
.sec-head h2{ letter-spacing:-1.2px; }
.section.alt{ background:color-mix(in srgb,var(--bg2) 92%, #000 8%); }
/* ---- comparison table: container + highlighted NetDesk column + icons ---- */
.compare{ border-collapse:separate; border-spacing:0; border:1px solid var(--hair); border-radius:var(--r); overflow:hidden; background:var(--card); box-shadow:var(--shadow-sm); }
.compare th, .compare td{ border-bottom:1px solid var(--hair); }
.compare tr:last-child td{ border-bottom:none; }
.compare th:nth-child(2), .compare td:nth-child(2){ background:color-mix(in srgb,var(--accent) 8%, transparent); position:relative; }
.compare th:nth-child(2){ color:var(--accent2); }
.compare thead th:nth-child(2)::after, .compare tr:first-child td:nth-child(2)::before{ }
.compare td.y{ color:var(--ok); }
.compare tr td:first-child{ color:var(--fg); }
/* ---- enterprise trust: stats band ---- */
.statband{ border-top:1px solid var(--hair); border-bottom:1px solid var(--hair);
background:color-mix(in srgb,var(--bg2) 60%, transparent); }
.statband .wrap{ display:grid; grid-template-columns:repeat(4,1fr); gap:18px; padding:30px 22px; }
.stat{ text-align:center; position:relative; }
.stat:not(:last-child)::after{ content:""; position:absolute; right:-9px; top:14%; height:72%; width:1px; background:var(--hair); }
.stat b{ display:block; font-size:clamp(26px,3.4vw,36px); font-weight:850; letter-spacing:-1px; line-height:1;
background:var(--accent-grad); -webkit-background-clip:text; background-clip:text; color:transparent; }
.stat span{ display:block; margin-top:8px; color:var(--muted); font-size:13.5px; font-weight:500; }
/* ---- logos strip: refined ---- */
.logos{ gap:40px; opacity:1; }
.logos span{ color:var(--faint); font-weight:650; font-size:15px; letter-spacing:.2px; transition:color .2s, filter .2s; filter:grayscale(1) opacity(.7); }
.logos span:hover{ color:var(--muted); filter:none; }
/* ---- hero shot: layered depth ---- */
.hero-shot, .hz-shot{ box-shadow:var(--shadow-lg), 0 0 0 1px var(--hair); }
/* ---- footer: subtle top glow ---- */
.ft{ position:relative; border-top:1px solid var(--hair); }
.ft::before{ content:""; position:absolute; top:-1px; left:0; right:0; height:1px;
background:linear-gradient(90deg,transparent,rgba(77,139,255,.35),transparent); }
.theme-toggle:hover{ border-color:var(--accent); }
/* ---- form fields: softer focus ---- */
.field input:focus, .field textarea:focus, .field select:focus{ box-shadow:var(--ring); }
/* ---- responsive: stats stack ---- */
@media (max-width:760px){
.statband .wrap{ grid-template-columns:repeat(2,1fr); gap:24px 12px; }
.stat:nth-child(2)::after{ display:none; }
}
+103
View File
@@ -0,0 +1,103 @@
/* NetDesk marketing site: shared header + footer, theme, mobile nav. */
(function () {
"use strict";
var YEAR = 2026;
var NAV = [
["Features", "/features"], ["Pricing", "/pricing"],
["Security", "/security"], ["Download", "/download"],
["Source", "/git/akronlogic/netdesk"]
];
var page = (document.body.getAttribute("data-page") || "").toLowerCase();
function svgLogo() {
return '<svg width="20" height="20" viewBox="0 0 32 32"><path d="M8 20l8-12 8 12-8-4z" fill="#fff"/>' +
'<rect x="12" y="18" width="8" height="7" rx="2" fill="#fff"/></svg>';
}
function header() {
var links = NAV.map(function (n) {
var active = page === n[0].toLowerCase() ? " class=\"active\"" : "";
return '<a href="' + n[1] + '"' + active + '>' + n[0] + "</a>";
}).join("");
return '<header class="nz"><div class="wrap nz-in">' +
'<a class="nz-brand" href="/"><span class="nz-logo">' + svgLogo() + "</span>NetDesk</a>" +
'<nav class="nz-links" id="nzLinks">' + links +
'<a href="/contact">Contact</a><a href="/portal">Self-Host Console</a></nav>' +
'<div class="nz-cta">' +
'<a class="btn btn-ghost" href="/app" id="btnSignin">Sign in</a>' +
'<a class="btn btn-primary" href="/download">Get NetDesk</a>' +
'<button class="nz-burger" id="nzBurger" aria-label="Menu">☰</button>' +
"</div></div></header>";
}
function footer() {
return '<footer class="ft"><div class="wrap">' +
'<div class="ft-top">' +
'<div class="ft-brand"><a class="nz-brand" href="/"><span class="nz-logo">' + svgLogo() + "</span>NetDesk</a>" +
"<p>The fast, secure, self-hosted remote desktop. Own your infrastructure, your keys, and your data — " +
"the alternative to TeamViewer, AnyDesk and RustDesk that you fully control.</p>" +
'<button class="theme-toggle" id="ftTheme">☀ Toggle theme</button></div>' +
'<div class="ft-col"><h4>Product</h4>' +
'<a href="/features">Features</a><a href="/pricing">Pricing</a><a href="/download">Download</a>' +
'<a href="/security">Security</a><a href="/app">Web Client</a><a href="/git/akronlogic/netdesk">Source Code</a></div>' +
'<div class="ft-col"><h4>Company</h4>' +
'<a href="/about">About</a><a href="/contact">Contact</a><a href="/portal">Self-Hosting Console</a>' +
'<a href="/git/akronlogic/netdesk">Open Source</a></div>' +
'<div class="ft-col"><h4>Legal</h4>' +
'<a href="/terms">Terms of Service</a><a href="/privacy">Privacy Policy</a>' +
'<a href="/security">Security &amp; Trust</a><a href="/refund">Refund Policy</a></div>' +
"</div>" +
'<div class="ft-bot"><span>© ' + YEAR + ' NetDesk. Self-hosted remote access, done right.</span>' +
'<span class="ft-social">' +
'<a href="/git/akronlogic/netdesk" aria-label="Source code">Source</a>' +
'<a href="/contact">Support</a><a href="/privacy">Privacy</a></span>' +
"</div></div></footer>";
}
function applyTheme(t) {
document.documentElement.setAttribute("data-theme", t);
try { localStorage.setItem("nd.theme", t); } catch (e) {}
}
document.addEventListener("DOMContentLoaded", function () {
var h = document.getElementById("siteHeader");
var f = document.getElementById("siteFooter");
if (h) h.innerHTML = header();
if (f) f.innerHTML = footer();
var burger = document.getElementById("nzBurger");
if (burger) burger.onclick = function () { document.getElementById("nzLinks").classList.toggle("open"); };
var ft = document.getElementById("ftTheme");
if (ft) ft.onclick = function () {
applyTheme(document.documentElement.getAttribute("data-theme") === "light" ? "dark" : "light");
};
// reflect signed-in state in the header
fetch("/api/auth/me").then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {
var b = document.getElementById("btnSignin");
if (d && d.email && b) { b.textContent = "My Computers"; b.href = "/app"; }
}).catch(function () {});
// --- scroll reveal (every page that uses .reveal) ---
var revs = document.querySelectorAll(".reveal");
if (revs.length && "IntersectionObserver" in window) {
var io = new IntersectionObserver(function (es) {
es.forEach(function (e) { if (e.isIntersecting) { e.target.classList.add("in"); io.unobserve(e.target); } });
}, { threshold: 0.1, rootMargin: "0px 0px -8% 0px" });
revs.forEach(function (el) { io.observe(el); });
} else {
revs.forEach(function (el) { el.classList.add("in"); });
}
// --- cursor spotlight on cards (sets --mx/--my for the ::before glow) ---
function spot(e) {
var r = e.currentTarget.getBoundingClientRect();
e.currentTarget.style.setProperty("--mx", (e.clientX - r.left) + "px");
e.currentTarget.style.setProperty("--my", (e.clientY - r.top) + "px");
}
document.querySelectorAll(".feat, .plan").forEach(function (c) {
c.addEventListener("mousemove", spot, { passive: true });
});
});
try { applyTheme(localStorage.getItem("nd.theme") || "dark"); } catch (e) { applyTheme("dark"); }
})();
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://netdesk.space/</loc><changefreq>weekly</changefreq><priority>1.0</priority></url>
<url><loc>https://netdesk.space/pricing</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://netdesk.space/features</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://netdesk.space/security</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://netdesk.space/download</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://netdesk.space/contact</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://netdesk.space/about</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://netdesk.space/terms</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://netdesk.space/privacy</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://netdesk.space/refund</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>
</urlset>
+44
View File
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Terms of Service — NetDesk</title>
<meta name="description" content="NetDesk Terms of Service.">
<link rel="canonical" href="https://netdesk.space/terms">
<meta property="og:title" content="Terms of Service — NetDesk">
<meta property="og:description" content="NetDesk Terms of Service.">
<meta property="og:url" content="https://netdesk.space/terms">
<meta name="theme-color" content="#0a0d14">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="site.css?v=2">
</head>
<body data-page="terms">
<div id="siteHeader"></div>
<section class="section"><div class="wrap"><div class="prose">
<h1>Terms of Service</h1><p class="updated">Last updated: September 27, 2026</p>
<p>These Terms govern your use of NetDesk software and the optional hosted services provided at netdesk.space
("Service"). By downloading, self-hosting or using NetDesk, you agree to these Terms.</p>
<h2>1. License</h2><p>NetDesk's source code is provided under its open-source license. You may self-host, modify and
use it, including commercially, subject to that license. The hosted Pro and Business services are provided under these Terms.</p>
<h2>2. Acceptable use</h2><p>You may only use NetDesk to access computers you own or are explicitly authorized to
administer. You must not use NetDesk to gain unauthorized access to any system, to distribute malware, or for any
unlawful purpose. You are responsible for complying with all applicable laws in your jurisdiction.</p>
<h2>3. Accounts</h2><p>You are responsible for safeguarding your account credentials, host passwords and 2FA secrets.
Notify us of any unauthorized use.</p>
<h2>4. Free tier &amp; limits</h2><p>The free Individual tier is provided as-is and may include session time limits and
device limits. We may adjust free-tier limits with reasonable notice.</p>
<h2>5. Payment</h2><p>Paid plans are billed in advance and renew automatically until cancelled. Fees are non-refundable
except as stated in our <a href="/refund">Refund Policy</a>.</p>
<h2>6. Disclaimer</h2><p>THE SOFTWARE AND SERVICE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. To the maximum extent
permitted by law, NetDesk disclaims all warranties and is not liable for any indirect, incidental or consequential damages.</p>
<h2>7. Changes</h2><p>We may update these Terms; material changes will be posted here with a new date.</p>
<h2>8. Contact</h2><p>Questions? <a href="/contact">Contact us</a> or email hello@netdesk.space.</p>
</div></div></section>
<div id="siteFooter"></div>
<script src="site.js?v=3"></script>
</body>
</html>