Check for updates when the headset app opens, and let Frametanium update itself

The headset app asks for a check (cached releases under 10 minutes old count) each time it opens; every check
then installs a newer Frametanium release without asking unless something else is installing
("self_update": false turns it off). Its version now comes from a VERSION file in the bundle, so its own row
is right however it was installed (deploy.sh included), and Update all does Frametanium last so the restart
can't cut off another install. After an update the list says to reopen the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C3G4bF9bag9rHEh1Anvngi
This commit is contained in:
CogentialandClaude Opus 5.5 committed 2026-10-21 19:39:44 -04:00
1 parent 80a4e62a49
commit af3d2b37e7
7 files changed
+71 -12

No files matched your search

+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
- name: Package
run: |
tools/bundle.sh dist/app dist/bundle
VERSION="$GITHUB_REF_NAME" tools/bundle.sh dist/app dist/bundle
tar -C dist/bundle -czf frametanium-steam-frame-arm64.tar.gz .
tar -tzf frametanium-steam-frame-arm64.tar.gz | grep -v '^./art/'
+6 -1
View File
@@ -8,6 +8,11 @@ See README.md for what it does. Service: `manager.py` (stdlib Python, runs on th
`ensure_secrets()`. Never add a loopback/no-password bypass, never accept secrets in the query string, and keep
`host_ok()` (Host allow-list + Origin check). Any auth change must change app.c and page.html together, and old
headset apps must still get a readable "#" notice in TSV replies.
- **Self-update:** `check()` (every few hours, on each headset-app launch via `/api/refresh?launch=1`, and on
request) installs a newer Frametanium release by itself (`self_update` in config.json, default on). Its installed
version is the bundle's `VERSION` file (`tools/bundle.sh`; CI passes `VERSION=$GITHUB_REF_NAME`), not
installed.json. `update_all` must keep Frametanium last (its install.sh restarts the service 3 s later).
A bad release spreads to every Frame on its own now: test before tagging.
- Write settings files only with `save_json` (unique temp + fsync; `private=True` for config.json) and do
read-modify-writes under `files_lock`.
- Test locally: run manager.py with `HOME=<scratch>` and a config.json with another port; build app.c natively
@@ -21,4 +26,4 @@ Left, in this order: security MEDIUM 3 (Discover installs run install.sh without
check: verify the GitHub asset `digest`; plan was an ed25519 signature for Frametanium's own updates, verified with
`openssl pkeyutl -rawin`), 6 (tar symlink traversal, .tar.zst via GNU tar), 5 (workflow: SDL2 hash, pin
actions by SHA), 7 (AppImage executed to unpack: prefer `unsquashfs -o <ELF end>`), LOW 8/9/11/14; then code-review
bugs 1-9 and 12-16 (2: update Frametanium last in update_all; 4: Frametanium's own row needs a VERSION file).
bugs 1, 3, 5-9 and 12-16 (2 and 4 were fixed with the self-update on 2026-10-21).
+7 -4
View File
@@ -34,11 +34,14 @@ and Frametanium keeps it up to date. Works from the headset or a browser.
updates, **B** back/close.
- **From a phone or PC:** `http://<frame-ip>:8643` (add apps, edit name/install style/program, remove).
Updates are checked every 6 hours and on demand.
Updates are checked every 6 hours, each time you open the headset app, and on demand.
**Frametanium updates itself:** it's in its own list. Updating it downloads its latest
[release](https://github.com/Cogential/frametanium/releases), reinstalls the service and the headset app, and
restarts the service a few seconds later (reopen the headset app to get the new one).
**Frametanium updates itself:** when a check finds a newer [release](https://github.com/Cogential/frametanium/releases)
of Frametanium, it installs it without asking (unless something else is installing; the next check tries again):
it reinstalls the service and the headset app and restarts the service a few seconds later. The headset app then
says to close and reopen it to get the new version. Turn this off with `"self_update": false` in
`~/.config/frametanium/config.json` (you can still update it from its row in the list). Its version comes from the
`VERSION` file the bundle carries (`tools/bundle.sh`; releases set it from the tag).
## Files
- `catalog.json`: the built-in ports.
+5 -1
View File
@@ -801,7 +801,7 @@ int main(void) {
static Found fd;
static Disc disc[MAX_DISC];
int napps = -1, sel = 0, nrels = 0, rsel = 0, msel = 0, asel = 0, nsug = 0, tried_start = 0;
int ndisc = 0, dsel = 0, disc_running = 0, disc_new = 0, found_from = ADDING;
int ndisc = 0, dsel = 0, disc_running = 0, disc_new = 0, found_from = ADDING, launch_checked = 0;
char disc_msg[300] = "";
Uint32 next_disc_poll = 0;
int screen = LIST, kb_row = 1, kb_col = 0, notice_busy = 0;
@@ -863,6 +863,10 @@ int main(void) {
tried_start = 1;
try_start_service();
}
if (napps >= 0 && !launch_checked) { /* check for updates (and let Frametanium update itself) on opening */
launch_checked = 1;
post("/api/refresh?launch=1", NULL, 0);
}
int busy = notice_busy;
for (int i = 0; i < napps; i++) busy |= busy_status(apps[i].status);
next_poll = SDL_GetTicks() + (busy ? 400 : 2000);
+1
View File
@@ -20,6 +20,7 @@ done
mkdir -p "$dest"
cp "$here/manager.py" "$here/page.html" "$here/catalog.json" "$here/suggestions.json" "$dest/"
if [ -f "$here/VERSION" ]; then cp "$here/VERSION" "$dest/"; else rm -f "$dest/VERSION"; fi
rm -rf "$dest/art" && cp -r "$here/art" "$dest/art"
cp "$here/frametanium.service" "$HOME/.config/systemd/user/"
systemctl --user daemon-reload
+49 -5
View File
@@ -56,6 +56,7 @@ DEFAULT_CONFIG = {
"github_token": "", # needed only for private repos (read-only access to their releases is enough)
"compat_tool": "SteamLinuxRuntime_4-arm64",
"check_hours": 6,
"self_update": True, # install new Frametanium releases by itself
}
CHUNK = 1024 * 1024
RELEASES_TTL = 600
@@ -1149,10 +1150,11 @@ def remove(pid, delete_files=True):
def update_all(_pid=None):
"""Updates every installed app that has a newer release, one after another."""
"""Updates every installed app that has a newer release, one after another. Frametanium goes last: updating it
restarts the service a few seconds later, which would stop any install after it."""
jobs.set("_list", "installing", 0.0, "Updating everything")
count = 0
for row in app_state():
for row in sorted(app_state(), key=lambda r: r["id"] == SELF_ID):
if row["status"] == "update available":
install(row["id"])
count += 1
@@ -1175,6 +1177,34 @@ def start(fn, pid, *args):
_latest = {} # app id -> (tag, error)
SELF_ID = "frametanium"
def own_version():
"""The release this service came from (VERSION, written by tools/bundle.sh), or None."""
try:
return (APP_DIR / "VERSION").read_text().strip() or None
except OSError:
return None
def self_update():
"""Installs a newer Frametanium release once a check has found one, unless something else is installing (the next
check tries again) or "self_update" is off in config.json."""
if not config().get("self_update", True):
return
row = next((r for r in app_state() if r["id"] == SELF_ID), None)
if row and row["status"] == "update available" and start(install, SELF_ID):
print(f"Updating Frametanium {row['installed']} -> {row['latest']}", file=sys.stderr, flush=True)
def check(force=True):
"""An update check (every few hours, when the headset app opens, or on request), then the self-update."""
ok = refresh(force=force)
self_update()
return ok
def refresh(force=False):
"""Checks every app for releases. Returns False if any check failed."""
ok = True
@@ -1195,6 +1225,8 @@ def app_state():
folder = target_dir(port, mode) if mode != "auto" else None
present = bool(folder and folder.exists())
tag = installed.get(pid, {}).get("tag") if present else None
if pid == SELF_ID: # it's running, however it was installed (deploy.sh, its install.sh or an update)
present, tag = True, own_version() or tag
if present and not tag:
tag = "unknown"
if pid not in _latest:
@@ -1412,8 +1444,13 @@ class Handler(BaseHTTPRequestHandler):
arg = lambda k: q.get(k, [""])[0]
pid = arg("id")
if url.path == "/api/refresh":
_releases_cache.clear()
threading.Thread(target=refresh, kwargs={"force": True}, daemon=True).start()
# The headset app asks with launch=1 each time it opens: releases checked in the last 10 minutes count.
force = arg("launch") != "1"
if force:
_releases_cache.clear()
elif jobs.state.get("_list", {}).get("message", "").startswith("Frametanium updated to"):
jobs.state.pop("_list", None) # it's been reopened
threading.Thread(target=check, kwargs={"force": force}, daemon=True).start()
self.reply({"ok": True})
elif url.path == "/api/update_all":
ok = start(update_all, "_list")
@@ -1487,7 +1524,7 @@ class Handler(BaseHTTPRequestHandler):
def checker():
retry = 30
while True:
if refresh(force=True):
if check(force=True):
retry = 30
discover()
time.sleep(max(1, float(config().get("check_hours", 6))) * 3600)
@@ -1501,6 +1538,13 @@ def checker():
def main():
ensure_secrets()
cfg = config()
version = own_version()
if version and version != cfg.get("last_version"):
if cfg.get("last_version"):
jobs.set("_list", "done", 1.0, f"Frametanium updated to {version}. Close this app and open it again "
"to use the new version.")
with files_lock:
save_json(CONFIG_FILE, {**load_json(CONFIG_FILE, {}), "last_version": version}, private=True)
threading.Thread(target=checker, daemon=True).start()
server = ThreadingHTTPServer(("0.0.0.0", int(cfg["port"])), Handler)
server.daemon_threads = True
+2
View File
@@ -8,5 +8,7 @@ APPDIR="$1" OUT="$2"
rm -rf "$OUT" && mkdir -p "$OUT/tools"
cp manager.py page.html catalog.json suggestions.json frametanium.service install.sh README.md "$OUT/"
cp tools/find_appid.py "$OUT/tools/"
# The release it is (releases pass VERSION; local builds count as the newest tag), so Frametanium knows when to update.
echo "${VERSION:-$(git describe --tags --abbrev=0 2>/dev/null || echo unknown)}" > "$OUT/VERSION"
cp -r art "$OUT/art"
cp -r "$APPDIR" "$OUT/app"