diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 98e291c..86138c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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/' diff --git a/CLAUDE.md b/CLAUDE.md index 4add231..e1168b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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=` 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 `), 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). diff --git a/README.md b/README.md index 77de041..f5a72fc 100644 --- a/README.md +++ b/README.md @@ -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://: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. diff --git a/app/app.c b/app/app.c index bde6b63..f42dfc0 100644 --- a/app/app.c +++ b/app/app.c @@ -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); diff --git a/install.sh b/install.sh index 072f9c9..7527af8 100755 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/manager.py b/manager.py index 5276347..e1717d0 100755 --- a/manager.py +++ b/manager.py @@ -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 diff --git a/tools/bundle.sh b/tools/bundle.sh index ba8af9f..b8fe535 100755 --- a/tools/bundle.sh +++ b/tools/bundle.sh @@ -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"