From 2a2d72f288f31bd2572359691ece81a53ad60720 Mon Sep 17 00:00:00 2001 From: drjones Date: Thu, 13 Aug 2026 18:06:14 -0700 Subject: [PATCH] noVNC bridge: per-device live screens in dashboard (websockify + TokenFile + VNC button) --- android-vnc.service | 12 ++++++++++++ app.py | 41 ++++++++++++++++++++++++++++++++++++++++- static/index.html | 11 +++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 android-vnc.service diff --git a/android-vnc.service b/android-vnc.service new file mode 100644 index 0000000..5000a26 --- /dev/null +++ b/android-vnc.service @@ -0,0 +1,12 @@ +[Unit] +Description=Android Fleet — noVNC bridge (per-device live screens) +After=network.target + +[Service] +Type=simple +ExecStart=/bin/websockify --web=/usr/share/novnc --token-plugin=TokenFile --token-source=/opt/android-fleet/vnc_tokens.json 0.0.0.0:8081 +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/app.py b/app.py index c7b70ce..ca5b7a9 100644 --- a/app.py +++ b/app.py @@ -691,6 +691,45 @@ def api_shot_latest(mac): return jsonify({"error": "no screenshot yet"}), 404 return send_file(path, mimetype="image/png") +VNC_TOKEN_FILE = "/opt/android-fleet/vnc_tokens.json" + +def regen_vnc_tokens(): + """Map each online device MAC -> its QEMU VNC port for websockify TokenFile.""" + try: + con = db() + rows = con.execute("SELECT mac, vmid FROM devices WHERE vmid IS NOT NULL").fetchall() + con.close() + lines = [] + for r in rows: + got = pve("GET", f"/nodes/pve/qemu/{r['vmid']}/config") or {} + cfg = (got.get("data") or {}) or {} + args = cfg.get("args", "") or "" + m = re.search(r"-vnc 0\.0\.0\.0:(\d{4})", args) + if m: + port = int(m.group(1)) + 5900 # QEMU: -vnc :N listens on N+5900 + lines.append(f"{r['mac']}: 10.30.20.85:{port}") + with open(VNC_TOKEN_FILE, "w") as f: + f.write("\n".join(lines) + ("\n" if lines else "")) + except Exception as e: + log("WARN", f"vnc token regen: {e}") + +def vnc_loop(): + while True: + regen_vnc_tokens() + time.sleep(60) + +@app.route("/api/device//vnc") +def api_vnc(mac): + regen_vnc_tokens() + try: + tokens = open(VNC_TOKEN_FILE).read() + except FileNotFoundError: + tokens = "" + if mac not in tokens: + return jsonify({"error": "device has no VNC mapping (needs vmid + -vnc args)"}), 404 + return jsonify({"url": (f"http://10.30.20.31:8081/vnc.html?autoconnect=true" + f"&reconnect=true&resize=scale&path=websockify?token={mac}")}) + @app.route("/api/device/", methods=["DELETE"]) def api_delete_device(mac): """Remove a device row (and release its proxy).""" @@ -841,7 +880,7 @@ if __name__ == "__main__": subprocess.run(["adb", "start-server"], capture_output=True, timeout=10) except Exception: pass - for fn in (discovery_loop, adb_loop, proxy_loop, ws_broadcast_loop, screenshot_loop): + for fn in (discovery_loop, adb_loop, proxy_loop, ws_broadcast_loop, screenshot_loop, vnc_loop): threading.Thread(target=fn, daemon=True).start() log("STARTUP", f"godhead up — template {TEMPLATE}, LAN {LAN}") app.run(host="0.0.0.0", port=8080, threaded=True) diff --git a/static/index.html b/static/index.html index 1088400..f2c7264 100644 --- a/static/index.html +++ b/static/index.html @@ -240,6 +240,7 @@ function renderFleet() {
+ @@ -396,6 +397,16 @@ function hoverOff() { } // ---------- interactive terminal ---------- +function doVnc(mac) { + fetch("/api/device/" + mac + "/vnc").then(r => r.json()).then(d => { + if (d.url) { + openModal("LIVE SCREEN — " + mac.slice(0, 14), ``); + } else { + openModal("LIVE SCREEN", `
${d.error || "no VNC mapping"}
`); + } + }); +} + function doTerm(mac) { openModal("TERMINAL — " + mac.slice(0, 14), `