mirror of
https://github.com/Cogential/frametanium.git
synced 2026-10-17 18:00:30 +02:00
Searches GitHub (steam-frame topic, "steam frame" in names/descriptions) and the awesome-steam-frame list, checks each project's releases for a download the Frame can install, and lists them with New badges, in the headset app (X / Discover row) and on the web page. Cached, rechecked only when a repo changes, and limited per run so update checks keep enough of GitHub's unauthenticated rate limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHDaz86aayqMULHsjByEmT
355 lines
17 KiB
HTML
355 lines
17 KiB
HTML
<!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
|
||
|
||
async function get(path) {
|
||
const r = await fetch(path, { headers: hdr() });
|
||
if (r.status === 401) {
|
||
key = prompt("Frametanium 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(); 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>
|