const baseUrlInput = document.getElementById("baseUrl"); const targetOverrideInput = document.getElementById("targetOverride"); const deviceSelect = document.getElementById("deviceSelect"); const requestLog = document.getElementById("requestLog"); const responseView = document.getElementById("responseView"); const appLaunchValue = document.getElementById("appLaunchValue"); const refreshClear = document.getElementById("refreshClear"); const singleKey = document.getElementById("singleKey"); const sequenceKeys = document.getElementById("sequenceKeys"); const sequenceDelay = document.getElementById("sequenceDelay"); const devZip = document.getElementById("devZip"); const configKey = document.getElementById("configKey"); const configValue = document.getElementById("configValue"); function getBaseUrl() { return baseUrlInput.value.replace(/\/+$/, ""); } function getDeviceTarget() { const override = targetOverrideInput.value.trim(); if (override.length > 0) return override; const selected = deviceSelect.value.trim(); if (selected.length > 0) return selected; throw new Error("Select a device or enter a manual override target first."); } function setResponse(value) { responseView.value = value; } function appendLog(line) { const timestamp = new Date().toISOString(); const current = requestLog.value || ""; requestLog.value = `[${timestamp}] ${line}\n${current}`.trim(); } function safeParseJsonInput(input) { try { return JSON.parse(input); } catch { return input; } } async function callJson(path, method = "GET", body = null, extraHeaders = {}) { const url = `${getBaseUrl()}${path}`; const headers = { ...extraHeaders }; const init = { method, headers }; if (body !== null) { headers["Content-Type"] = "application/json"; init.body = JSON.stringify(body); } appendLog(`${method} ${url}`); let response; try { response = await fetch(url, init); } catch (error) { const origin = window.location.origin; setResponse( JSON.stringify( { request: { method, url, body, origin }, error: { kind: "network_or_cors", message: String(error), hint: "If tvctl API is running, this is likely CORS. Enable daemon.cors_enabled and add this origin to daemon.cors_allowed_origins.", }, }, null, 2 ) ); throw error; } const raw = await response.text(); let parsed = null; try { parsed = JSON.parse(raw); } catch { parsed = { non_json_body: raw }; } setResponse( JSON.stringify( { request: { method, url, body }, response: { status: response.status, ok: response.ok, headers: Object.fromEntries(response.headers.entries()), body: parsed, }, }, null, 2 ) ); if (!response.ok) { throw new Error(`${method} ${path} failed with status ${response.status}`); } return parsed; } async function callMultipart(path, file) { const target = getDeviceTarget(); const url = `${getBaseUrl()}${path.replace("{id}", encodeURIComponent(target))}`; const form = new FormData(); form.append("archive", file); appendLog(`POST ${url} (multipart archive=${file.name})`); let response; try { response = await fetch(url, { method: "POST", body: form }); } catch (error) { const origin = window.location.origin; setResponse( JSON.stringify( { request: { method: "POST", url, body: `multipart archive=${file.name}`, origin, }, error: { kind: "network_or_cors", message: String(error), hint: "If tvctl API is running, this is likely CORS. Enable daemon.cors_enabled and add this origin to daemon.cors_allowed_origins.", }, }, null, 2 ) ); throw error; } const raw = await response.text(); let parsed = null; try { parsed = JSON.parse(raw); } catch { parsed = { non_json_body: raw }; } setResponse( JSON.stringify( { request: { method: "POST", url, body: `multipart archive=${file.name}`, }, response: { status: response.status, ok: response.ok, headers: Object.fromEntries(response.headers.entries()), body: parsed, }, }, null, 2 ) ); if (!response.ok) { throw new Error(`POST ${path} failed with status ${response.status}`); } } async function runAction(action) { try { await action(); } catch (error) { appendLog(`ERROR: ${error}`); } } function requireConfirm(message) { return window.confirm(message); } async function refreshDeviceList() { const json = await callJson("/devices"); const devices = json?.data ?? []; deviceSelect.innerHTML = ""; if (devices.length === 0) { deviceSelect.innerHTML = ""; return; } for (const device of devices) { const value = device.id || device.name; const option = document.createElement("option"); option.value = value; option.textContent = `${device.name} (${device.id})`; if (device.is_default) { option.selected = true; } deviceSelect.appendChild(option); } } document.getElementById("pingBtn").addEventListener("click", () => runAction(async () => { await callJson("/daemon/status"); }) ); document.getElementById("listDevicesBtn").addEventListener("click", () => runAction(async () => { await refreshDeviceList(); }) ); document.getElementById("discoverBtn").addEventListener("click", () => runAction(async () => { await callJson("/devices/discover", "POST"); await refreshDeviceList(); }) ); document.getElementById("getDeviceBtn").addEventListener("click", () => runAction(async () => { const target = getDeviceTarget(); await callJson(`/devices/${encodeURIComponent(target)}`); }) ); document.getElementById("deleteDeviceBtn").addEventListener("click", () => runAction(async () => { const target = getDeviceTarget(); if (!requireConfirm(`Delete device '${target}'?`)) return; await callJson(`/devices/${encodeURIComponent(target)}`, "DELETE"); await refreshDeviceList(); }) ); document.getElementById("getStateBtn").addEventListener("click", () => runAction(async () => { const target = getDeviceTarget(); await callJson(`/devices/${encodeURIComponent(target)}/state`); }) ); document.getElementById("listAppsBtn").addEventListener("click", () => runAction(async () => { const target = getDeviceTarget(); await callJson(`/devices/${encodeURIComponent(target)}/apps`); }) ); document.getElementById("launchAppBtn").addEventListener("click", () => runAction(async () => { const target = getDeviceTarget(); const app = appLaunchValue.value.trim(); if (!app) throw new Error("Launch app value is required."); if (!requireConfirm(`Launch '${app}' on '${target}'?`)) return; await callJson(`/devices/${encodeURIComponent(target)}/apps/launch`, "POST", { app }); }) ); document.getElementById("stopAppBtn").addEventListener("click", () => runAction(async () => { const target = getDeviceTarget(); if (!requireConfirm(`Stop active app on '${target}'?`)) return; await callJson(`/devices/${encodeURIComponent(target)}/apps/stop`, "POST"); }) ); document.getElementById("refreshAppsBtn").addEventListener("click", () => runAction(async () => { const target = getDeviceTarget(); await callJson(`/devices/${encodeURIComponent(target)}/apps/refresh`, "POST", { clear: refreshClear.checked, }); }) ); document.getElementById("sendKeyBtn").addEventListener("click", () => runAction(async () => { const target = getDeviceTarget(); const key = singleKey.value.trim(); if (!key) throw new Error("Single key is required."); await callJson(`/devices/${encodeURIComponent(target)}/remote/key`, "POST", { key }); }) ); document.getElementById("sendSequenceBtn").addEventListener("click", () => runAction(async () => { const target = getDeviceTarget(); const keys = sequenceKeys.value .split(",") .map((item) => item.trim()) .filter(Boolean); if (keys.length === 0) throw new Error("At least one sequence key is required."); await callJson(`/devices/${encodeURIComponent(target)}/remote/sequence`, "POST", { keys, delay_ms: Number(sequenceDelay.value || "0"), }); }) ); document.getElementById("devInstallBtn").addEventListener("click", () => runAction(async () => { const target = getDeviceTarget(); if (!devZip.files || devZip.files.length === 0) { throw new Error("Select a zip file before installing."); } if (!requireConfirm(`Install selected dev zip to '${target}'?`)) return; await callMultipart(`/devices/{id}/dev/install`, devZip.files[0]); }) ); document.getElementById("devReloadBtn").addEventListener("click", () => runAction(async () => { const target = getDeviceTarget(); await callJson(`/devices/${encodeURIComponent(target)}/dev/reload`, "POST"); }) ); document.getElementById("devLogsBtn").addEventListener("click", () => runAction(async () => { const target = getDeviceTarget(); await callJson(`/devices/${encodeURIComponent(target)}/dev/logs`); }) ); document.getElementById("getConfigBtn").addEventListener("click", () => runAction(async () => { await callJson("/config"); }) ); document.getElementById("patchConfigBtn").addEventListener("click", () => runAction(async () => { const key = configKey.value.trim(); if (!key) throw new Error("Config key is required."); if (!requireConfirm(`Patch config key '${key}'?`)) return; const value = safeParseJsonInput(configValue.value.trim()); await callJson("/config", "PATCH", { [key]: value }); }) ); document.getElementById("reloadConfigBtn").addEventListener("click", () => runAction(async () => { if (!requireConfirm("Reload daemon config now?")) return; await callJson("/config/reload", "POST"); }) ); window.addEventListener("error", (event) => { appendLog(`ERROR: ${event.message}`); }); window.addEventListener("unhandledrejection", (event) => { appendLog(`ERROR: ${event.reason}`); }); runAction(refreshDeviceList);