1 Commits
Author SHA1 Message Date
CogentialandClaude Opus 5.5 1e1564df68 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
2026-10-21 19:39:44 -04:00
7 changed files with 71 additions and 12 deletions

No files matched your search

+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
- name: Package - name: Package
run: | 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 -C dist/bundle -czf frametanium-steam-frame-arm64.tar.gz .
tar -tzf frametanium-steam-frame-arm64.tar.gz | grep -v '^./art/' 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 `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 `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. 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 - Write settings files only with `save_json` (unique temp + fsync; `private=True` for config.json) and do
read-modify-writes under `files_lock`. 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 - 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 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 `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 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. updates, **B** back/close.
- **From a phone or PC:** `http://<frame-ip>:8643` (add apps, edit name/install style/program, remove). - **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 **Frametanium updates itself:** when a check finds a newer [release](https://github.com/Cogential/frametanium/releases)
[release](https://github.com/Cogential/frametanium/releases), reinstalls the service and the headset app, and of Frametanium, it installs it without asking (unless something else is installing; the next check tries again):
restarts the service a few seconds later (reopen the headset app to get the new one). 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 ## Files
- `catalog.json`: the built-in ports. - `catalog.json`: the built-in ports.
+5 -1
View File
@@ -801,7 +801,7 @@ int main(void) {
static Found fd; static Found fd;
static Disc disc[MAX_DISC]; static Disc disc[MAX_DISC];
int napps = -1, sel = 0, nrels = 0, rsel = 0, msel = 0, asel = 0, nsug = 0, tried_start = 0; 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] = ""; char disc_msg[300] = "";
Uint32 next_disc_poll = 0; Uint32 next_disc_poll = 0;
int screen = LIST, kb_row = 1, kb_col = 0, notice_busy = 0; int screen = LIST, kb_row = 1, kb_col = 0, notice_busy = 0;
@@ -863,6 +863,10 @@ int main(void) {
tried_start = 1; tried_start = 1;
try_start_service(); 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; int busy = notice_busy;
for (int i = 0; i < napps; i++) busy |= busy_status(apps[i].status); for (int i = 0; i < napps; i++) busy |= busy_status(apps[i].status);
next_poll = SDL_GetTicks() + (busy ? 400 : 2000); next_poll = SDL_GetTicks() + (busy ? 400 : 2000);
+1
View File
@@ -20,6 +20,7 @@ done
mkdir -p "$dest" mkdir -p "$dest"
cp "$here/manager.py" "$here/page.html" "$here/catalog.json" "$here/suggestions.json" "$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" rm -rf "$dest/art" && cp -r "$here/art" "$dest/art"
cp "$here/frametanium.service" "$HOME/.config/systemd/user/" cp "$here/frametanium.service" "$HOME/.config/systemd/user/"
systemctl --user daemon-reload 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) "github_token": "", # needed only for private repos (read-only access to their releases is enough)
"compat_tool": "SteamLinuxRuntime_4-arm64", "compat_tool": "SteamLinuxRuntime_4-arm64",
"check_hours": 6, "check_hours": 6,
"self_update": True, # install new Frametanium releases by itself
} }
CHUNK = 1024 * 1024 CHUNK = 1024 * 1024
RELEASES_TTL = 600 RELEASES_TTL = 600
@@ -1149,10 +1150,11 @@ def remove(pid, delete_files=True):
def update_all(_pid=None): 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") jobs.set("_list", "installing", 0.0, "Updating everything")
count = 0 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": if row["status"] == "update available":
install(row["id"]) install(row["id"])
count += 1 count += 1
@@ -1175,6 +1177,34 @@ def start(fn, pid, *args):
_latest = {} # app id -> (tag, error) _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): def refresh(force=False):
"""Checks every app for releases. Returns False if any check failed.""" """Checks every app for releases. Returns False if any check failed."""
ok = True ok = True
@@ -1195,6 +1225,8 @@ def app_state():
folder = target_dir(port, mode) if mode != "auto" else None folder = target_dir(port, mode) if mode != "auto" else None
present = bool(folder and folder.exists()) present = bool(folder and folder.exists())
tag = installed.get(pid, {}).get("tag") if present else None 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: if present and not tag:
tag = "unknown" tag = "unknown"
if pid not in _latest: if pid not in _latest:
@@ -1412,8 +1444,13 @@ class Handler(BaseHTTPRequestHandler):
arg = lambda k: q.get(k, [""])[0] arg = lambda k: q.get(k, [""])[0]
pid = arg("id") pid = arg("id")
if url.path == "/api/refresh": if url.path == "/api/refresh":
_releases_cache.clear() # The headset app asks with launch=1 each time it opens: releases checked in the last 10 minutes count.
threading.Thread(target=refresh, kwargs={"force": True}, daemon=True).start() 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}) self.reply({"ok": True})
elif url.path == "/api/update_all": elif url.path == "/api/update_all":
ok = start(update_all, "_list") ok = start(update_all, "_list")
@@ -1487,7 +1524,7 @@ class Handler(BaseHTTPRequestHandler):
def checker(): def checker():
retry = 30 retry = 30
while True: while True:
if refresh(force=True): if check(force=True):
retry = 30 retry = 30
discover() discover()
time.sleep(max(1, float(config().get("check_hours", 6))) * 3600) time.sleep(max(1, float(config().get("check_hours", 6))) * 3600)
@@ -1501,6 +1538,13 @@ def checker():
def main(): def main():
ensure_secrets() ensure_secrets()
cfg = config() 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() threading.Thread(target=checker, daemon=True).start()
server = ThreadingHTTPServer(("0.0.0.0", int(cfg["port"])), Handler) server = ThreadingHTTPServer(("0.0.0.0", int(cfg["port"])), Handler)
server.daemon_threads = True server.daemon_threads = True
+2
View File
@@ -8,5 +8,7 @@ APPDIR="$1" OUT="$2"
rm -rf "$OUT" && mkdir -p "$OUT/tools" rm -rf "$OUT" && mkdir -p "$OUT/tools"
cp manager.py page.html catalog.json suggestions.json frametanium.service install.sh README.md "$OUT/" cp manager.py page.html catalog.json suggestions.json frametanium.service install.sh README.md "$OUT/"
cp tools/find_appid.py "$OUT/tools/" 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 art "$OUT/art"
cp -r "$APPDIR" "$OUT/app" cp -r "$APPDIR" "$OUT/app"