Files
Cogential--frametanium/page.html
T
CogentialandClaude Opus 5.5 6635ac1342 Require a secret for every API request, loopback included; check Host and Origin
The service makes up a web password and a headset-app key on first start (config.json, mode 600). The headset
app sends the key in X-Frametanium-Key and shows the page's address and password under its list; the page sends
the password in X-Key (no more ?key=). Unknown Host names (DNS rebinding) and cross-site Origins get 403, wrong
secrets wait a second. Headset apps from before this see 'close this app and open it again' after a self-update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C3G4bF9bag9rHEh1Anvngi
2026-10-21 00:07:54 -04:00

364 lines
18 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>Frametanium</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; }
.disc { border-top: 1px solid var(--line); padding: 12px 0; }
.disc:first-child { border-top: 0; padding-top: 4px; }
.disc .desc { margin: 4px 0; font-size: .92rem; overflow-wrap: anywhere; }
.badge.new { color: var(--accent); border-color: var(--accent); }
.warn { color: var(--warn); }
</style>
</head>
<body>
<main>
<div class="top"><h1>Frametanium</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 class="panel" id="discover">
<div class="top"><h2>New for the Frame on GitHub <span class="badge new" id="newcount" hidden></span></h2>
<button id="research">Search again</button></div>
<p class="hint" id="discstate">Projects made for the Steam Frame with a release you can install. Searched every
12 hours.</p>
<div id="disclist"></div>
<button id="discmore" hidden></button>
</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
// Every request carries the password (the Frametanium app in the headset shows it under its list).
async function call(path, opts = {}) {
const used = key;
const r = await fetch(path, { ...opts, headers: hdr() });
if (r.status === 403) {
$("notice").hidden = false;
$("notice").textContent = (await r.json()).error;
throw new Error("refused");
}
if (r.status !== 401) return r.json();
if (key === used) { // ask once, not once per request that was already on its way
const typed = prompt((key ? "That password didn't work. " : "") +
"Frametanium password (it's under the list in the Frametanium app in the headset):");
if (typed === null) throw new Error("No password");
key = typed.trim();
try { localStorage.setItem("pmKey", key); } catch (e) {}
}
return call(path, opts);
}
const get = path => call(path);
const post = (path, body) => call(path, { method: "POST", body });
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(); loadDiscover();
};
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:" }));
}
// ---- discover
const shownNew = new Set(); // repos that were new when this page opened keep their badge until reload
let discAll = false, discTimer = null;
async function loadDiscover() {
const d = await get("/api/discover");
for (const p of d.projects || []) if (p.new) shownNew.add(p.repo);
const box = $("disclist");
box.innerHTML = "";
const rows = d.projects || [];
const show = discAll ? rows : rows.slice(0, 8);
for (const p of show) {
const lookBtn = el("button", { className: "primary", textContent: "Look at it" });
lookBtn.onclick = () => { $("repo").value = p.repo; lookup(p.repo, p.name); $("repo").scrollIntoView({ behavior: "smooth" }); };
const hide = el("button", { textContent: "Not interested" });
hide.onclick = async () => { await post(`/api/discover/hide?repo=${q(p.repo)}`); loadDiscover(); };
const meta = `${p.tag}${p.prerelease ? " (pre-release)" : ""} · ${p.date} · ★ ${p.stars} · ${p.asset}`;
box.append(el("div", { className: "disc" },
el("div", { className: "top" },
el("div", {}, el("span", { className: "name", textContent: p.name + " " }),
shownNew.has(p.repo) ? el("span", { className: "badge new", textContent: "New" }) : "",
el("br"), el("a", { className: "repo", href: `https://github.com/${p.repo}`, target: "_blank", rel: "noopener", textContent: p.repo }))),
el("p", { className: "desc", textContent: p.description || "" }),
el("p", { className: "hint", textContent: meta },
p.arm64 ? "" : el("span", { className: "warn", textContent: " · doesn't say ARM64, check before installing" })),
el("div", { className: "row" }, lookBtn, hide)));
}
if (!rows.length) box.append(el("p", { className: "hint", textContent: d.running ? "Searching…" : "Nothing new right now." }));
$("discmore").hidden = rows.length <= 8;
$("discmore").textContent = discAll ? "Show fewer" : `Show all ${rows.length}`;
const n = [...shownNew].filter(r => rows.some(p => p.repo === r)).length;
$("newcount").hidden = !n;
$("newcount").textContent = `${n} new`;
$("discstate").textContent = d.running ? (d.message || "Searching GitHub…")
: (d.message || "Projects made for the Steam Frame with a release you can install. Searched every 12 hours.");
$("research").disabled = d.running;
clearTimeout(discTimer);
if (d.running) discTimer = setTimeout(loadDiscover, 2000);
else if (rows.some(p => p.new)) setTimeout(() => post("/api/discover/seen?repos=" + q(rows.map(p => p.repo).join(","))), 3000);
}
$("discmore").onclick = () => { discAll = !discAll; loadDiscover(); };
$("research").onclick = async () => { await post("/api/discover/refresh"); setTimeout(loadDiscover, 500); };
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();
loadDiscover();
</script>
</body>
</html>