Files
Cogential--frametanium/page.html
T
CogentialandClaude Opus 5.5 94865cb34f Add any GitHub repo, Obtainium-style
Apps can now be added by repo (headset on-screen keyboard, pasted link, suggestions or the web page). The service
picks the ARM64 Linux download, unpacks zips/tarballs/AppImages/single programs, and either adds the app to the
Steam library with a generated run.sh and GitHub art, or runs the project's own install.sh. Adds update all,
per-app pre-release setting, remove (with uninstaller), and an edit panel on the web page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHDaz86aayqMULHsjByEmT
2026-10-07 12:25:59 -04:00

297 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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; }
h2 { font-size: 1.05rem; margin: 0 0 10px; }
a { color: var(--accent); }
.sub { color: var(--muted); margin: 0 0 16px; }
.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; }
.repo { color: var(--muted); font-size: .85rem; text-decoration: none; }
.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; overflow-wrap: anywhere; }
.msg.error { color: var(--bad); } .msg.done { color: var(--ok); }
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.grow { flex: 1; min-width: 200px; }
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.danger { color: var(--bad); }
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); }
details[open] summary { margin-bottom: 10px; }
.hint { color: var(--muted); font-size: .9rem; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.chip { text-align: left; padding: 8px 12px; max-width: 100%; }
.chip small { display: block; color: var(--muted); font-size: .8rem; }
.found { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 12px; }
.notice { color: var(--ok); margin: 0 0 12px; }
label { display: flex; gap: 8px; align-items: center; }
</style>
</head>
<body>
<main>
<div class="top"><h1>Port Manager</h1>
<div class="row"><button id="updateall">Update all</button><button id="refresh">Check for updates</button></div></div>
<p class="sub">Install and update Steam Frame apps and ports from their GitHub releases. Saves and ROMs are kept.</p>
<p class="notice" id="notice" hidden></p>
<div class="panel">
<h2>Add an app from GitHub</h2>
<div class="row"><input id="repo" class="grow" placeholder="owner/name or a github.com link" autocomplete="off">
<button id="lookup">Look up</button></div>
<p class="msg error" id="adderr" hidden></p>
<div id="found" class="found" hidden></div>
<div class="chips" id="suggest"></div>
</div>
<div id="apps"></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" class="grow" 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 = {};
const open = {}; // app ids whose Edit section is open, kept across redraws
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 el(tag, props = {}, ...kids) {
const e = Object.assign(document.createElement(tag), props);
e.append(...kids);
return e;
}
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"];
}
const modeText = { game: "In the Steam library", installer: "Set up by its installer", auto: "Decided on first install" };
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]) {
sel.append(el("option", { value: r.tag, textContent:
`${r.tag}${r.prerelease ? " (pre-release)" : ""}${r.tag === p.latest ? " (latest)" : ""}${r.tag === p.installed ? " (installed)" : ""} · ${r.date}` }));
}
if (!sel.options.length) sel.append(el("option", { textContent: "No versions" }));
}
function card(p, busy) {
const div = el("div", { className: "panel" });
const [cls, label] = badge(p);
div.innerHTML = `
<div class="top"><div><span class="name"></span><br><a class="repo" target="_blank" rel="noopener"></a></div>
<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>
<details class="edit"><summary>Edit</summary>
<div class="row"><input class="ename grow" placeholder="Name"></div>
<p></p>
<label><input type="checkbox" class="epre"> Include pre-releases</label>
<p></p>
<div class="row">How it's installed:
<select class="emode"><option value="auto">Decide on first install</option>
<option value="game">Add to the Steam library</option><option value="installer">Run its install.sh</option></select></div>
<p></p>
<div class="row"><input class="eprog grow" placeholder="Program to start, e.g. bin/mygame (blank: find it)"></div>
<p class="hint">Used for library apps without their own run.sh. Takes effect on the next install.</p>
<div class="row"><button class="save">Save</button><button class="danger rm">Remove</button></div>
</details>`;
div.querySelector(".name").textContent = p.name;
const link = div.querySelector(".repo");
link.textContent = p.repo; link.href = `https://github.com/${p.repo}`;
div.querySelector(".ver").textContent =
`Installed: ${p.installed === null ? "—" : p.installed} · Latest: ${p.latest === null ? "—" : p.latest} · ${modeText[p.mode] || p.mode}`;
const act = div.querySelector(".act");
act.textContent = p.status === "not installed" || p.status === "error" && !p.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.job === "error" || p.status === "error" ? "error" : p.job === "done" ? "done" : "");
if (working) {
const bar = el("progress", { max: 1, value: p.progress });
div.insertBefore(bar, msg);
}
const edit = div.querySelector(".edit");
edit.open = !!open[p.id];
edit.ontoggle = () => { open[p.id] = edit.open; };
div.querySelector(".ename").value = p.name;
div.querySelector(".epre").checked = p.prereleases;
div.querySelector(".emode").value = p.mode;
div.querySelector(".eprog").value = p.program || "";
div.querySelector(".save").onclick = async () => {
const pre = div.querySelector(".epre").checked ? 1 : 0;
await post(`/api/settings?id=${q(p.id)}&name=${q(div.querySelector(".ename").value)}&prereleases=${pre}` +
`&mode=${q(div.querySelector(".emode").value)}&program=${q(div.querySelector(".eprog").value)}`);
delete releases[p.id]; last = ""; poll();
};
div.querySelector(".rm").onclick = async () => {
const what = p.installed ? " Its files are deleted too (saves and settings kept elsewhere stay)." : "";
if (!confirm(`Remove ${p.name}?${what}`)) return;
await post(`/api/remove?id=${q(p.id)}`);
delete open[p.id]; setTimeout(poll, 600);
};
return div;
}
// ---- adding apps
async function lookup(repo, name) {
$("adderr").hidden = true;
$("found").hidden = false;
$("found").textContent = "Looking it up…";
const info = await get(`/api/inspect?repo=${q(repo)}`);
if (info.error) {
$("found").hidden = true;
$("adderr").textContent = info.error; $("adderr").hidden = false;
return;
}
const f = $("found");
f.innerHTML = "";
f.append(
el("div", { className: "name", textContent: info.name }),
el("a", { className: "repo", href: `https://github.com/${info.repo}`, target: "_blank", textContent: info.repo }),
el("p", { className: "hint", textContent: (info.description || "") }),
el("p", { className: "hint", textContent: `Latest: ${info.tag} · ${info.date}${info.prereleases ? " (pre-release; pre-releases will be included)" : ""}` }));
if (info.existing) {
f.append(el("p", { className: "msg done", textContent: "It's already on your list." }));
return;
}
const nameIn = el("input", { className: "grow", value: name || info.name });
const assetSel = el("select");
for (const a of info.assets)
assetSel.append(el("option", { value: a.name, textContent: `${a.name} (${(a.size / 1048576).toFixed(1)} MB)${a.arm64 ? "" : " – not ARM64"}` }));
const modeSel = el("select");
for (const [v, t] of [["auto", "Decide from the download"], ["game", "Add to the Steam library"], ["installer", "Run its install.sh"]])
modeSel.append(el("option", { value: v, textContent: t }));
const add = async install => {
const r = await post(`/api/add?repo=${q(info.repo)}&asset=${q(assetSel.value)}&name=${q(nameIn.value)}&mode=${q(modeSel.value)}&install=${install}`);
if (r.error) { $("adderr").textContent = r.error; $("adderr").hidden = false; return; }
f.hidden = true; $("repo").value = ""; last = ""; poll(); loadSuggestions();
};
f.append(
el("div", { className: "row" }, "Name", nameIn), el("p"),
el("div", { className: "row" }, "Download", assetSel), el("p"),
el("div", { className: "row" }, "Install", modeSel), el("p"),
el("div", { className: "row" },
el("button", { className: "primary", textContent: "Add and install", onclick: () => add(1) }),
el("button", { textContent: "Add", onclick: () => add(0) })));
if (!info.assets.some(a => a.arm64))
f.append(el("p", { className: "msg error", textContent: "None of its downloads say ARM64, so they may not run on the Frame." }));
}
async function loadSuggestions() {
const s = await get("/api/suggestions");
const box = $("suggest");
box.innerHTML = "";
for (const x of (s.suggestions || []).filter(x => !x.added)) {
box.append(el("button", { className: "chip", onclick: () => { $("repo").value = x.repo; lookup(x.repo, x.name); } },
x.name, el("small", { textContent: x.note })));
}
if (box.children.length) box.prepend(el("div", { className: "hint", style: "width:100%", textContent: "Suggested for the Frame:" }));
}
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 = $("apps");
box.innerHTML = "";
for (const p of s.apps) box.append(card(p, s.busy));
const n = s.notice || {};
$("notice").hidden = !n.message;
$("notice").textContent = n.message || "";
$("updateall").disabled = s.busy;
}
$("tokenstate").textContent = s.token_set ? "A token is saved." : "No token saved.";
clearTimeout(timer);
timer = setTimeout(poll, s.busy ? 700 : 5000);
}
$("lookup").onclick = () => lookup($("repo").value);
$("repo").onkeydown = e => { if (e.key === "Enter") lookup($("repo").value); };
$("refresh").onclick = async () => {
for (const k in releases) delete releases[k];
await post("/api/refresh");
setTimeout(poll, 2500);
};
$("updateall").onclick = () => post("/api/update_all").then(() => setTimeout(poll, 300));
$("savetoken").onclick = async () => {
await post("/api/token", $("token").value.trim());
$("token").value = "";
for (const k in releases) delete releases[k];
setTimeout(poll, 2500);
};
poll();
loadSuggestions();
</script>
</body>
</html>