mirror of
https://github.com/Cogential/frametanium.git
synced 2026-10-22 06:00:31 +02:00
manager.py is a standard-library service on the Frame (port 8643). It lists each catalog port's releases, downloads the chosen asset (zip, or an AppImage it unpacks with --appimage-extract and gives a launcher), stages it next to ~/devkit-game/<gameid> and swaps it in, keeping ROMs placed next to run.sh and the previous install for rollback. It refuses while the game runs, fixes executable bits, registers the shortcut with Steam and adds library art. A token in the settings unlocks private repos. app/ is the controller-driven headset front end (C + SDL2, talks to the service over loopback with tab-separated replies); page.html is the browser one. deploy.sh installs both over SSH. Tested on the laptop against the real releases: install, version switch, ROM carry-over, rollback, the running-game guard, the AppImage path (under qemu), and both front ends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHDaz86aayqMULHsjByEmT
168 lines
7.2 KiB
HTML
168 lines
7.2 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Port Manager</title>
|
|
<style>
|
|
:root { --bg: #0f1216; --panel: #181d23; --line: #2a313a; --text: #e8edf2; --muted: #93a0ad;
|
|
--accent: #4fa3ff; --ok: #4cc38a; --warn: #f0b429; --bad: #f2555a; }
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; background: var(--bg); color: var(--text);
|
|
font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
|
main { max-width: 760px; margin: 0 auto; padding: 20px 16px 48px; }
|
|
h1 { font-size: 1.5rem; margin: 0 0 4px; }
|
|
.sub { color: var(--muted); margin: 0 0 20px; }
|
|
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 16px; margin-bottom: 14px; }
|
|
.top { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; flex-wrap: wrap; }
|
|
.name { font-weight: 650; font-size: 1.05rem; }
|
|
.badge { font-size: .85rem; padding: 2px 10px; border-radius: 99px; border: 1px solid var(--line); color: var(--muted); white-space: nowrap; }
|
|
.badge.update { color: var(--warn); border-color: var(--warn); }
|
|
.badge.ok { color: var(--ok); border-color: var(--ok); }
|
|
.badge.error { color: var(--bad); border-color: var(--bad); }
|
|
.badge.busy { color: var(--accent); border-color: var(--accent); }
|
|
.ver { color: var(--muted); font-size: .92rem; margin: 6px 0 10px; }
|
|
.msg { font-size: .92rem; margin: 8px 0 0; }
|
|
.msg.error { color: var(--bad); } .msg.done { color: var(--ok); }
|
|
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
button, select, input { font: inherit; border-radius: 8px; }
|
|
button { padding: 8px 14px; border: 1px solid var(--line); background: #222a33; color: var(--text); cursor: pointer; }
|
|
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
button:disabled { opacity: .5; cursor: default; }
|
|
select, input { background: var(--bg); color: var(--text); border: 1px solid var(--line); padding: 8px 10px; max-width: 100%; }
|
|
progress { width: 100%; height: 8px; margin-top: 10px; accent-color: var(--accent); }
|
|
details summary { cursor: pointer; color: var(--muted); }
|
|
.hint { color: var(--muted); font-size: .9rem; }
|
|
input[type=password] { flex: 1; min-width: 200px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<div class="top"><h1>Port Manager</h1><button id="refresh">Check for updates</button></div>
|
|
<p class="sub">Install and update your Steam Frame ports from their GitHub releases. Saves and ROMs are kept.</p>
|
|
<div id="ports"></div>
|
|
<div class="panel">
|
|
<details>
|
|
<summary>Settings</summary>
|
|
<p class="hint">Private repos need a GitHub token with read access to their contents (a fine-grained token
|
|
limited to those repos is enough). It's stored only on the Frame and never shown again.</p>
|
|
<div class="row"><input id="token" type="password" placeholder="github_pat_…" autocomplete="off">
|
|
<button id="savetoken">Save token</button></div>
|
|
<p class="hint" id="tokenstate"></p>
|
|
</details>
|
|
</div>
|
|
</main>
|
|
<script>
|
|
let key = "";
|
|
try { key = localStorage.getItem("pmKey") || ""; } catch (e) {}
|
|
const $ = id => document.getElementById(id);
|
|
const q = encodeURIComponent;
|
|
const hdr = () => key ? { "X-Key": key } : {};
|
|
const releases = {};
|
|
|
|
async function get(path) {
|
|
const r = await fetch(path, { headers: hdr() });
|
|
if (r.status === 401) {
|
|
key = prompt("Port Manager password") || "";
|
|
try { localStorage.setItem("pmKey", key); } catch (e) {}
|
|
return get(path);
|
|
}
|
|
return r.json();
|
|
}
|
|
async function post(path, body) {
|
|
const r = await fetch(path, { method: "POST", headers: hdr(), body });
|
|
return r.json();
|
|
}
|
|
|
|
function badge(p) {
|
|
if (p.status === "downloading" || p.status === "installing") return ["busy", p.status === "downloading" ? "Downloading" : "Installing"];
|
|
if (p.status === "update available") return ["update", "Update available"];
|
|
if (p.status === "up to date") return ["ok", "Up to date"];
|
|
if (p.status === "error") return ["error", "Problem"];
|
|
return ["", "Not installed"];
|
|
}
|
|
|
|
async function loadReleases(p, sel) {
|
|
if (!releases[p.id]) {
|
|
const r = await get(`/api/releases?id=${q(p.id)}`);
|
|
releases[p.id] = r.releases || [];
|
|
}
|
|
sel.innerHTML = "";
|
|
for (const r of releases[p.id]) {
|
|
const o = document.createElement("option");
|
|
o.value = r.tag;
|
|
o.textContent = `${r.tag}${r.tag === p.latest ? " (latest)" : ""}${r.tag === p.installed ? " (installed)" : ""} · ${r.date}`;
|
|
sel.append(o);
|
|
}
|
|
}
|
|
|
|
function card(p, busy) {
|
|
const div = document.createElement("div");
|
|
div.className = "panel";
|
|
const [cls, label] = badge(p);
|
|
div.innerHTML = `
|
|
<div class="top"><span class="name"></span><span class="badge ${cls}">${label}</span></div>
|
|
<div class="ver"></div>
|
|
<div class="row">
|
|
<button class="primary act"></button>
|
|
<select class="pick"><option>Loading versions…</option></select>
|
|
<button class="inst">Install this version</button>
|
|
<button class="back">Previous version</button>
|
|
</div>
|
|
<p class="msg"></p>`;
|
|
div.querySelector(".name").textContent = p.name;
|
|
div.querySelector(".ver").textContent =
|
|
`Installed: ${p.installed === null ? "—" : p.installed} · Latest: ${p.latest === null ? "—" : p.latest}`;
|
|
const act = div.querySelector(".act");
|
|
act.textContent = p.status === "not installed" ? "Install" : p.status === "update available" ? "Update" : "Reinstall";
|
|
const working = p.status === "downloading" || p.status === "installing";
|
|
for (const b of div.querySelectorAll("button")) b.disabled = busy;
|
|
div.querySelector(".back").disabled = busy || !p.can_rollback;
|
|
if (!p.latest) act.disabled = true;
|
|
act.onclick = () => post(`/api/install?id=${q(p.id)}`).then(poll);
|
|
const sel = div.querySelector(".pick");
|
|
loadReleases(p, sel);
|
|
div.querySelector(".inst").onclick = () => post(`/api/install?id=${q(p.id)}&tag=${q(sel.value)}`).then(poll);
|
|
div.querySelector(".back").onclick = () => post(`/api/rollback?id=${q(p.id)}`).then(poll);
|
|
const msg = div.querySelector(".msg");
|
|
msg.textContent = p.message;
|
|
msg.className = "msg " + (p.status === "error" || (p.message && p.job === "error") ? "error" : p.job === "done" ? "done" : "");
|
|
if (working) {
|
|
const bar = document.createElement("progress");
|
|
bar.max = 1; bar.value = p.progress;
|
|
div.append(bar);
|
|
}
|
|
return div;
|
|
}
|
|
|
|
let timer = null, last = "";
|
|
async function poll() {
|
|
const s = await get("/api/state");
|
|
const snapshot = JSON.stringify(s);
|
|
if (snapshot !== last) { // redraw only on changes, so an open version list isn't reset
|
|
last = snapshot;
|
|
const box = $("ports");
|
|
box.innerHTML = "";
|
|
for (const p of s.ports) box.append(card(p, s.busy));
|
|
}
|
|
$("tokenstate").textContent = s.token_set ? "A token is saved." : "No token saved.";
|
|
clearTimeout(timer);
|
|
timer = setTimeout(poll, s.busy ? 700 : 5000);
|
|
}
|
|
|
|
$("refresh").onclick = async () => {
|
|
for (const k in releases) delete releases[k];
|
|
await post("/api/refresh");
|
|
setTimeout(poll, 2500);
|
|
};
|
|
$("savetoken").onclick = async () => {
|
|
await post("/api/token", $("token").value.trim());
|
|
$("token").value = "";
|
|
for (const k in releases) delete releases[k];
|
|
setTimeout(poll, 2500);
|
|
};
|
|
poll();
|
|
</script>
|
|
</body>
|
|
</html>
|