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

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+971
View File
@@ -0,0 +1,971 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NetDesk v1.5.0 — Enterprise Remote Desktop</title>
<style>
:root {
--win-bg: #11161d; --sidebar-bg: #0d1117; --card-bg: #181f29; --card-border: #26303f;
--input-bg: #0f141b; --input-border: #252f3d;
--em: #19c87e; --em-br: #25db8e; --crimson-bg: #321c22; --crimson-bd: #7c3139; --crimson-fg: #e05a61;
--blue: #3b9eff; --fg: #e6edf3; --muted: #8e9aa9; --faint: #64748b;
}
* { box-sizing: border-box; user-select: none; -webkit-font-smoothing: antialiased; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: radial-gradient(circle at 18% 15%, rgba(25,200,126,.08) 0%, transparent 42%),
radial-gradient(circle at 82% 85%, rgba(59,158,255,.07) 0%, transparent 45%),
linear-gradient(180deg, #0b0f15 0%, #06080c 100%);
font-family: 'Segoe UI', 'Inter', system-ui, sans-serif; color: var(--fg); font-size: 13.5px;
overflow: hidden;
}
.mono { font-family: 'Cascadia Mono', Consolas, monospace; user-select: text; }
input, textarea { user-select: text; }
.shell { height: 100vh; display: flex; flex-direction: column; background: var(--win-bg);
border: 1px solid #263241; overflow: hidden; }
/* ===== titlebar ===== */
.tbar { height: 38px; background: #0a0e13; border-bottom: 1px solid #1d2633; display: flex;
align-items: center; justify-content: space-between; padding-left: 12px; flex-shrink: 0; }
.tbar-l { display: flex; align-items: center; gap: 10px; }
.tbar-logo { width: 18px; height: 18px; border-radius: 4px; background: linear-gradient(180deg,#25d386,#16a765);
display: flex; align-items: center; justify-content: center; }
.tbar-title { font-size: 12.5px; color: #d8dee9; font-weight: 500; }
.tbar-sub { font-size: 11px; color: var(--faint); }
.telemetry { display: flex; align-items: center; gap: 10px; font-size: 11px; color: #8b96a6;
background: #131922; border: 1px solid #222c3a; padding: 3px 12px; border-radius: 999px; }
.telemetry .tdot { width: 6px; height: 6px; border-radius: 50%; background: var(--em); }
.telemetry .sep { color: #334155; }
.telemetry b { color: #cbd5e1; font-weight: 600; }
#pingMs { color: var(--em); }
.winbtns { display: flex; height: 100%; }
.wbtn { width: 44px; height: 100%; display: flex; align-items: center; justify-content: center;
color: #94a3b8; background: none; border: none; cursor: pointer; transition: .12s; }
.wbtn:hover { background: rgba(255,255,255,.08); color: #fff; }
.wbtn.close:hover { background: #e81123; color: #fff; }
/* ===== layout ===== */
.body { flex: 1; display: flex; min-height: 0; }
/* sidebar */
.side { width: 236px; background: var(--sidebar-bg); border-right: 1px solid #1e2734;
display: flex; flex-direction: column; justify-content: space-between; padding: 14px; flex-shrink: 0; }
.brand { display: flex; align-items: center; gap: 12px; padding: 2px 2px 16px; border-bottom: 1px solid #1c2430; }
.brand-logo { width: 40px; height: 40px; border-radius: 10px; background: linear-gradient(180deg,#24cf83,#16ad69);
display: flex; align-items: center; justify-content: center; box-shadow: 0 3px 14px rgba(25,200,126,.32); flex-shrink: 0; }
.brand-name { font-size: 21px; font-weight: 700; color: var(--em); letter-spacing: -.3px; line-height: 1; }
.brand-ver { font-size: 12px; color: #6e7b8b; }
.brand-tag { font-size: 10.5px; color: #8b96a6; display: block; margin-top: 4px; }
.navsec { font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;
color: #586577; padding: 14px 8px 6px; }
.nav-item { display: flex; align-items: center; justify-content: space-between; width: 100%;
padding: 9px 11px; border-radius: 8px; font-size: 13px; font-weight: 500; color: #94a3b8;
background: transparent; border: 1px solid transparent; cursor: pointer; transition: .15s;
text-align: left; margin-bottom: 3px; font-family: inherit; }
.nav-item:hover { color: #e2e8f0; background: rgba(255,255,255,.04); }
.nav-item.active { color: #fff; background: linear-gradient(90deg, rgba(25,200,126,.16), rgba(25,200,126,.03));
border-color: rgba(25,200,126,.32); }
.nav-item .nl { display: flex; align-items: center; gap: 10px; }
.nav-item svg { flex-shrink: 0; }
.nav-badge { font-size: 10px; padding: 2px 7px; border-radius: 6px; font-weight: 600; }
.nb-em { background: rgba(25,200,126,.15); color: var(--em); }
.nb-red { background: var(--crimson-bg); color: var(--crimson-fg); border: 1px solid var(--crimson-bd); }
.nb-grey { background: #1e293b; color: #94a3b8; }
.side-info { background: #131922; border: 1px solid #222c3a; border-radius: 9px; padding: 10px 11px; margin-bottom: 10px; }
.side-row { display: flex; align-items: center; justify-content: space-between; font-size: 11px; color: #8b96a6; }
.side-row + .side-row { margin-top: 5px; }
.acct-chip { display: flex; align-items: center; gap: 10px; padding: 2px 4px; }
.acct-ava { width: 28px; height: 28px; border-radius: 50%; background: rgba(25,200,126,.2);
border: 1px solid rgba(25,200,126,.4); display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 700; color: var(--em); flex-shrink: 0; }
.acct-mail { font-size: 11.5px; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.acct-sub { font-size: 10px; color: var(--em); }
/* main */
.main { flex: 1; padding: 16px 18px; display: flex; flex-direction: column; min-width: 0; overflow-y: auto;
background-image: linear-gradient(to right, rgba(255,255,255,.014) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,.014) 1px, transparent 1px);
background-size: 28px 28px; }
.toprow { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
.connectbar { display: flex; align-items: center; background: var(--input-bg); border: 1px solid #263242;
border-radius: 9px; padding: 4px 4px 4px 12px; gap: 8px; flex: 1; max-width: 560px; transition: .15s; }
.connectbar:focus-within { border-color: var(--em); }
.cb-lbl { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .8px; color: var(--em); flex-shrink: 0; }
.cb-input { background: transparent; border: none; outline: none; color: #fff; flex: 1; min-width: 120px;
font-family: 'Cascadia Mono', Consolas, monospace; font-size: 13px; }
.cb-input::placeholder { color: #556274; font-family: 'Segoe UI', sans-serif; }
.cb-go { background: var(--em); color: #08110c; font-weight: 700; font-size: 12px; padding: 7px 13px;
border-radius: 7px; border: none; cursor: pointer; display: flex; align-items: center; gap: 5px; transition: .15s; flex-shrink: 0; }
.cb-go:hover { background: #22db8d; }
.portable-pill { background: #8c949e; color: #11161d; font-size: 12px; font-weight: 700; padding: 4px 12px;
border-radius: 999px; border: none; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
.portable-note { font-size: 12px; color: #6d7888; margin-left: 8px; }
/* cards */
.grid2 { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: 14px; }
.col { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
.card { background: linear-gradient(180deg, rgba(27,35,46,.95), rgba(22,29,38,.95));
border: 1px solid var(--card-border); border-radius: 10px; padding: 16px;
box-shadow: 0 6px 20px rgba(0,0,0,.22); transition: border-color .2s; }
.card:hover { border-color: #313f52; }
.card h2 { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); }
.card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.tag-sm { font-size: 10.5px; font-weight: 600; padding: 2px 8px; border-radius: 5px; }
.tag-em { background: rgba(25,200,126,.1); color: var(--em); border: 1px solid rgba(25,200,126,.3); }
/* id card */
.id-big { font-family: 'Cascadia Mono', Consolas, monospace; font-size: 34px; font-weight: 800;
color: #fff; letter-spacing: .5px; line-height: 1.15; user-select: text; }
.btn-copy { background: #232b38; border: 1px solid #384558; color: var(--fg); border-radius: 9px;
padding: 9px 14px; font-size: 13px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px;
cursor: pointer; transition: .15s; }
.btn-copy:hover { background: #2c3646; border-color: #4b5b70; }
.btn-copy:active { transform: translateY(1px); }
.fmt-btn { font-size: 10.5px; color: var(--faint); background: #11161d; border: 1px solid var(--card-border);
border-radius: 5px; padding: 2px 7px; cursor: pointer; transition: .12s; }
.fmt-btn:hover { color: #cbd5e1; }
.status-pill { display: inline-flex; align-items: center; gap: 8px; padding: 4px 13px; border-radius: 999px;
font-size: 12.5px; font-weight: 600; transition: .2s; }
.status-pill .sdot { width: 8px; height: 8px; border-radius: 50%; }
.sp-stopped { background: var(--crimson-bg); border: 1px solid var(--crimson-bd); color: var(--crimson-fg); }
.sp-stopped .sdot { background: var(--crimson-fg); }
.sp-running { background: rgba(25,200,126,.12); border: 1px solid rgba(25,200,126,.45); color: var(--em-br); }
.sp-running .sdot { background: var(--em-br); animation: pulse 1.8s infinite; }
.sp-conn { background: #33301c; border: 1px solid #7c6535; color: #d9b96a; }
.sp-conn .sdot { background: #d9b96a; }
@keyframes pulse { 0%,100%{box-shadow:0 0 0 0 rgba(37,219,142,.6)} 50%{box-shadow:0 0 0 6px rgba(37,219,142,0)} }
.e2e-tag { display: inline-flex; align-items: center; gap: 5px; background: #11161d; border: 1px solid #242e3d;
padding: 3px 9px; border-radius: 6px; font-size: 11.5px; color: #94a3b8; }
/* inputs */
.nd-input { width: 100%; height: 38px; background: var(--input-bg); border: 1px solid var(--input-border);
border-radius: 7px; padding: 0 12px; font-size: 13.5px; color: #f0f6fc; outline: none; transition: .16s; }
.nd-input::placeholder { color: #556274; }
.nd-input:focus { border-color: var(--em); box-shadow: 0 0 0 3px rgba(25,200,126,.15); }
.field { margin-bottom: 12px; }
.field label { display: block; font-size: 13px; color: #9aa6b5; margin-bottom: 6px; }
.field .opt { color: #637081; }
.icon-btn { width: 38px; height: 38px; border-radius: 7px; background: #262f3d; border: 1px solid #384556;
color: #d0d8e2; display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
flex-shrink: 0; transition: .15s; }
.icon-btn:hover { background: #303b4c; border-color: #4b5b70; color: #fff; }
.icon-btn:active { transform: translateY(1px); }
.link-btn { background: none; border: none; color: var(--blue); font-size: 13.5px; font-weight: 600;
cursor: pointer; padding: 4px; flex-shrink: 0; }
.link-btn:hover { color: #66b3ff; text-decoration: underline; }
.badge-2fa { background: var(--crimson-bg); border: 1px solid var(--crimson-bd); color: var(--crimson-fg);
border-radius: 6px; padding: 8px 10px; font-size: 12.5px; font-weight: 600; cursor: pointer; flex-shrink: 0; transition: .15s; }
.badge-2fa.on { background: rgba(25,200,126,.12); border-color: rgba(25,200,126,.45); color: var(--em-br); }
.helper { font-size: 12.5px; color: #738091; line-height: 1.5; margin-top: 12px; }
.card-foot { margin-top: 14px; padding-top: 12px; border-top: 1px solid #222b38; display: flex;
align-items: center; justify-content: space-between; gap: 8px; font-size: 11.5px; color: #8b96a6; flex-wrap: wrap; }
.gr-link { color: var(--em); background: none; border: none; cursor: pointer; font-weight: 500; font-size: 11.5px; }
.gr-link:hover { text-decoration: underline; }
/* account */
.acct-line { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.acct-ok { font-size: 13.5px; color: var(--em); font-weight: 500; }
.fleet { margin-top: 14px; padding-top: 12px; border-top: 1px solid #222b38; }
.fleet-head { display: flex; align-items: center; justify-content: space-between; font-size: 11px;
color: #8b96a6; margin-bottom: 8px; }
.fleet-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.fleet-card { background: #11161d; border: 1px solid #253040; border-radius: 7px; padding: 7px 10px;
display: flex; align-items: center; justify-content: space-between; gap: 6px; min-width: 0; transition: .15s; }
.fleet-card.click { cursor: pointer; } .fleet-card.click:hover { border-color: rgba(25,200,126,.5); }
.fleet-name { font-size: 11.5px; font-weight: 600; color: #d0d8e2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fleet-sub { font-size: 10px; color: var(--faint); }
.fleet-sub.on { color: var(--em); }
.fdot { width: 8px; height: 8px; border-radius: 50%; background: #64748b; flex-shrink: 0; }
.fdot.on { background: var(--em); }
/* start/stop */
.startrow { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 14px; }
.btn-start { height: 44px; border-radius: 8px; background: linear-gradient(180deg,#22d084,#16b56f);
border: 1px solid #35e699; box-shadow: 0 0 22px rgba(25,200,126,.35), inset 0 1px 0 rgba(255,255,255,.22);
color: #fff; font-size: 14.5px; font-weight: 700; display: flex; align-items: center; justify-content: center;
gap: 9px; cursor: pointer; transition: .18s; font-family: inherit; }
.btn-start:hover { filter: brightness(1.06); }
.btn-start:active { transform: translateY(1px); }
.btn-stop { height: 44px; border-radius: 8px; background: linear-gradient(180deg,#555f6d,#48515e);
border: 1px solid #647080; color: #c7d0dc; font-size: 14px; font-weight: 600; display: flex;
align-items: center; justify-content: center; gap: 8px; cursor: pointer; transition: .18s; font-family: inherit; }
.btn-stop:hover { background: linear-gradient(180deg,#606b7b,#515b69); color: #fff; }
.stop-sq { width: 12px; height: 12px; border-radius: 2px; background: currentColor; }
/* toggles */
.togglecard { margin-top: 14px; padding: 14px 16px; }
.toggles { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; text-align: center; }
.tg { display: flex; flex-direction: column; align-items: center; }
.sw { width: 48px; height: 25px; border-radius: 999px; position: relative; cursor: pointer;
transition: .22s cubic-bezier(.4,0,.2,1); padding: 0; outline: none; margin-bottom: 8px; flex-shrink: 0; }
.sw.on { background: linear-gradient(180deg,#24d286,#18b872); border: 1px solid #39e59a;
box-shadow: 0 0 12px rgba(25,200,126,.38); }
.sw.off { background: #323b48; border: 1px solid #475364; box-shadow: inset 0 1px 3px rgba(0,0,0,.45); }
.sw-thumb { width: 19px; height: 19px; border-radius: 50%; position: absolute; top: 2px; left: 2px;
transition: transform .22s cubic-bezier(.4,0,.2,1), background .2s; }
.sw.on .sw-thumb { transform: translateX(23px); background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.4); }
.sw.off .sw-thumb { background: #8b96a6; }
.tg-lb { font-size: 12.5px; color: #9aa6b5; line-height: 1.3; }
/* footer buttons */
.footrow { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 14px; }
.btn-foot { height: 40px; background: #161d26; border: 1px solid #1f7a53; border-radius: 8px;
box-shadow: 0 0 10px rgba(25,200,126,.12); color: #d9e2ec; font-size: 13.5px; font-weight: 500;
display: flex; align-items: center; justify-content: center; gap: 8px; cursor: pointer; transition: .16s; font-family: inherit; }
.btn-foot:hover { background: #1c2632; border-color: #27be7c; color: #fff; }
.btn-foot svg { stroke: var(--em); }
/* views */
.view { display: none; } .view.active { display: block; }
.list-row { display: flex; align-items: center; justify-content: space-between; gap: 10px;
background: #11161d; border: 1px solid #253040; border-radius: 8px; padding: 11px 14px; margin-bottom: 8px; }
.logbox { background: #0a0e13; border: 1px solid #1e2734; border-radius: 8px; padding: 12px;
font-family: 'Cascadia Mono', Consolas, monospace; font-size: 11.5px; color: #9fb0c4; line-height: 1.7;
max-height: 480px; overflow-y: auto; white-space: pre-wrap; user-select: text; }
/* modal + toast */
.modal-bg { position: fixed; inset: 38px 0 0 0; background: rgba(7,10,15,.8); backdrop-filter: blur(5px);
z-index: 40; display: none; align-items: center; justify-content: center; padding: 20px; }
.modal-bg.open { display: flex; }
.modal { width: 100%; max-width: 500px; background: #161d27; border: 1px solid #2a3648; border-radius: 12px;
box-shadow: 0 24px 70px rgba(0,0,0,.7); overflow: hidden; }
.modal-h { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px;
background: #10151d; border-bottom: 1px solid #232d3c; }
.modal-h h3 { font-size: 14px; font-weight: 700; color: var(--em); }
.modal-x { background: none; border: none; color: #8b96a6; cursor: pointer; font-size: 14px; padding: 2px 8px; }
.modal-x:hover { color: #fff; }
.modal-b { padding: 16px; font-size: 13px; color: #cbd5e1; line-height: 1.6; max-height: 440px; overflow-y: auto; }
.modal-f { padding: 12px 16px; background: #10151d; border-top: 1px solid #232d3c; display: flex; justify-content: flex-end; gap: 8px; }
.modal-ok { padding: 8px 18px; border-radius: 7px; background: var(--em); color: #07120c; font-weight: 700;
font-size: 12.5px; border: none; cursor: pointer; }
.modal-ok:hover { background: #22db8d; }
.mono-box { font-family: Consolas, monospace; background: var(--input-bg); color: var(--em);
padding: 2px 8px; border-radius: 5px; user-select: text; word-break: break-all; }
#toasts { position: fixed; bottom: 16px; right: 16px; z-index: 50; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
.toast { background: rgba(19,25,34,.96); border: 1px solid #2c3849; border-left: 3px solid var(--em);
border-radius: 9px; padding: 10px 14px; box-shadow: 0 10px 30px rgba(0,0,0,.5); font-size: 12px;
transition: .25s; min-width: 220px; }
.toast b { color: #fff; display: block; }
.toast span { color: #94a3b8; font-size: 11px; }
::-webkit-scrollbar { width: 7px; height: 7px; }
::-webkit-scrollbar-track { background: #0d1117; }
::-webkit-scrollbar-thumb { background: #283342; border-radius: 3px; }
/* fitZoom keeps the effective layout ≥1080px, and CSS zoom does NOT affect
media queries - so collapse only at truly tiny raw window widths */
@media (max-width: 660px) { .grid2 { grid-template-columns: 1fr; } .toggles { grid-template-columns: repeat(3,1fr); row-gap: 16px; } .fleet-grid { grid-template-columns: 1fr 1fr; } }
/* ---- fleet toolbar / chips / allowlist (1.5) ---- */
.main { overflow-x: hidden; }
.dev-toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
.dev-search { flex: 1; min-width: 200px; height: 34px; background: #0d131b; border: 1px solid #263241;
border-radius: 8px; color: #e2e8f0; font-size: 12.5px; padding: 0 12px; outline: none; }
.dev-search:focus { border-color: #2f6f4f; }
.chips { display: inline-flex; gap: 6px; }
.chip { height: 28px; padding: 0 12px; border-radius: 999px; border: 1px solid #263241;
background: #0d131b; color: #94a3b8; font-size: 11.5px; cursor: pointer; }
.chip.active { background: rgba(34,197,94,.12); border-color: #2f6f4f; color: #4ade80; }
.wake-btn { height: 28px; padding: 0 12px; border-radius: 7px; border: 1px solid #3b4a5e;
background: #17202b; color: #cbd5e1; font-size: 11.5px; cursor: pointer; }
.wake-btn:hover { border-color: #4ade80; color: #4ade80; }
#qrWrap { display: flex; }
</style>
</head>
<body>
<div class="shell">
<!-- titlebar -->
<header class="tbar">
<div class="tbar-l pywebview-drag-region" style="flex:1;height:100%;align-self:stretch">
<span class="tbar-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="tbar-title">NetDesk</span>
<span class="tbar-sub">— Enterprise Remote Desktop &amp; Unattended Access</span>
</div>
<div class="telemetry pywebview-drag-region">
<span style="display:flex;align-items:center;gap:6px"><span class="tdot" id="relayDot"></span>Relay: <b class="mono" id="relayHost">…</b></span>
<span class="sep">|</span><span class="mono" id="pingMs">— ms</span>
<span class="sep">|</span><span>TLS 1.3 • AES-256-GCM</span>
</div>
<div class="winbtns">
<button class="wbtn" id="winMin" title="Minimize"><svg width="11" height="11" viewBox="0 0 11 11"><rect x="1" y="5" width="9" height="1" fill="currentColor"/></svg></button>
<button class="wbtn" id="winMax" title="Maximize"><svg width="10" height="10" viewBox="0 0 10 10" fill="none"><rect x="1" y="1" width="8" height="8" stroke="currentColor"/></svg></button>
<button class="wbtn close" id="winClose" title="Close"><svg width="11" height="11" viewBox="0 0 11 11" fill="none"><path d="M1.5 1.5L9.5 9.5M9.5 1.5L1.5 9.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg></button>
</div>
</header>
<div class="body">
<!-- sidebar -->
<aside class="side">
<div>
<div class="brand">
<div class="brand-logo"><svg width="23" height="23" 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></div>
<div><span class="brand-name">NetDesk</span> <span class="brand-ver">v1.5.0</span>
<span class="brand-tag">Zero-Trust Remote Agent</span></div>
</div>
<div class="navsec">Workspace</div>
<button class="nav-item active" data-view="dash">
<span class="nl"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#19c87e" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg><span>Remote Control</span></span>
<span class="nav-badge nb-em">Host</span></button>
<button class="nav-item" data-view="devs">
<span class="nl"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#60a5fa" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/></svg><span>My Computers</span></span>
<span class="nav-badge nb-grey mono" id="navDevCount">0</span></button>
<button class="nav-item" data-view="sec">
<span class="nl"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg><span>Security &amp; 2FA</span></span>
<span class="nav-badge nb-red" id="nav2fa">2FA Off</span></button>
<button class="nav-item" data-view="logs">
<span class="nl"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#a78bfa" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg><span>Session Logs</span></span>
<span class="nav-badge nb-grey mono" id="navLogN">·</span></button>
</div>
<div>
<div class="side-info">
<div class="side-row"><span>Local Endpoint</span><span class="mono" style="color:var(--em)" id="sideHost">…</span></div>
<div class="side-row"><span>Daemon Status</span><span id="sideStatus" style="color:var(--crimson-fg);font-weight:600">● Stopped</span></div>
</div>
<div class="acct-chip" id="sideAcct" style="display:none">
<div class="acct-ava" id="sideAva">–</div>
<div style="min-width:0"><div class="acct-mail" id="sideMail"></div><div class="acct-sub">✓ Cloud Synced</div></div>
</div>
</div>
</aside>
<!-- main -->
<main class="main">
<div class="toprow">
<div class="connectbar">
<span class="cb-lbl">Connect to Partner:</span>
<input class="cb-input" id="partnerId" placeholder="Enter remote Partner ID (e.g. 842 190 441)" maxlength="12">
<button class="cb-go" id="partnerGo">Connect
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></button>
</div>
<div style="display:flex;align-items:center;flex-shrink:0">
<button class="portable-pill" id="portableBtn"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg> Portable</button>
<span class="portable-note">settings next to the app</span>
</div>
</div>
<!-- DASHBOARD VIEW -->
<div class="view active" id="view-dash">
<div class="grid2">
<div class="col">
<!-- ID card -->
<section class="card">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px">
<div>
<div style="display:flex;align-items:center;gap:8px"><h2>This Computer's ID</h2>
<button class="fmt-btn" id="fmtBtn" title="Toggle spaced format">Format</button></div>
<div class="id-big" id="bigId">…</div>
</div>
<button class="btn-copy" id="copyBtn"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><span id="copyLbl">Copy ID</span></button>
</div>
<div style="margin-top:12px;display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap">
<span class="status-pill sp-stopped" id="statusPill"><span class="sdot"></span><span id="statusTxt">Stopped</span></span>
<span class="e2e-tag"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#19c87e" stroke-width="2.2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> E2EE Ready</span>
</div>
</section>
<!-- Password card -->
<section class="card" style="flex:1">
<div class="card-head"><h2>Unattended Password</h2>
<span class="tag-sm tag-em" id="pwBadge">PBKDF2 · 200k</span></div>
<div style="display:flex;align-items:center;gap:8px">
<input type="password" id="pwInput" class="nd-input mono" style="letter-spacing:1px" placeholder="no password yet — generate one">
<button class="icon-btn" id="pwEye" title="Show / hide"><svg width="16" height="16" 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>
<button class="icon-btn" id="pwGen" title="Generate a strong random password"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg></button>
<button class="link-btn" id="pwSet">Set</button>
<button class="badge-2fa" id="twoFa">2FA: off</button>
</div>
<p class="helper">Anyone who knows this ID and password can control this PC. Set a strong
password; turn on 2FA for an extra authenticator code at connect.</p>
<div class="card-foot">
<span style="display:flex;align-items:center;gap:6px"><span style="width:6px;height:6px;border-radius:50%;background:var(--em)"></span>Brute-force throttle: <b style="color:#cbd5e1">0.4s / attempt</b></span>
<button class="gr-link" id="go2fa">Configure Authenticator App →</button>
</div>
</section>
</div>
<div class="col">
<!-- Connection card -->
<section class="card">
<div class="card-head"><h2>Connection</h2>
<button class="gr-link" id="pingBtn" style="background:rgba(25,200,126,.1);border:1px solid rgba(25,200,126,.3);border-radius:5px;padding:3px 9px">Ping Relay</button></div>
<div class="field"><label for="srvIn">Server Address</label>
<input id="srvIn" class="nd-input mono" style="font-size:13px" spellcheck="false"></div>
<div class="field"><label for="regIn">Registration Key <span class="opt">(optional)</span></label>
<input id="regIn" class="nd-input mono" style="font-size:13px" placeholder="Leave blank for open relay" spellcheck="false"></div>
<div class="field" style="margin-bottom:0"><label for="nameIn">Computer Name</label>
<input id="nameIn" class="nd-input" spellcheck="false"></div>
</section>
<!-- Account card -->
<section class="card" style="flex:1">
<div class="card-head"><h2>NetDesk Account</h2>
<span class="tag-sm tag-em" id="acctBadge" style="display:none">Fleet Synced</span></div>
<div id="acctOut">
<div class="field"><input id="accEmail" class="nd-input" type="email" placeholder="email" autocomplete="username"></div>
<div class="field"><input id="accPass" class="nd-input" type="password" placeholder="password" autocomplete="current-password"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<button class="btn-start" style="height:38px;font-size:13px" id="accIn">Sign In</button>
<button class="btn-stop" style="height:38px;font-size:13px" id="accUp">Create Account</button>
</div>
</div>
<div id="acctIn" style="display:none">
<div class="acct-line">
<span class="acct-ok">✓ Signed in as <b id="acctMail"></b></span>
<button class="link-btn" id="accOut2">Sign out</button>
</div>
<p class="helper" style="margin-top:8px">This PC shows under “My computers” on the website.</p>
<div class="fleet">
<div class="fleet-head"><span>LINKED DEVICES IN “MY COMPUTERS”</span>
<button class="link-btn" style="font-size:11px" id="fleetAll">View all</button></div>
<div class="fleet-grid" id="fleetGrid"></div>
</div>
</div>
</section>
</div>
</div>
<div class="startrow">
<button class="btn-start" id="startBtn"><svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M12 3L3 12.6H21L12 3Z" fill="#fff"/><path d="M8.6 14.4H15.4L16.5 20.5H7.5L8.6 14.4Z" fill="#fff"/></svg><span id="startTxt">Start Service</span></button>
<button class="btn-stop" id="stopBtn"><span class="stop-sq"></span><span>Stop</span></button>
</div>
<section class="card togglecard">
<div class="toggles">
<div class="tg"><button class="sw off" id="swSvc" role="switch"><span class="sw-thumb"></span></button><span class="tg-lb">Service<br>Installed</span></div>
<div class="tg"><button class="sw off" id="swWin" role="switch"><span class="sw-thumb"></span></button><span class="tg-lb">Start with<br>Windows</span></div>
<div class="tg"><button class="sw off" id="swPriv" role="switch"><span class="sw-thumb"></span></button><span class="tg-lb">Privacy<br>Mode</span></div>
<div class="tg"><button class="sw off" id="swLock" role="switch"><span class="sw-thumb"></span></button><span class="tg-lb">Lock on<br>disconnect</span></div>
<div class="tg"><button class="sw on" id="swTray" role="switch"><span class="sw-thumb"></span></button><span class="tg-lb">Close to<br>tray</span></div>
</div>
</section>
<div class="footrow">
<button class="btn-foot" id="webBtn"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" 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"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>Open Web Viewer</button>
<button class="btn-foot" id="logsBtn"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke-width="1.9" 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>Logs Folder</button>
</div>
</div>
<!-- MY COMPUTERS VIEW -->
<div class="view" id="view-devs">
<section class="card">
<div class="card-head"><h2>My Computers</h2>
<span style="display:flex;gap:10px;align-items:center">
<button class="gr-link" id="devRefresh">↻ Refresh</button>
<button class="cb-go" id="enrollBtn" style="height:30px">+ Enroll Computer</button>
</span></div>
<div class="dev-toolbar">
<input class="dev-search" id="devSearch" type="text" placeholder="Search by name or ID…" spellcheck="false">
<span class="chips">
<button class="chip active" data-f="all">All</button>
<button class="chip" data-f="online">Online</button>
<button class="chip" data-f="offline">Offline</button>
</span>
</div>
<div id="devList"><p class="helper">Sign in to your NetDesk account to see the computers linked to it.</p></div>
</section>
</div>
<!-- SECURITY VIEW -->
<div class="view" id="view-sec">
<section class="card" style="margin-bottom:14px">
<div class="card-head"><h2>Two-Factor Authentication (TOTP)</h2><span class="badge-2fa" id="secTfaState">2FA: off</span></div>
<p class="helper" style="margin-top:0">Adds a 6-digit authenticator code (Google Authenticator, Authy, 1Password…)
required at every connect — even with the correct password.</p>
<div style="margin-top:12px;display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap">
<button class="btn-start" style="height:38px;font-size:13px;max-width:280px" id="secTfaBtn">Enable 2FA</button>
<div id="qrWrap" style="display:none;align-items:center;gap:14px">
<img id="qrImg" alt="TOTP QR" width="132" height="132"
style="border-radius:10px;background:#fff;padding:4px">
<div style="min-width:0">
<div class="lbl" style="margin-bottom:4px">Scan with your authenticator app</div>
<div class="mono" id="qrSecret" style="font-size:11px;color:var(--faint);word-break:break-all;max-width:320px"></div>
</div>
</div>
</div>
</section>
<section class="card" style="margin-bottom:14px">
<div class="card-head"><h2>Viewer IP Allowlist</h2><span class="tag-sm tag-em" id="allowState">off — everyone with the password</span></div>
<p class="helper" style="margin-top:0">Only these IPs / CIDR ranges may connect (e.g. <span class="mono">203.0.113.7, 10.0.0.0/8</span>).
Leave empty to allow any IP that knows the password. Applies to the next connection.</p>
<div style="display:flex;gap:10px;margin-top:10px;flex-wrap:wrap">
<input class="dev-search mono" id="allowIn" style="flex:1;min-width:260px" type="text"
placeholder="empty — all IPs allowed" spellcheck="false">
<button class="cb-go" id="allowSave" style="height:38px">Save Allowlist</button>
</div>
<p class="helper" style="margin-top:10px"><button class="gr-link" id="reqPreview">Preview the incoming-connection dialog →</button></p>
</section>
<section class="card">
<div class="card-head"><h2>Boot-Time Service</h2><span class="tag-sm tag-em" id="secSvcState">not installed</span></div>
<p class="helper" style="margin-top:0">Installs a SYSTEM service so this PC is reachable at the logon screen,
across reboots and UAC prompts — no one needs to be logged in. Requires one UAC approval.</p>
<div style="margin-top:12px;display:flex;gap:10px;flex-wrap:wrap">
<button class="btn-start" style="height:38px;font-size:13px;min-width:200px" id="secSvcBtn">Install Service</button>
<button class="btn-stop" style="height:38px;font-size:13px;min-width:160px" id="secSvcRm">Remove Service</button>
</div>
</section>
</div>
<!-- LOGS VIEW -->
<div class="view" id="view-logs">
<section class="card">
<div class="card-head"><h2>Session Audit Log</h2>
<span style="display:flex;gap:12px;align-items:center">
<span class="chips">
<button class="chip lchip active" data-lf="all">All</button>
<button class="chip lchip" data-lf="session">Sessions</button>
<button class="chip lchip" data-lf="error">Errors</button>
</span>
<button class="gr-link" id="logRefresh">↻ Refresh</button>
<button class="gr-link" id="logExport">⇩ Export</button>
<button class="gr-link" id="logOpen">Open folder</button></span></div>
<div class="logbox" id="logBox">Loading…</div>
</section>
</div>
</main>
</div>
<!-- modal -->
<div class="modal-bg" id="modalBg">
<div class="modal">
<div class="modal-h"><h3 id="mTitle">NetDesk</h3><button class="modal-x" id="mX">✕</button></div>
<div class="modal-b" id="mBody"></div>
<div class="modal-f"><button class="modal-ok" id="mOk">Close</button></div>
</div>
</div>
<div id="toasts"></div>
</div>
<script>
(function () {
"use strict";
window.__errs = [];
window.onerror = function (m, s, l, c) { window.__errs.push(m + " @" + l + ":" + c);
try { window.pywebview.api.log_err(m + " @" + l + ":" + c); } catch (e) {} };
function api() { return window.pywebview && window.pywebview.api; }
function call(m) {
var args = [].slice.call(arguments, 1);
var run = function () { var a = api(); if (!a) throw new Error("bridge"); return a[m].apply(a, args); };
return Promise.resolve().then(run).catch(function (e) {
return new Promise(function (r) { setTimeout(r, 700); }).then(function () { return run(); });
}).catch(function (e) { window.__errs.push(m + ": " + e); throw e; });
}
var $ = function (id) { return document.getElementById(id); };
/* ---------- modal + toasts ---------- */
function openModal(title, html, okLabel, onOk) {
$("mTitle").textContent = title; $("mBody").innerHTML = html;
$("mOk").textContent = okLabel || "Close";
$("mOk").onclick = function () { if (onOk && onOk() === false) return; closeModal(); };
$("modalBg").classList.add("open");
}
function closeModal() { $("modalBg").classList.remove("open"); }
$("mX").onclick = closeModal;
$("modalBg").addEventListener("click", function (e) { if (e.target === this) closeModal(); });
function toast(title, sub) {
var t = document.createElement("div"); t.className = "toast";
var b = document.createElement("b"); b.textContent = title;
var s = document.createElement("span"); s.textContent = sub || "";
t.append(b, s); $("toasts").append(t);
setTimeout(function () { t.style.opacity = "0"; t.style.transform = "translateY(6px)";
setTimeout(function () { t.remove(); }, 250); }, 2600);
}
/* ---------- auto-fit: the layout is designed at ~1080px; zoom the page so
it always fits the window with NO horizontal scroll, at any size ------- */
function fitZoom() {
var z = Math.min(1, window.innerWidth / 1080, window.innerHeight / 850);
z = Math.max(0.72, z);
document.documentElement.style.zoom = z;
// vh units are NOT scaled by CSS zoom - size the shell to the real window
var sh = document.querySelector(".shell");
if (sh) sh.style.height = Math.round(window.innerHeight / z) + "px";
window.scrollTo(0, 0);
}
window.addEventListener("resize", fitZoom); fitZoom();
/* ---------- window controls ---------- */
$("winMin").onclick = function () { call("minimize"); };
$("winMax").onclick = function () { call("maximize_window"); };
$("winClose").onclick = function () { call("close_request"); };
/* ---------- sidebar views ---------- */
var navBtns = document.querySelectorAll(".nav-item");
navBtns.forEach(function (b) {
b.onclick = function () {
navBtns.forEach(function (x) { x.classList.remove("active"); });
b.classList.add("active");
document.querySelectorAll(".view").forEach(function (v) { v.classList.remove("active"); });
$("view-" + b.dataset.view).classList.add("active");
if (b.dataset.view === "logs") loadLogs();
if (b.dataset.view === "devs") loadDevs();
};
});
/* ---------- state paint ---------- */
var S = { id: "", spaced: true, busySvc: false, acct: "" };
function fmtId(id) { return S.spaced ? String(id).replace(/(\d{3})(\d{3})(\d{3})/, "$1 $2 $3") : String(id); }
function setSwitch(id, on) { var b = $(id); b.classList.toggle("on", !!on); b.classList.toggle("off", !on);
b.setAttribute("aria-checked", String(!!on)); }
function paint(s) {
S.id = s.id || "";
$("bigId").textContent = fmtId(S.id);
if (document.activeElement !== $("srvIn")) $("srvIn").value = s.server || "";
if (document.activeElement !== $("regIn")) $("regIn").value = s.regKey || "";
if (document.activeElement !== $("nameIn")) $("nameIn").value = s.name || "";
if (document.activeElement !== $("pwInput") && s.password) $("pwInput").value = s.password;
try { $("relayHost").textContent = new URL((s.server || "").replace("wss","https").replace("ws","http")).host || "—"; }
catch (e) { $("relayHost").textContent = "—"; }
$("sideHost").textContent = s.name || "—";
var st = s.state === "online" && s.inSession ? "session" : s.state;
var pill = $("statusPill"), txt = $("statusTxt"), sideS = $("sideStatus");
if (st === "online" || st === "running" || st === "session") {
pill.className = "status-pill sp-running"; txt.textContent = st === "session" ? "In Session" : "Online";
sideS.textContent = "● Online"; sideS.style.color = "var(--em-br)";
$("startTxt").textContent = "Service Running";
} else if (st === "connecting") {
pill.className = "status-pill sp-conn"; txt.textContent = "Connecting…";
sideS.textContent = "● Connecting"; sideS.style.color = "#d9b96a";
$("startTxt").textContent = "Start Service";
} else {
pill.className = "status-pill sp-stopped"; txt.textContent = "Stopped";
sideS.textContent = "● Stopped"; sideS.style.color = "var(--crimson-fg)";
$("startTxt").textContent = "Start Service";
}
setSwitch("swSvc", s.serviceInstalled); setSwitch("swWin", s.startup);
setSwitch("swPriv", s.privacy); setSwitch("swLock", s.lockOnEnd);
setSwitch("swTray", s.closeToTray !== false);
var tfa = s.totp ? "2FA: ON" : "2FA: off";
$("twoFa").textContent = tfa; $("twoFa").classList.toggle("on", !!s.totp);
$("secTfaState").textContent = tfa; $("secTfaState").classList.toggle("on", !!s.totp);
$("secTfaBtn").textContent = s.totp ? "Disable 2FA" : "Enable 2FA";
$("nav2fa").textContent = s.totp ? "2FA On" : "2FA Off";
$("nav2fa").className = "nav-badge " + (s.totp ? "nb-em" : "nb-red");
$("secSvcState").textContent = s.serviceInstalled ? "installed" : "not installed";
// security view: TOTP QR + allowlist
if (s.totp && !S.qrLoaded) {
S.qrLoaded = true; // in-flight guard; cleared on failure so it retries
call("totp_qr").then(function (r) {
if (r.ok) { $("qrImg").src = r.png; $("qrSecret").textContent = r.secret;
$("qrWrap").style.display = "flex"; }
else S.qrLoaded = false;
}).catch(function () { S.qrLoaded = false; });
} else if (!s.totp) { S.qrLoaded = false; $("qrWrap").style.display = "none"; }
if (document.activeElement !== $("allowIn") && s.allowIps !== undefined)
$("allowIn").value = s.allowIps;
var hasAllow = !!(s.allowIps || "").trim();
$("allowState").textContent = hasAllow ? "on — restricted" : "off — everyone with the password";
// account
S.acct = s.account || "";
$("acctOut").style.display = S.acct ? "none" : "block";
$("acctIn").style.display = S.acct ? "block" : "none";
$("acctBadge").style.display = S.acct ? "inline-block" : "none";
if (S.acct) {
$("acctMail").textContent = S.acct;
$("sideAcct").style.display = "flex"; $("sideMail").textContent = S.acct;
$("sideAva").textContent = S.acct.slice(0, 2).toUpperCase();
} else { $("sideAcct").style.display = "none"; }
}
function refresh() { return call("get_state").then(paint).catch(function () {}); }
function poll() { refresh(); setTimeout(poll, 1500); }
/* ---------- ID card ---------- */
$("fmtBtn").onclick = function () { S.spaced = !S.spaced; $("bigId").textContent = fmtId(S.id); };
$("copyBtn").onclick = function () {
call("copy_id").then(function () { $("copyLbl").textContent = "Copied!"; toast("ID copied", S.id);
setTimeout(function () { $("copyLbl").textContent = "Copy ID"; }, 1400); });
};
/* ---------- password ---------- */
$("pwEye").onclick = function () { var i = $("pwInput"); i.type = i.type === "password" ? "text" : "password"; };
$("pwGen").onclick = function () {
openModal("Generate new password",
"Generate a strong random password?<br>The current one stops working for new connections.",
"Generate", function () {
call("generate_password").then(function (r) {
if (r.ok) { $("pwInput").type = "text"; $("pwInput").value = r.password;
openModal("New password", 'Your new unattended password:<br><span class="mono-box" style="font-size:17px;letter-spacing:1px">' +
r.password + "</span><br><br>It applies to the next incoming connection — no restart needed.");
} else openModal("Generate", r.error || "Failed.");
}); return false; });
};
$("pwSet").onclick = function () {
var v = $("pwInput").value;
if (!v) { $("pwInput").focus(); return; }
call("set_password", v).then(function (r) {
if (r.ok) toast("Password saved", "Applies to the next connection");
else openModal("Password", r.error || "Failed.");
});
};
function toggle2fa() {
call("toggle_2fa").then(function (r) {
if (!r.ok) return;
refresh();
if (r.on) openModal("2FA enabled",
'Add this secret to your authenticator app:<br><span class="mono-box">' + r.secret +
"</span><br><br>Also saved to:<br><span class=\"mono-box\">" + r.path + "</span>");
else toast("2FA disabled", "");
});
}
$("twoFa").onclick = toggle2fa; $("go2fa").onclick = toggle2fa; $("secTfaBtn").onclick = toggle2fa;
/* ---------- connection ---------- */
S.pingFails = 0;
function doPing() {
call("ping_relay").then(function (r) {
if (r.ok) S.pingFails = 0; else S.pingFails++;
// only declare offline after 2 consecutive failures (slow-line friendly)
var off = !r.ok && S.pingFails >= 2;
if (r.ok) { $("pingMs").textContent = r.ms + " ms"; $("pingBtn").textContent = "Ping Relay (" + r.ms + " ms)"; }
else if (off) $("pingMs").textContent = "offline";
$("relayDot").style.background = off ? "var(--crimson-fg)" : "var(--em)";
}).catch(function () {});
}
$("pingBtn").onclick = function () { doPing(); toast("Pinging relay…", ""); };
/* ---------- partner connect ---------- */
function goPartner() {
var v = $("partnerId").value;
call("open_partner", v).then(function (r) {
if (r.ok) toast("Opening session…", r.app ? "Remote screen opening in a NetDesk window" : "Opening the viewer in your browser");
else openModal("Connect to Partner", r.error || "Failed.");
});
}
$("partnerGo").onclick = goPartner;
$("partnerId").addEventListener("keydown", function (e) { if (e.key === "Enter") goPartner(); });
/* ---------- account ---------- */
$("accIn").onclick = function () {
call("account_login", $("accEmail").value, $("accPass").value).then(function (r) {
if (r.ok) { $("accPass").value = ""; toast("Signed in", r.email); refresh(); loadDevs(); }
else openModal("Sign in", r.error || "Failed.");
});
};
$("accUp").onclick = function () {
call("account_signup", $("accEmail").value, $("accPass").value).then(function (r) {
if (r.ok) { $("accPass").value = ""; refresh(); loadDevs();
openModal("Account created", "You are signed in. This PC now appears under “My computers”."); }
else openModal("Create account", r.error || "Failed.");
});
};
$("accPass").addEventListener("keydown", function (e) { if (e.key === "Enter") $("accIn").click(); });
$("accOut2").onclick = function () { call("account_logout").then(function () { refresh(); }); };
$("fleetAll").onclick = function () { document.querySelector('[data-view="devs"]').click(); };
/* fleet cache + filters */
S.devs = []; S.devFilter = "all"; S.devQ = "";
function devMatch(d) {
if (S.devFilter === "online" && !d.online) return false;
if (S.devFilter === "offline" && d.online) return false;
var q = S.devQ.toLowerCase();
return !q || (d.name || "").toLowerCase().indexOf(q) >= 0 || String(d.id).indexOf(q) >= 0;
}
function devRow(d, compact) {
var el = document.createElement("div");
el.className = compact ? "fleet-card" + (d.online ? " click" : "") : "list-row";
var left = document.createElement("div"); left.style.minWidth = "0";
var n = document.createElement("div"); n.className = "fleet-name"; n.textContent = d.name || d.id;
var s = document.createElement("div"); s.className = "fleet-sub" + (d.online ? " on" : "");
s.textContent = d.online ? (d.busy ? "In session" : "Online") : "Offline";
left.append(n, s);
el.append(left);
if (compact) {
var dot = document.createElement("span"); dot.className = "fdot" + (d.online ? " on" : ""); el.append(dot);
if (d.online) el.onclick = function () { call("open_partner", d.id); };
} else {
var right = document.createElement("div"); right.style.display = "flex"; right.style.gap = "8px"; right.style.alignItems = "center";
var idm = document.createElement("span"); idm.className = "mono"; idm.style.color = "var(--faint)"; idm.style.fontSize = "12px";
idm.textContent = d.id; right.append(idm);
if (d.online) { var go = document.createElement("button"); go.className = "cb-go"; go.textContent = "Connect";
go.onclick = function () { call("open_partner", d.id); }; right.append(go); }
else if (d.mac) { var wk = document.createElement("button"); wk.className = "wake-btn"; wk.textContent = "⚡ Wake";
wk.title = "Wake-on-LAN via another online computer on your account";
wk.onclick = function () {
wk.disabled = true; wk.textContent = "Waking…";
call("wake_device", d.id).then(function (r) {
wk.disabled = false; wk.textContent = "⚡ Wake";
if (r.ok) toast(r.already_online ? "Already online" : "Wake packet sent",
r.already_online ? "" : "Relayed via " + (r.relayed || 1) + " computer(s) on your LAN");
else openModal("Wake-on-LAN", r.error || "Failed.");
});
}; right.append(wk); }
el.append(right);
}
return el;
}
function renderDevs() {
var full = $("devList"); full.innerHTML = "";
var grid = $("fleetGrid"); grid.innerHTML = "";
var list = S.devs.filter(devMatch);
if (!list.length) {
var q = document.createElement("p"); q.className = "helper";
q.textContent = S.devs.length ? "No computers match this filter." :
"No computers linked yet — this PC appears here once the engine registers.";
full.append(q);
}
list.forEach(function (d) { full.append(devRow(d, false)); });
S.devs.slice(0, 3).forEach(function (d) { grid.append(devRow(d, true)); });
}
function loadDevs() {
call("list_devices").then(function (r) {
$("navDevCount").textContent = String((r.devices || []).length);
if (!r.ok) {
var full = $("devList"); full.innerHTML = "";
var p = document.createElement("p"); p.className = "helper";
p.textContent = r.error === "not signed in" ?
"Sign in to your NetDesk account to see the computers linked to it." :
"Could not load devices: " + (r.error || "");
full.append(p); return;
}
S.devs = r.devices || [];
renderDevs();
}).catch(function () {});
}
$("devRefresh").onclick = loadDevs;
$("devSearch").addEventListener("input", function () { S.devQ = this.value.trim(); renderDevs(); });
document.querySelectorAll(".chip[data-f]").forEach(function (c) {
c.onclick = function () {
document.querySelectorAll(".chip[data-f]").forEach(function (x) { x.classList.remove("active"); });
c.classList.add("active"); S.devFilter = c.dataset.f; renderDevs();
};
});
$("enrollBtn").onclick = function () {
var base = "https://netdesk.space";
try { base = new URL($("srvIn").value.replace("wss", "https").replace("ws", "http")).origin; } catch (e) {}
openModal("Enroll a new computer",
"<b>1.</b> On the new PC, download the agent:<br><span class=\"mono-box\">" + base + "/download</span><br><br>" +
"<b>2.</b> Run <span class=\"mono\">NetDeskHost.exe</span> — it gets its own 9-digit ID automatically.<br><br>" +
"<b>3.</b> Sign in with the same account (<span class=\"mono\">" + (S.acct || "your account") + "</span>) " +
"in its NetDesk Account card — the computer then appears here in My Computers.<br><br>" +
"<b>4.</b> Optional: turn on <i>Service Installed</i> there for unattended access at boot.");
};
/* ---------- start / stop ---------- */
$("startBtn").onclick = function () {
call("start_engine", $("srvIn").value, $("regIn").value, $("nameIn").value).then(function (r) {
if (r.ok) toast("Engine starting…", "Connecting to the relay");
else openModal("Start", r.error || "Failed.");
});
};
$("stopBtn").onclick = function () { call("stop_engine").then(function () { toast("Engine stopped", ""); refresh(); }); };
/* ---------- toggles ---------- */
function installService() {
if (S.busySvc) return; S.busySvc = true;
var done = function () { S.busySvc = false; refresh(); };
if ($("swSvc").classList.contains("on")) {
call("remove_service").then(function (r) { toast("Service removed", ""); }).then(done, done);
return;
}
openModal("Installing service",
"Accept the UAC prompt to continue. The service makes this PC reachable at boot, " +
"at the logon screen and across UAC prompts.");
call("install_service").then(function (r) {
if (r.ok) openModal("Service installed", (r.msg || "Installed.") +
"<br><br>Your PC is now reachable even after a reboot — before anyone logs in.");
else openModal("Install service", r.error || "Install failed.");
}).then(done, done);
}
$("swSvc").onclick = installService; $("secSvcBtn").onclick = installService;
$("secSvcRm").onclick = function () { call("remove_service").then(function () { toast("Service removed", ""); refresh(); }); };
$("swWin").onclick = function () {
var on = $("swWin").classList.contains("on");
call(on ? "remove_startup" : "add_startup").then(function () { refresh(); });
};
$("swPriv").onclick = function () {
var on = !$("swPriv").classList.contains("on");
setSwitch("swPriv", on);
if (on) openModal("Privacy mode", "Privacy mode turns the PHYSICAL monitor off while someone is " +
"connected. Anyone at the PC sees a no-signal screen. Only enable this if that is what you want.");
call("set_switches", { privacy: on });
};
$("swLock").onclick = function () {
var on = !$("swLock").classList.contains("on");
setSwitch("swLock", on); call("set_switches", { lock: on });
};
$("swTray").onclick = function () {
var on = !$("swTray").classList.contains("on");
setSwitch("swTray", on); call("set_close_to_tray", on);
};
/* ---------- footer / misc ---------- */
$("webBtn").onclick = function () { call("open_viewer"); };
$("logsBtn").onclick = function () { call("open_logs"); };
$("logOpen").onclick = function () { call("open_logs"); };
$("portableBtn").onclick = function () {
call("get_state").then(function (s) {
openModal("Portable mode", 'Configuration is stored next to the app:<br><span class="mono-box">' +
(s.configPath || "") + "</span>");
});
};
/* ---------- security extras ---------- */
$("allowSave").onclick = function () {
call("set_allow_ips", $("allowIn").value).then(function (r) {
if (r.ok) { toast("Allowlist saved", r.count ? r.count + " entr" + (r.count > 1 ? "ies" : "y") :
"cleared — all IPs allowed"); refresh(); }
else openModal("IP allowlist", r.error || "Failed.");
});
};
$("allowIn").addEventListener("keydown", function (e) { if (e.key === "Enter") $("allowSave").click(); });
$("reqPreview").onclick = function () {
openModal("Incoming connection request",
'<div style="text-align:center;padding:6px 0">' +
'<div style="font-size:34px;margin-bottom:8px">🖥️</div>' +
'<b style="font-size:15px">203.0.113.7 wants to control this desktop</b><br>' +
'<span style="color:var(--faint);font-size:12px">Password verified · E2E key exchanged · ' +
new Date().toLocaleTimeString() + "</span><br><br>" +
'<span style="color:#94a3b8;font-size:12px">This is a preview of what a viewer sees while connecting. ' +
"Access is only granted after the unattended password (and 2FA, when on) is verified.</span></div>",
"Got it");
};
/* ---------- logs view ---------- */
S.logLines = []; S.logFilter = "all";
function logMatch(l) {
var s = l.toLowerCase();
if (S.logFilter === "session") return s.indexOf("session") >= 0 || s.indexOf("viewer") >= 0 || s.indexOf("connect") >= 0;
if (S.logFilter === "error") return s.indexOf("error") >= 0 || s.indexOf("warn") >= 0 || s.indexOf("denied") >= 0 || s.indexOf("fail") >= 0;
return true;
}
function renderLogs() {
var lines = S.logLines.filter(logMatch);
$("logBox").textContent = lines.join("\n") || "No matching log entries.";
$("logBox").scrollTop = $("logBox").scrollHeight;
}
function loadLogs() {
call("get_logs", 300).then(function (r) {
S.logLines = r.lines || [];
$("navLogN").textContent = String(S.logLines.length);
renderLogs();
}).catch(function () {});
}
$("logRefresh").onclick = loadLogs;
document.querySelectorAll(".lchip").forEach(function (c) {
c.onclick = function () {
document.querySelectorAll(".lchip").forEach(function (x) { x.classList.remove("active"); });
c.classList.add("active"); S.logFilter = c.dataset.lf; renderLogs();
};
});
$("logExport").onclick = function () {
call("export_logs").then(function (r) {
if (r.ok) openModal("Log exported", 'Saved to:<br><span class="mono-box">' + r.path + "</span>");
else openModal("Export", r.error || "Failed.");
});
};
/* ---------- boot ---------- */
var ready = new Promise(function (res) {
if (api()) return res();
window.addEventListener("pywebviewready", res);
setTimeout(res, 4000);
});
ready.then(function () {
refresh().then(function () { poll(); loadDevs(); doPing(); });
setInterval(doPing, 12000);
setInterval(function () { if (S.acct) loadDevs(); }, 20000);
});
})();
</script>
</body>
</html>
+825
View File
@@ -0,0 +1,825 @@
#!/usr/bin/env python3
"""NetDesk desktop GUI - renders the approved HTML design in a native
WebView2 window (pywebview), wired to the real engine backend."""
import base64
import json
import os
import secrets
import string
import subprocess
import sys
import threading
import time
import urllib.request
import urllib.error
import webbrowser
from pathlib import Path
IS_WIN = sys.platform == "win32"
IS_MAC = sys.platform == "darwin"
if IS_WIN:
import ctypes
HERE = Path(__file__).resolve().parent
if getattr(sys, "frozen", False):
HERE = Path(sys.executable).resolve().parent
sys.path.insert(0, HERE)
else:
sys.path.insert(0, str(HERE))
import netdesk_host as nd # engine module
CREATE_NO_WINDOW = 0x08000000 if IS_WIN else 0
NL = chr(10)
def _html_path():
if getattr(sys, "frozen", False):
me = getattr(sys, "_MEIPASS", os.path.dirname(sys.executable))
cand = os.path.join(me, "netdesk_gui.html")
if os.path.exists(cand):
return cand
return os.path.join(os.path.dirname(sys.executable), "netdesk_gui.html")
return str(HERE / "netdesk_gui.html")
def engine_exe():
if getattr(sys, "frozen", False):
return [sys.executable, "--engine"]
return [sys.executable, str(HERE / "netdesk_host.py"), "--engine"]
class Api:
"""Exposed to the HTML UI as window.pywebview.api.*"""
def __init__(self):
self.proc = None
self.cfg = nd.Config(nd.appdata_dir() / "config.json")
self.portable = getattr(sys, "frozen", False) and \
self.cfg.path.parent.resolve() == HERE
_svc_cache = {"v": None, "t": 0.0}
_stu_cache = {"v": None, "t": 0.0}
def _service_installed(self):
# NOTE: `schtasks /Query /TN NetDesk` returns "Access is denied" for a
# NON-elevated process when the task was created elevated - which made
# the toggle grey on every fresh app start. The COM API distinguishes
# not-found (0x80070002) from access-denied (0x80070005 = task EXISTS)
# by HRESULT, which is locale-independent.
if not IS_WIN:
return False
if time.time() - type(self)._svc_cache["t"] < 10:
return bool(type(self)._svc_cache["v"])
ps = ("$s=New-Object -ComObject Schedule.Service;$s.Connect();"
"try{$null=$s.GetFolder('\\').GetTask('NetDesk');Write-Output 1}"
"catch{if($_.Exception.HResult -eq -2147024891){Write-Output 1}"
"else{Write-Output 0}}")
try:
r = subprocess.run(["powershell", "-NoProfile", "-Command", ps],
capture_output=True, text=True, timeout=20,
creationflags=CREATE_NO_WINDOW)
v = (r.stdout or "").strip().endswith("1")
except Exception:
v = bool(type(self)._svc_cache["v"]) # keep last known on error
type(self)._svc_cache = {"v": v, "t": time.time()}
return v
def _startup_present(self):
if not IS_WIN:
return False
if time.time() - type(self)._stu_cache["t"] < 10:
return bool(type(self)._stu_cache["v"])
r = subprocess.run(["reg", "query",
r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run",
"/v", "NetDesk"], capture_output=True,
creationflags=CREATE_NO_WINDOW).returncode
type(self)._stu_cache = {"v": r == 0, "t": time.time()}
return r == 0
def _engine_alive(self):
sf = self.cfg.path.parent / "gui_status.json"
try:
d = json.loads(sf.read_text("utf-8"))
return time.time() - d.get("ts", 0) < 12
except Exception:
return False
def log_err(self, msg):
try:
with open(self.cfg.path.parent / "gui_err.log", "a", encoding="utf-8") as f:
f.write(time.strftime("%H:%M:%S ") + str(msg) + NL)
except Exception:
pass
return {"ok": True}
# ---------------- account (sign in / sign up, binds this PC to the web app)
def _api_base(self):
url = self.cfg.data.get("server") or nd.DEFAULT_SERVER
url = url.replace("wss://", "https://").replace("ws://", "http://")
url = url.rsplit("/ws", 1)[0]
return url.rstrip("/")
def _api_post(self, path, payload):
data = json.dumps(payload).encode()
headers = {"Content-Type": "application/json"}
tok = self.cfg.data.get("account_token", "")
if tok:
headers["Authorization"] = "Bearer " + tok
req = urllib.request.Request(self._api_base() + path, data=data,
headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=20) as r:
return json.loads(r.read().decode() or "{}")
except urllib.error.HTTPError as e:
try:
return json.loads(e.read().decode() or "{}")
except Exception:
return {"error": f"server error {e.code}"}
except Exception as e:
return {"error": f"cannot reach server: {e}"}
def _account_result(self, r):
if r.get("ok") and r.get("token"):
self.cfg.data["account_token"] = r["token"]
self.cfg.data["account_email"] = r.get("email", "")
self.cfg.save() # engine picks it up at the next (re)register via reload()
return {"ok": True, "email": r.get("email", ""), "msg": r.get("msg", "")}
return {"ok": False, "error": r.get("error", "sign-in failed")}
def account_login(self, email, password):
email = (email or "").strip().lower()
if "@" not in email or len(password or "") < 1:
return {"ok": False, "error": "Enter your email and password."}
return self._account_result(self._api_post(
"/api/auth/login", {"email": email, "password": password, "app": True}))
def account_signup(self, email, password):
email = (email or "").strip().lower()
if "@" not in email:
return {"ok": False, "error": "Enter a valid email."}
if len(password or "") < 8:
return {"ok": False, "error": "Password needs 8+ characters."}
return self._account_result(self._api_post(
"/api/auth/signup", {"email": email, "password": password, "app": True}))
def account_logout(self):
tok = self.cfg.data.get("account_token", "")
if tok:
try:
self._api_post("/api/auth/logout", {})
except Exception:
pass
self.cfg.data.pop("account_token", None)
self.cfg.data.pop("account_email", None)
self.cfg.save()
return {"ok": True}
def get_state(self):
try:
return self._get_state_inner()
except Exception as e:
try:
self.log_err("get_state: " + repr(e))
except Exception:
pass
return {"id": self.cfg.id, "state": "error", "error": repr(e),
"portable": self.portable}
_last_online = 0.0
def _get_state_inner(self):
state, in_sess = "stopped", False
sf = self.cfg.path.parent / "gui_status.json"
try:
d = json.loads(sf.read_text("utf-8"))
if time.time() - d.get("ts", 0) < 12:
state, in_sess = d.get("state"), bool(d.get("in_session"))
except Exception:
pass
# Anti-flicker: a stale duplicate engine writes "error: already
# connected elsewhere" between the real engine's online heartbeats.
# That error literally means the ID IS being served by this machine,
# and any brief error/connecting blip within 12s of a confirmed
# online heartbeat is noise, not a real outage.
now = time.time()
cls = type(self)
alive = self._engine_alive() # some local engine heartbeated < 12s ago
# A stale duplicate engine writes "already connected elsewhere" between
# the real engine's online heartbeats. When an engine is genuinely
# heartbeating locally, that means the ID IS served here -> online.
if isinstance(state, str) and "already connected" in state and alive:
state = "online"
if state in ("online", "session"):
cls._last_online = now
elif (state != "stopped" or alive) and now - cls._last_online < 12:
# brief connecting/error blip within 12s of a confirmed online
# (e.g. slow-network reconnect) is noise, not a real outage
state = "online"
# Truly stopped only when OUR spawned engine exited AND no engine at all
# is heartbeating - never override a live/reconnecting engine's status.
if self.proc is not None and self.proc.poll() is not None \
and not alive and now - cls._last_online >= 12:
state = "stopped"
elif state is None:
state = "running" if alive else "stopped"
return {
"id": self.cfg.id,
"server": self.cfg.data.get("server") or nd.DEFAULT_SERVER,
"regKey": self.cfg.data.get("reg_key", ""),
"name": self.cfg.data.get("name", ""),
"password": self.cfg.data.get("pw_plain", ""),
"account": self.cfg.data.get("account_email", ""),
"state": state,
"inSession": in_sess,
"totp": bool(self.cfg.data.get("totp")),
"allowIps": self.cfg.data.get("allow_ips", ""),
"portable": self.portable,
"configPath": str(self.cfg.path),
"serviceInstalled": self._service_installed(),
"startup": self._startup_present(),
"privacy": bool(self.cfg.data.get("privacy_on_session")
and self.cfg.data.get("allow_display_power")),
"lockOnEnd": bool(self.cfg.data.get("lock_on_end")),
"closeToTray": bool(self.cfg.data.get("close_to_tray", True)),
"inTray": self._tray_running(),
"isMac": IS_MAC,
}
def copy_id(self):
if IS_WIN:
subprocess.run(["powershell", "-Command",
f"Set-Clipboard -Value '{self.cfg.id}'"],
capture_output=True, creationflags=CREATE_NO_WINDOW)
return {"ok": True}
def start_engine(self, server, regkey, name):
if self._engine_alive():
return {"ok": False, "error": "Engine is already running."}
server = nd.normalize_server(server) or (self.cfg.data.get("server") or nd.DEFAULT_SERVER)
self.cfg.data["server"] = server
self.cfg.data["server_locked_by_user"] = True
if regkey.strip():
self.cfg.data["reg_key"] = regkey.strip()
if name.strip():
self.cfg.data["name"] = name.strip()
self.cfg.save()
cmd = engine_exe() + ["--server", server]
regkey = regkey.strip() or self.cfg.data.get("reg_key", "")
if regkey:
cmd += ["--reg-key", regkey]
if name.strip():
cmd += ["--name", name.strip()]
flags = CREATE_NO_WINDOW if IS_WIN else 0
self.proc = subprocess.Popen(cmd, creationflags=flags,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return {"ok": True}
def ensure_engine(self):
"""Auto-bring the host ONLINE on app launch (TeamViewer-style) unless
the user chose to stay offline. Safe: a fresh config always carries a
random password, so 'online' never means 'open to anyone'. No-op when
an engine (user or SYSTEM broker) is already serving this ID."""
try:
if self.cfg.data.get("start_offline"):
return {"ok": False, "offline": True}
if self._engine_alive():
return {"ok": True, "already": True}
server = self.cfg.data.get("server") or nd.DEFAULT_SERVER
cmd = engine_exe() + ["--server", server]
rk = self.cfg.data.get("reg_key", "")
if rk:
cmd += ["--reg-key", rk]
nm = self.cfg.data.get("name", "")
if nm:
cmd += ["--name", nm]
flags = CREATE_NO_WINDOW if IS_WIN else 0
self.proc = subprocess.Popen(cmd, creationflags=flags,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
return {"ok": True, "started": True}
except Exception as e:
return {"ok": False, "error": str(e)}
def set_start_offline(self, on):
"""User preference: skip auto-going-online when the app launches."""
self.cfg.data["start_offline"] = bool(on)
self.cfg.save()
return {"ok": True}
def stop_engine(self):
if self.proc is not None and self.proc.poll() is None:
self.proc.terminate()
self.proc = None
return {"ok": True}
if IS_WIN and getattr(sys, "frozen", False):
subprocess.run(["powershell", "-Command",
"Get-CimInstance Win32_Process -Filter \"Name='NetDeskHost.exe'\" | "
"Where-Object { $_.CommandLine -like '*--engine*' } | "
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"],
capture_output=True, creationflags=CREATE_NO_WINDOW)
return {"ok": True}
return {"ok": False, "error": "not running"}
def set_password(self, pw):
pw = (pw or "").strip()
if len(pw) < 8:
return {"ok": False, "error": "Use at least 8 characters."}
self.cfg.set_password(pw)
return {"ok": True, "msg": "Password updated - it applies to the next connection."}
def generate_password(self):
"""One-click random password, TeamViewer/RustDesk style. Saved and
shown; the engine picks it up at the next incoming connection."""
pw = nd.gen_password(10)
self.cfg.set_password(pw)
return {"ok": True, "password": pw}
def toggle_2fa(self):
if self.cfg.data.get("totp"):
self.cfg.data.pop("totp", None)
self.cfg.save()
return {"ok": True, "on": False}
secret = "".join(secrets.choice(string.ascii_uppercase + "234567") for _ in range(32))
self.cfg.set_totp(secret)
f = self.cfg.path.parent / "totp_secret.txt"
f.write_text("NetDesk 2FA secret (base32)\n\n" + secret + "\n", "utf-8")
return {"ok": True, "on": True, "secret": secret, "path": str(f)}
def totp_qr(self):
"""QR code (base64 PNG) for the current TOTP secret - scan it with
Google Authenticator / Authy / 1Password."""
secret = self.cfg.data.get("totp", "")
if not secret:
return {"ok": False, "error": "2FA is not enabled"}
label = f"NetDesk:{self.cfg.id}"
uri = (f"otpauth://totp/{urllib.parse.quote(label)}?secret={secret}"
f"&issuer=NetDesk&digits=6&period=30")
try:
import io
import qrcode
img = qrcode.make(uri, box_size=5, border=2)
buf = io.BytesIO()
img.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode()
return {"ok": True, "png": "data:image/png;base64," + b64,
"secret": secret}
except Exception as e:
return {"ok": False, "error": str(e)[:120], "secret": secret}
def set_allow_ips(self, text):
"""Save the viewer IP/CIDR allowlist (empty = allow everyone)."""
import ipaddress
parts = str(text or "").replace(",", " ").split()
bad = []
for p in parts:
try:
ipaddress.ip_network(p, strict=False)
except ValueError:
bad.append(p)
if bad:
return {"ok": False, "error": "Not a valid IP/CIDR: " + ", ".join(bad[:3])}
self.cfg.data["allow_ips"] = " ".join(parts)
self.cfg.save()
return {"ok": True, "count": len(parts)}
def wake_device(self, dev_id):
"""Wake-on-LAN an offline fleet computer (relayed via the server)."""
tok = self.cfg.data.get("account_token", "")
if not tok:
return {"ok": False, "error": "not signed in"}
r = self._api_post("/api/wake", {"id": str(dev_id)})
if r.get("ok"):
return {"ok": True, "already_online": bool(r.get("already_online")),
"relayed": r.get("relayed", 0)}
return {"ok": False, "error": r.get("error", "wake failed")}
def export_logs(self):
"""Copy the session audit log to the Desktop for support/compliance."""
try:
src = self.cfg.path.parent / "host.log"
if not src.is_file():
return {"ok": False, "error": "no log file yet"}
dest_dir = Path.home() / "Desktop"
if not dest_dir.is_dir():
dest_dir = Path.home()
dest = dest_dir / time.strftime("netdesk-log-%Y%m%d-%H%M%S.txt")
dest.write_bytes(src.read_bytes())
return {"ok": True, "path": str(dest)}
except Exception as e:
return {"ok": False, "error": str(e)[:120]}
# ---------------- enterprise UI data feeds ----------------
def ping_relay(self):
"""Round-trip time to the configured relay's /health endpoint.
Retries once so a single dropped packet on a slow line never
flashes 'offline'."""
err = ""
for _ in range(2):
try:
t0 = time.time()
with urllib.request.urlopen(self._api_base() + "/health", timeout=10) as r:
r.read()
return {"ok": True, "ms": int((time.time() - t0) * 1000)}
except Exception as e:
err = str(e)[:120]
return {"ok": False, "error": err}
def list_devices(self):
"""This account's computers from the relay (for My Computers)."""
tok = self.cfg.data.get("account_token", "")
if not tok:
return {"ok": False, "error": "not signed in", "devices": []}
req = urllib.request.Request(self._api_base() + "/api/devices",
headers={"Authorization": "Bearer " + tok})
try:
with urllib.request.urlopen(req, timeout=10) as r:
d = json.loads(r.read().decode())
return {"ok": True, "devices": d.get("devices", [])}
except Exception as e:
return {"ok": False, "error": str(e)[:120], "devices": []}
def get_logs(self, n=150):
"""Tail of the local agent log for the Session Logs view."""
try:
p = self.cfg.path.parent / "host.log"
lines = p.read_text("utf-8", errors="replace").splitlines()[-int(n):]
return {"ok": True, "lines": lines}
except Exception as e:
return {"ok": False, "lines": [], "error": str(e)[:120]}
def open_partner(self, pid):
"""Outgoing connection: open the remote-control viewer in its own app
window (WebView2) - app-to-app like RustDesk/TeamViewer, one window per
partner. Falls back to the system browser only if the window can't open."""
digits = "".join(ch for ch in str(pid or "") if ch.isdigit())
if len(digits) != 9:
return {"ok": False, "error": "Enter the partner's 9-digit ID."}
url = self._api_base() + "/app?id=" + digits + "&embed=1"
try:
import webview
import ctypes
sw, sh = 1536, 864
try:
u = ctypes.windll.user32
try:
scale = u.GetDpiForSystem() / 96.0
except Exception:
scale = 1.0
sw = u.GetSystemMetrics(0) / (scale or 1.0)
sh = u.GetSystemMetrics(1) / (scale or 1.0)
except Exception:
pass
w, h = int(sw * 0.8), int(sh * 0.82)
title = "NetDesk – " + " ".join(digits[i:i + 3] for i in range(0, 9, 3))
# the viewer window has the normal OS frame (resizable, maximize)
webview.create_window(title, url, width=w, height=h, min_size=(720, 460),
x=max(0, int((sw - w) / 2)), y=max(0, int((sh - h) / 2)),
background_color="#05070a", text_select=False)
return {"ok": True, "app": True}
except Exception as e:
self.log_err("viewer window: %s" % e)
webbrowser.open(url)
return {"ok": True}
_maximized = False
def maximize_window(self):
"""Toggle maximize/restore (never fullscreen - that traps a frameless
window with no way out)."""
try:
import webview
for w in webview.windows:
try:
if type(self)._maximized:
w.restore()
type(self)._maximized = False
else:
w.maximize()
type(self)._maximized = True
except Exception:
pass
except Exception:
pass
return {"ok": True, "maximized": type(self)._maximized}
def fit_window(self, height):
"""Resize the window height to fit its content (width unchanged)."""
try:
import webview
h = max(560, min(int(height), 1400))
for w in webview.windows:
try:
w.resize(getattr(w, "width", 1024) or 1024, h)
except Exception:
pass
except Exception:
pass
return {"ok": True}
def set_switches(self, startup=None, privacy=None, lock=None):
if privacy is not None:
self.cfg.data["privacy_on_session"] = bool(privacy)
self.cfg.data["allow_display_power"] = bool(privacy)
if lock is not None:
self.cfg.data["lock_on_end"] = bool(lock)
self.cfg.save()
return {"ok": True}
def install_service(self):
if not IS_WIN:
return {"ok": False, "error": "Windows only"}
# hand over: stop the GUI-spawned engine; the service owns the connection
self.stop_engine()
server = self.cfg.data.get("server") or nd.DEFAULT_SERVER
# clear any stale result from a previous attempt so we read a fresh one
result_path = nd.programdata_dir() / "install_result.json"
try:
result_path.unlink()
except Exception:
pass
exe = sys.executable
params = f'--install --server "{server}"'
reg = self.cfg.data.get("reg_key", "")
if reg:
params += f' --reg-key "{reg}"'
if not getattr(sys, "frozen", False):
params = f'"{HERE / "netdesk_host.py"}" ' + params
try:
r = ctypes.windll.shell32.ShellExecuteW(None, "runas", exe, params, None, 1)
if r <= 32:
return {"ok": False, "error": "UAC was cancelled - accept the prompt to install."}
except Exception as e:
return {"ok": False, "error": str(e)}
# Wait up to 60 s: the elevated one-file exe must first self-extract
# (a few seconds) before it can create the task. Trust install_result.json
# (written by the elevated installer) as the definitive answer, and fall
# back to querying the scheduled task directly.
for _ in range(120):
try:
res = json.loads(result_path.read_text("utf-8"))
if res.get("ok"):
# force the status cache so the toggle turns green immediately
type(self)._svc_cache = {"v": True, "t": time.time()}
return {"ok": True,
"msg": "Service installed - reachable at boot, the logon screen "
"and UAC prompts. The engine handed over to the service."}
return {"ok": False, "error": res.get("error", "install failed")}
except Exception:
pass
if self._service_installed():
type(self)._svc_cache = {"v": True, "t": time.time()}
return {"ok": True,
"msg": "Service installed - reachable at boot, the logon screen "
"and UAC prompts."}
time.sleep(0.5)
return {"ok": False,
"error": "Install is taking longer than expected. If you accepted the UAC "
"prompt, the toggle should turn on shortly - reopen the app to check."}
def remove_service_full(self):
r = self.remove_service()
# also stop broker/agent processes from the removed service
if IS_WIN and getattr(sys, "frozen", False):
subprocess.run(["powershell", "-Command",
"Get-CimInstance Win32_Process -Filter \"Name='NetDeskHost.exe'\" | "
"Where-Object { $_.CommandLine -like '*--broker*' -or "
"$_.CommandLine -like '*--engine*' } | "
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"],
capture_output=True, creationflags=CREATE_NO_WINDOW)
return r
def remove_service(self):
if not IS_WIN:
return {"ok": False}
subprocess.run(["schtasks", "/Delete", "/TN", "NetDesk", "/F"],
capture_output=True, creationflags=CREATE_NO_WINDOW)
return {"ok": True, "msg": "Service removed (if it existed)."}
def add_startup(self):
if getattr(sys, "frozen", False):
exe = Path(sys.executable).resolve()
else:
exe = HERE / "NetDeskHost.exe"
val = f'"{exe}" --engine --server "{self.cfg.data.get("server") or "wss://netdesk.space/ws"}"'
subprocess.run(["reg", "add", r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run",
"/v", "NetDesk", "/t", "REG_SZ", "/d", val, "/f"],
capture_output=True, creationflags=CREATE_NO_WINDOW)
return {"ok": True}
def remove_startup(self):
if not IS_WIN:
return {"ok": False}
subprocess.run(["reg", "delete", r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run",
"/v", "NetDesk", "/f"], capture_output=True,
creationflags=CREATE_NO_WINDOW)
return {"ok": True, "msg": "Startup entry removed."}
def open_viewer(self):
url = self.cfg.data.get("server") or "wss://netdesk.space/ws"
url = url.replace("wss://", "https://").replace("ws://", "http://")
url = url.rsplit("/ws", 1)[0] or "http://127.0.0.1:8443"
webbrowser.open(url)
return {"ok": True}
def close_request(self):
"""✕ button: hide to tray by default, or really quit if disabled."""
if self.cfg.data.get("close_to_tray", True):
self.hide_window()
return {"ok": True, "hidden": True}
return {"ok": True, "hidden": False, "quit": True}
def hide_window(self):
try:
import webview
for w in webview.windows:
w.hide()
except Exception:
pass
def show_window(self):
try:
import webview
for w in webview.windows:
w.show()
except Exception:
pass
def minimize(self):
try:
import webview
for w in webview.windows:
w.minimize()
except Exception:
pass
return {"ok": True}
def set_close_to_tray(self, on):
self.cfg.data["close_to_tray"] = bool(on)
self.cfg.save()
return {"ok": True}
def quit_all(self):
try:
self.stop_engine()
except Exception:
pass
try:
import webview
for w in webview.windows:
w.destroy()
except Exception:
pass
os._exit(0)
def _tray_running(self):
return getattr(self, "_tray", None) is not None and self._tray.visible
def open_logs(self):
p = str(self.cfg.path.parent)
if IS_WIN:
os.startfile(p) # noqa
elif IS_MAC:
subprocess.Popen(["open", p])
else:
subprocess.Popen(["xdg-open", p])
return {"ok": True}
def _tray_image():
from PIL import Image as _I
return _I.open(_io.BytesIO(base64.b64decode(ICON_B64))) if ICON_B64 else None
def _start_tray(api):
"""System tray icon with the NetDesk logo and control menu."""
try:
import pystray
img = _tray_image()
if img is None:
return
import webview
def show_w(api_ref, icon_ref, item):
api.show_window()
def quit_all(icon_ref, item):
api.quit_all()
def noop(icon_ref, item):
pass
menu = pystray.Menu(
pystray.MenuItem("Show NetDesk", lambda ic, it: (api.show_window()),
default=True),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Start engine",
lambda ic, it: api.start_engine(
api.cfg.data.get("server", ""),
"", api.cfg.data.get("name", ""))),
pystray.MenuItem("Stop engine", lambda ic, it: api.stop_engine()),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Open Web Viewer", lambda ic, it: api.open_viewer()),
pystray.MenuItem("Exit NetDesk", lambda ic, it: api.quit_all()),
)
icon = pystray.Icon("NetDesk", img, "NetDesk", menu)
api._tray = icon
icon.run_detached()
except Exception as e:
print("tray unavailable:", e)
def run():
import webview
api = Api()
# standard client size like RustDesk/TeamViewer - never starts maximized.
# Sized to ~62% x 75% of the desktop so it always feels like a windowed
# app; the page auto-zooms so the full layout fits (see fitZoom in HTML).
sw, sh = 1536, 864
try:
import ctypes
u = ctypes.windll.user32
try:
scale = u.GetDpiForSystem() / 96.0
except Exception:
scale = 1.0
sw = u.GetSystemMetrics(0) / (scale or 1.0)
sh = u.GetSystemMetrics(1) / (scale or 1.0)
except Exception:
pass
w = int(min(1060, max(880, sw * 0.62)))
h = int(min(760, max(600, sh * 0.75)))
win = webview.create_window("NetDesk", _html_path(), js_api=api,
width=w, height=h, min_size=(860, 590),
x=max(20, int((sw - w) / 2)), y=max(20, int((sh - h) / 2.4)),
maximized=False,
frameless=True, easy_drag=False,
background_color="#0b0f15")
def _on_closing():
# native ✕ hides to tray by default (RustDesk/TeamViewer behavior),
# or really quits when "Close to tray" is off
if api.cfg.data.get("close_to_tray", True):
api.hide_window()
return False # cancel the close - just hide
api.quit_all()
return True
try:
win.events.closing += _on_closing
except Exception:
pass
threading.Thread(target=_start_tray, args=(api,), daemon=True).start()
# Auto bring the host ONLINE on launch (TeamViewer-style), and on the very
# first run default "Start with Windows" ON so it reconnects at every login.
# Both are safe (a fresh config always carries a random password) and fully
# reversible from the toggles. Runs off-thread so the UI never blocks on it.
def _autostart():
try:
api.ensure_engine()
except Exception:
pass
try:
if IS_WIN and not api.cfg.data.get("autostart_initialized"):
api.add_startup()
api.cfg.data["autostart_initialized"] = True
api.cfg.save()
except Exception:
pass
threading.Thread(target=_autostart, daemon=True).start()
webview.start()
def selftest():
import webview
api = Api()
result = {"ok": False}
def _check():
time.sleep(2.2)
try:
st = api.get_state()
result["ok"] = isinstance(st, dict) and "id" in st
except Exception as e:
result["err"] = str(e)
for w in webview.windows:
try:
w.destroy()
except Exception:
pass
threading.Thread(target=_check, daemon=True).start()
webview.create_window("NetDesk selftest", _html_path(), js_api=api,
width=580, height=760, background_color="#0b0e13")
webview.start()
if result["ok"]:
print("GUI_SELFTEST_OK")
else:
print("GUI_SELFTEST_FAIL", result)
sys.exit(1)
if __name__ == "__main__":
run()
+3318
View File
File diff suppressed because it is too large. Load diff
+112
View File
@@ -0,0 +1,112 @@
"""Interactive command terminal for the NetDesk host ("cmd").
Spawns a shell and streams its output, so a connected viewer gets a working
console: run commands and see their output live (dir, ipconfig, ping, python
scripts, git, etc.). Uses a pipe rather than a full pseudo-console — that covers
command execution; full-screen TUI apps (vim, a nested shell UI) would need a
ConPTY and are a future enhancement.
Wire protocol (host side):
viewer -> host : {"t":"term.open","tid":N} open a shell
viewer -> host : {"t":"term.data","tid":N,"d":"<text>"} send keystrokes/commands
viewer -> host : {"t":"term.resize","tid":N,"cols":C,"rows":R}
viewer -> host : {"t":"term.close","tid":N}
host -> viewer: {"t":"term.data","tid":N,"d":"<text>"} shell output (streamed)
host -> viewer: {"t":"term.exit","tid":N} shell ended
Developed by Akronlogic.
"""
import os
import subprocess
import threading
CREATE_NO_WINDOW = 0x08000000
class TerminalSession:
"""One shell process. `on_output(text: str)` is invoked from a reader
thread as the shell produces output; it is called once with "" when the
shell exits."""
def __init__(self, on_output, shell=None, cwd=None):
self._on_output = on_output
shell = shell or os.environ.get("COMSPEC", "cmd.exe")
self._p = subprocess.Popen(
shell,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=0,
cwd=cwd,
creationflags=CREATE_NO_WINDOW if os.name == "nt" else 0,
)
self._alive = True
self._reader = threading.Thread(target=self._pump, daemon=True)
self._reader.start()
def _pump(self):
try:
while True:
chunk = self._p.stdout.read(4096)
if not chunk:
break
try:
self._on_output(chunk.decode("utf-8", "replace"))
except Exception:
pass
finally:
self._alive = False
try:
self._on_output("") # EOF -> shell closed
except Exception:
pass
def write(self, data):
"""Send keystrokes / a command line (include the trailing \\r\\n)."""
if isinstance(data, str):
data = data.encode("utf-8", "replace")
try:
self._p.stdin.write(data)
self._p.stdin.flush()
return True
except Exception:
return False
def resize(self, cols, rows):
# Pipe mode has no window size; accepted for API parity (ConPTY later).
return None
@property
def alive(self):
return self._alive and self._p.poll() is None
def close(self):
self._alive = False
try:
self._p.stdin.close()
except Exception:
pass
try:
self._p.terminate()
except Exception:
pass
if __name__ == "__main__":
import time
buf = []
term = TerminalSession(on_output=buf.append)
time.sleep(0.3)
term.write("echo NETDESK_TERM_OK_777\r\n")
time.sleep(0.6)
term.write("cd\r\n")
time.sleep(0.4)
term.write("exit\r\n")
time.sleep(0.4)
term.close()
text = "".join(buf)
ok = "NETDESK_TERM_OK_777" in text
print("SELFTEST", "PASS" if ok else "FAIL", "| chars:", len(text))
if not ok:
print(text[:800])