5 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
CogentialandClaude Opus 5.5 80a4e62a49 CLAUDE.md: auth and settings-file rules, and the review fixes still to do
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C3G4bF9bag9rHEh1Anvngi
2026-10-21 00:08:05 -04:00
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
CogentialandClaude Opus 5.5 a3a2450a4d Settings files: unique temp files, fsync, locked read-modify-writes, damaged files kept aside
Two writers no longer share one .tmp file or lose each other's changes, and a truncated catalog.json is
moved aside instead of being replaced by an empty list on the next save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C3G4bF9bag9rHEh1Anvngi
2026-10-21 00:05:47 -04:00
CogentandClaude Opus 5.5 fbd86e26fd Catalog: Ocarina of Time 3D (experimental SoH build with a stereoscopic 3D screen)
Same repo as SoH; its releases carry soh-steam-frame-3d-arm64.zip, so
each entry only sees its own builds. Shares SoH's data folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHDaz86aayqMULHsjByEmT
2026-10-11 13:25:23 -04:00
9 changed files with 347 additions and 91 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/'
+29
View File
@@ -0,0 +1,29 @@
# Frametanium: notes for Claude sessions
See README.md for what it does. Service: `manager.py` (stdlib Python, runs on the Frame). Headset app: `app/app.c`.
## Rules (from the 2026-10-20 review)
- **Auth:** every `/api/*` request needs a secret, loopback included. Headset app sends `X-Frametanium-Key` (`app_key`
from `~/.config/frametanium/config.json`), the page sends `X-Key` (the `password`). Both are generated by
`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
(`gcc $(sdl2-config --cflags) app/app.c $(sdl2-config --libs)`) and screenshot with
`SDL_VIDEODRIVER=dummy PM_SCREENSHOT=out.bmp PM_SCREEN=list`.
- Don't push, tag, release or deploy without the user's OK.
## Review fixes still to do (report: /data/cogent/reviews/2026-10-20/review-frametanium.md)
Done 2026-10-21: settings-file robustness (code-review 10, 11; security LOW 13), auth (security HIGH 1, 2, LOW 10, 12).
Left, in this order: security MEDIUM 3 (Discover installs run install.sh without a confirm step), 4 (no integrity
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, 3, 5-9 and 12-16 (2 and 4 were fixed with the self-update on 2026-10-21).
+11 -6
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.
@@ -71,8 +74,10 @@ the bundle (`tools/bundle.sh`).
Uninstall: `systemctl --user disable --now frametanium.service` and remove Frametanium from the library. Uninstall: `systemctl --user disable --now frametanium.service` and remove Frametanium from the library.
## Notes ## Notes
- The service listens on port 8643 on your network. Set `password` in `~/.config/frametanium/config.json` to require - The service listens on port 8643 on your network, and every request needs a secret: the browser page asks for the
one in the browser (the headset app connects locally and doesn't need it). password (made up on first start and shown under the list in the headset app; change `password` in
`~/.config/frametanium/config.json` if you like), and the headset app sends `app_key` from the same file (mode 600).
Requests addressed to unknown host names or sent from other sites' pages are refused.
- Standard-library Python only. The headset app is C + SDL2 with the public-domain - Standard-library Python only. The headset app is C + SDL2 with the public-domain
[font8x8](https://github.com/dhepper/font8x8). [font8x8](https://github.com/dhepper/font8x8).
- Testing on another architecture: `FRAMETANIUM_EXEC_WRAPPER=qemu-aarch64-static` (with `QEMU_LD_PREFIX`) runs - Testing on another architecture: `FRAMETANIUM_EXEC_WRAPPER=qemu-aarch64-static` (with `QEMU_LD_PREFIX`) runs
+45 -8
View File
@@ -134,8 +134,12 @@ static void fill(int x, int y, int w, int h, Uint32 rgb) {
/* ---------------------------------------------------------------- service */ /* ---------------------------------------------------------------- service */
static void read_port(void) { /* The service's port and the key it wants from this app, from the service's config.json (readable only by this
char path[512], buf[2048]; * user). It's read again when the service turns the key down, e.g. after the service made it on its first start. */
static char app_key[128];
static void read_config(void) {
char path[512], buf[4096];
snprintf(path, sizeof path, "%s/.config/frametanium/config.json", getenv("HOME") ? getenv("HOME") : ""); snprintf(path, sizeof path, "%s/.config/frametanium/config.json", getenv("HOME") ? getenv("HOME") : "");
FILE* f = fopen(path, "r"); FILE* f = fopen(path, "r");
if (!f) return; if (!f) return;
@@ -147,10 +151,16 @@ static void read_port(void) {
int v = atoi(p + 1); int v = atoi(p + 1);
if (v > 0 && v < 65536) service_port = v; if (v > 0 && v < 65536) service_port = v;
} }
p = strstr(buf, "\"app_key\"");
if (p && (p = strchr(p, ':')) && (p = strchr(p, '"'))) {
size_t k = strspn(p + 1, "0123456789abcdefABCDEF");
if (k < sizeof app_key && p[1 + k] == '"') snprintf(app_key, sizeof app_key, "%.*s", (int)k, p + 1);
}
} }
/* Sends a request to the service; returns the body (caller frees) or NULL if it isn't reachable. */ /* Sends a request to the service; returns the body (caller frees) or NULL if it isn't reachable. *status gets the
static char* http(const char* method, const char* path, int timeout_s) { * HTTP status. */
static char* http_once(const char* method, const char* path, int timeout_s, int* status) {
int s = socket(AF_INET, SOCK_STREAM, 0); int s = socket(AF_INET, SOCK_STREAM, 0);
if (s < 0) return NULL; if (s < 0) return NULL;
struct timeval tv = { .tv_sec = timeout_s }; struct timeval tv = { .tv_sec = timeout_s };
@@ -162,7 +172,9 @@ static char* http(const char* method, const char* path, int timeout_s) {
return NULL; return NULL;
} }
char req[2048]; char req[2048];
int n = snprintf(req, sizeof req, "%s %s HTTP/1.0\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n", method, path); int n = snprintf(req, sizeof req,
"%s %s HTTP/1.0\r\nHost: localhost\r\nX-Frametanium-Key: %s\r\nContent-Length: 0\r\n\r\n", method,
path, app_key);
if (n >= (int)sizeof req || write(s, req, n) != n) { if (n >= (int)sizeof req || write(s, req, n) != n) {
close(s); close(s);
return NULL; return NULL;
@@ -181,10 +193,23 @@ static char* http(const char* method, const char* path, int timeout_s) {
free(buf); free(buf);
return NULL; return NULL;
} }
*status = 0;
sscanf(buf, "HTTP/%*s %d", status);
memmove(buf, body + 4, strlen(body + 4) + 1); memmove(buf, body + 4, strlen(body + 4) + 1);
return buf; return buf;
} }
static char* http(const char* method, const char* path, int timeout_s) {
int status = 0;
char* body = http_once(method, path, timeout_s, &status);
if (body && status == 401) { /* the key changed or was just made: read it again */
free(body);
read_config();
body = http_once(method, path, timeout_s, &status);
}
return body;
}
/* Percent-encodes s for a query string. */ /* Percent-encodes s for a query string. */
static void urlenc(char* out, size_t sz, const char* s) { static void urlenc(char* out, size_t sz, const char* s) {
static const char hex[] = "0123456789ABCDEF"; static const char hex[] = "0123456789ABCDEF";
@@ -221,6 +246,9 @@ static void copy(char* dst, size_t sz, const char* src) {
/* Lines of a reply, one at a time (strtok_r so callers can nest). */ /* Lines of a reply, one at a time (strtok_r so callers can nest). */
#define EACH_LINE(body, line, save) for (char* line = strtok_r(body, "\n", &save); line; line = strtok_r(NULL, "\n", &save)) #define EACH_LINE(body, line, save) for (char* line = strtok_r(body, "\n", &save); line; line = strtok_r(NULL, "\n", &save))
/* How to open the web page from a phone or PC, shown under the list. */
static char web_line[300];
static int load_apps(App* apps, char* notice, size_t nsz, int* notice_busy) { static int load_apps(App* apps, char* notice, size_t nsz, int* notice_busy) {
char* body = http("GET", "/api/state?fmt=tsv", 5); char* body = http("GET", "/api/state?fmt=tsv", 5);
if (!body) return -1; if (!body) return -1;
@@ -236,6 +264,10 @@ static int load_apps(App* apps, char* notice, size_t nsz, int* notice_busy) {
*notice_busy = !strcmp(f[1], "installing"); *notice_busy = !strcmp(f[1], "installing");
continue; continue;
} }
if (k >= 3 && !strcmp(f[0], "@")) {
snprintf(web_line, sizeof web_line, "From a phone or PC: %s password %s", f[1], f[2]);
continue;
}
if (k < 13 || n >= MAX_APPS) continue; if (k < 13 || n >= MAX_APPS) continue;
App* p = &apps[n++]; App* p = &apps[n++];
copy(p->id, sizeof p->id, f[0]); copy(p->id, sizeof p->id, f[0]);
@@ -488,7 +520,7 @@ static void draw_list(App* apps, int n, int sel, const char* banner, Uint32 bann
return; return;
} }
int row_h = 50 * S; int row_h = 50 * S;
int max_rows = (H - y - 40 * S) / row_h; int max_rows = (H - y - (web_line[0] ? 56 : 40) * S) / row_h;
if (max_rows < 1) max_rows = 1; if (max_rows < 1) max_rows = 1;
int first = scroll_first(sel, max_rows); int first = scroll_first(sel, max_rows);
for (int i = first; i <= n + 1 && i < first + max_rows; i++) { for (int i = first; i <= n + 1 && i < first + max_rows; i++) {
@@ -529,6 +561,7 @@ static void draw_list(App* apps, int n, int sel, const char* banner, Uint32 bann
y += row_h; y += row_h;
} }
legend("A Open Y Add X Discover Select Update all Start Check B Close"); legend("A Open Y Add X Discover Select Update all Start Check B Close");
if (web_line[0]) text_fit(web_line, m, H - H / 20 - 30 * S, S, W - 2 * m, MUTED);
SDL_RenderPresent(ren); SDL_RenderPresent(ren);
} }
@@ -760,7 +793,7 @@ int main(void) {
if (!ren) return 1; if (!ren) return 1;
SDL_ShowCursor(SDL_DISABLE); SDL_ShowCursor(SDL_DISABLE);
SDL_StopTextInput(); SDL_StopTextInput();
read_port(); read_config();
static App apps[MAX_APPS]; static App apps[MAX_APPS];
static Release rels[MAX_RELEASES]; static Release rels[MAX_RELEASES];
@@ -768,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;
@@ -830,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);
+19
View File
@@ -18,6 +18,25 @@
"mode": "game", "mode": "game",
"icon_url": "https://raw.githubusercontent.com/Cogential/Shipwright-frame/steam-frame/soh/macosx/sohIcon.png" "icon_url": "https://raw.githubusercontent.com/Cogential/Shipwright-frame/steam-frame/soh/macosx/sohIcon.png"
}, },
{
"id": "soh3d",
"name": "Ocarina of Time 3D (Ship of Harkinian, experimental)",
"repo": "Cogential/Shipwright-frame",
"asset": "soh-steam-frame-3d-arm64.zip",
"type": "zip",
"gameid": "Ocarina_of_Time_SoH_3D",
"launch": "run.sh",
"keep": [
"*.z64",
"*.n64",
"*.v64"
],
"data": "~/.local/share/soh",
"rom_hint": "Send your Ocarina of Time ROM with Frame Inbox, or put a .z64 in ~/.local/share/soh.",
"prereleases": true,
"mode": "game",
"icon_url": "https://raw.githubusercontent.com/Cogential/Shipwright-frame/steam-frame/soh/macosx/sohIcon.png"
},
{ {
"id": "2ship", "id": "2ship",
"name": "Majora's Mask (2 Ship 2 Harkinian)", "name": "Majora's Mask (2 Ship 2 Harkinian)",
+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
+219 -65
View File
@@ -13,10 +13,14 @@ Control it from the headset app (app/) or from a browser at http://<frame>:8643.
""" """
import fnmatch import fnmatch
import hmac
import ipaddress
import json import json
import os import os
import re import re
import secrets
import shutil import shutil
import socket
import stat import stat
import struct import struct
import subprocess import subprocess
@@ -46,10 +50,13 @@ STEAM_USERDATA = HOME / ".local/share/Steam/userdata"
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
"port": 8643, "port": 8643,
"password": "", # for the web page; empty means anyone on the network can use it "password": "", # for the web page; made up on first start when empty (the headset app shows it)
"app_key": "", # for the headset app, which reads it from this file; made up on first start
"allowed_hosts": [], # extra names the page may be opened by (IP addresses, .local and plain names always work)
"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
@@ -57,28 +64,78 @@ INSTALLERS = ["install.sh", "contrib/install.sh", "scripts/install.sh", "setup.s
REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
# Held around every read-modify-write of the settings files, which the web handlers and install threads share.
files_lock = threading.RLock()
def load_json(path, default): def load_json(path, default):
try: try:
with open(path) as f: with open(path) as f:
return json.load(f) return json.load(f)
except (OSError, ValueError): except FileNotFoundError:
return default
except (OSError, ValueError) as e:
# A damaged file (e.g. power lost mid-write) is kept aside rather than overwritten by the next save.
if isinstance(e, ValueError):
aside = path.with_name(f"{path.name}.damaged-{int(time.time())}")
try:
os.replace(path, aside)
print(f"{path} couldn't be read; kept it as {aside}", file=sys.stderr, flush=True)
except OSError:
pass
return default return default
def save_json(path, data): def save_json(path, data, private=False):
"""Writes atomically: a temp file of its own (never readable by others when private), synced, then renamed."""
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp") tmp = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
with open(tmp, "w") as f: fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600 if private else 0o644)
with os.fdopen(fd, "w") as f:
json.dump(data, f, indent=2) json.dump(data, f, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path) os.replace(tmp, path)
def config(): def config():
return {**DEFAULT_CONFIG, **load_json(CONFIG_FILE, {})} return {**DEFAULT_CONFIG, **load_json(CONFIG_FILE, {})}
PASSWORD_LETTERS = "abcdefghjkmnpqrstuvwxyz23456789" # nothing that's easy to misread
def ensure_secrets():
"""Makes up the headset app's key and the web page's password the first time (also on installs from before
they existed, which had no password), and keeps config.json readable only by its owner."""
with files_lock:
cfg = load_json(CONFIG_FILE, {})
changed = not CONFIG_FILE.exists()
if not cfg.get("app_key"):
cfg["app_key"] = secrets.token_hex(32)
changed = True
if not cfg.get("password"):
cfg["password"] = "-".join("".join(secrets.choice(PASSWORD_LETTERS) for _ in range(4)) for _ in range(3))
changed = True
if changed:
save_json(CONFIG_FILE, {**DEFAULT_CONFIG, **cfg}, private=True)
for path, mode in ((CONFIG_DIR, 0o700), (CONFIG_FILE, 0o600)):
try:
os.chmod(path, mode)
except OSError:
pass
def lan_address():
"""This machine's address on the local network (no packets are sent), or its hostname."""
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("192.0.2.1", 9))
return s.getsockname()[0]
except OSError:
return socket.gethostname()
def catalog(): def catalog():
"""The built-in ports plus the apps the user added. A user entry with the id of a built-in one changes it, and """The built-in ports plus the apps the user added. A user entry with the id of a built-in one changes it, and
{"id": ..., "hidden": true} takes a built-in one off the list.""" {"id": ..., "hidden": true} takes a built-in one off the list."""
@@ -98,13 +155,14 @@ def catalog():
def update_user_entry(pid, **fields): def update_user_entry(pid, **fields):
"""Changes (or with remove=True deletes) the user's entry for an app, creating one for a built-in app.""" """Changes (or with remove=True deletes) the user's entry for an app, creating one for a built-in app."""
entries = load_json(USER_CATALOG, []) with files_lock:
entry = next((e for e in entries if e.get("id") == pid), None) entries = load_json(USER_CATALOG, [])
if entry is None: entry = next((e for e in entries if e.get("id") == pid), None)
entry = {"id": pid} if entry is None:
entries.append(entry) entry = {"id": pid}
entry.update(fields) entries.append(entry)
save_json(USER_CATALOG, entries) entry.update(fields)
save_json(USER_CATALOG, entries)
def suggestions(): def suggestions():
@@ -119,8 +177,9 @@ def unhide_builtin(repo):
port = next((p for p in load_json(APP_DIR / "catalog.json", []) if p["repo"].lower() == repo.lower()), None) port = next((p for p in load_json(APP_DIR / "catalog.json", []) if p["repo"].lower() == repo.lower()), None)
if port is None: if port is None:
return None return None
entries = load_json(USER_CATALOG, []) with files_lock:
save_json(USER_CATALOG, [e for e in entries if not (e.get("id") == port["id"] and e.get("hidden"))]) entries = load_json(USER_CATALOG, [])
save_json(USER_CATALOG, [e for e in entries if not (e.get("id") == port["id"] and e.get("hidden"))])
return port["id"] return port["id"]
@@ -334,14 +393,15 @@ def add_app(repo_text, asset=None, name=None, mode="auto", prereleases=None):
g, n = gameid, 2 g, n = gameid, 2
while g in used: while g in used:
g, n = f"{gameid}_{n}", n + 1 g, n = f"{gameid}_{n}", n + 1
entries = load_json(USER_CATALOG, []) with files_lock:
entries.append({ entries = load_json(USER_CATALOG, [])
"id": pid, "name": title, "repo": info["repo"], "asset": asset_glob(asset), "auto_asset": True, entries.append({
"mode": mode if mode in ("auto", "game", "installer") else "auto", "id": pid, "name": title, "repo": info["repo"], "asset": asset_glob(asset), "auto_asset": True,
"prereleases": info["prereleases"] if prereleases is None else bool(prereleases), "mode": mode if mode in ("auto", "game", "installer") else "auto",
"gameid": g, "description": info["description"][:300], "added": int(time.time()), "prereleases": info["prereleases"] if prereleases is None else bool(prereleases),
}) "gameid": g, "description": info["description"][:300], "added": int(time.time()),
save_json(USER_CATALOG, entries) })
save_json(USER_CATALOG, entries)
_releases_cache.clear() _releases_cache.clear()
return pid return pid
@@ -506,10 +566,11 @@ def discovered():
def discover_mark(repos, key): def discover_mark(repos, key):
prefs = load_json(DISCOVER_FILE, {"seen": [], "hidden": []}) with files_lock:
have = {r.lower() for r in prefs[key]} prefs = load_json(DISCOVER_FILE, {"seen": [], "hidden": []})
prefs[key] += [r for r in repos if r and r.lower() not in have] have = {r.lower() for r in prefs[key]}
save_json(DISCOVER_FILE, prefs) prefs[key] += [r for r in repos if r and r.lower() not in have]
save_json(DISCOVER_FILE, prefs)
def start_discover(force=True): def start_discover(force=True):
@@ -1002,10 +1063,11 @@ def install(pid, tag=None):
raise raise
message = f"Installed {rel['tag']}." + (f" {tail}" if tail else "") message = f"Installed {rel['tag']}." + (f" {tail}" if tail else "")
installed = load_json(INSTALLED_FILE, {}) with files_lock:
prev = installed.get(pid, {}).get("tag") installed = load_json(INSTALLED_FILE, {})
installed[pid] = {"tag": rel["tag"], "previous": prev, "time": int(time.time()), "mode": mode} prev = installed.get(pid, {}).get("tag")
save_json(INSTALLED_FILE, installed) installed[pid] = {"tag": rel["tag"], "previous": prev, "time": int(time.time()), "mode": mode}
save_json(INSTALLED_FILE, installed)
data = Path(os.path.expanduser(port.get("data", ""))) data = Path(os.path.expanduser(port.get("data", "")))
if port.get("rom_hint") and port.get("data") and not any(data.glob("*.o2r")) and not any(data.glob("*.z64")): if port.get("rom_hint") and port.get("data") and not any(data.glob("*.o2r")) and not any(data.glob("*.z64")):
@@ -1041,9 +1103,11 @@ def rollback(pid):
if mode == "installer": if mode == "installer":
jobs.set(pid, "installing", 1.0, "Running its installer") jobs.set(pid, "installing", 1.0, "Running its installer")
run_installer(final, port) run_installer(final, port)
installed[pid] = {"tag": cur.get("previous"), "previous": cur.get("tag"), "time": int(time.time()), with files_lock:
"mode": mode} installed = load_json(INSTALLED_FILE, {})
save_json(INSTALLED_FILE, installed) installed[pid] = {"tag": cur.get("previous"), "previous": cur.get("tag"), "time": int(time.time()),
"mode": mode}
save_json(INSTALLED_FILE, installed)
jobs.set(pid, "done", 1.0, f"Back to {cur.get('previous') or 'the previous version'}.") jobs.set(pid, "done", 1.0, f"Back to {cur.get('previous') or 'the previous version'}.")
except Exception as e: except Exception as e:
jobs.set(pid, "error", 0.0, str(e)) jobs.set(pid, "error", 0.0, str(e))
@@ -1070,12 +1134,14 @@ def remove(pid, delete_files=True):
note = " Remove its tile from the library too: Manage > Remove non-Steam game." note = " Remove its tile from the library too: Manage > Remove non-Steam game."
shutil.rmtree(final, ignore_errors=True) shutil.rmtree(final, ignore_errors=True)
shutil.rmtree(final.parent / f".{final.name}.previous", ignore_errors=True) shutil.rmtree(final.parent / f".{final.name}.previous", ignore_errors=True)
installed.pop(pid, None) with files_lock:
save_json(INSTALLED_FILE, installed) installed = load_json(INSTALLED_FILE, {})
entries = [e for e in load_json(USER_CATALOG, []) if e.get("id") != pid] installed.pop(pid, None)
if port.get("builtin"): save_json(INSTALLED_FILE, installed)
entries.append({"id": pid, "hidden": True}) entries = [e for e in load_json(USER_CATALOG, []) if e.get("id") != pid]
save_json(USER_CATALOG, entries) if port.get("builtin"):
entries.append({"id": pid, "hidden": True})
save_json(USER_CATALOG, entries)
jobs.state.pop(pid, None) jobs.state.pop(pid, None)
_latest.pop(pid, None) _latest.pop(pid, None)
jobs.set("_list", "done", 1.0, f"Removed {port['name']}.{note}") jobs.set("_list", "done", 1.0, f"Removed {port['name']}.{note}")
@@ -1084,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
@@ -1110,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
@@ -1130,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:
@@ -1210,29 +1307,77 @@ class Handler(BaseHTTPRequestHandler):
else: else:
self.reply({"error": message}, code=code) self.reply({"error": message}, code=code)
def allowed(self, q): # Every request needs a secret in a header, from loopback too: the headset app sends the key it reads from
password = config().get("password") or "" # config.json, the page the password. Pages from other sites can't set headers on requests here (and never get
# The headset app talks to us over loopback and never needs the password. # a CORS answer), so they can't use the API even from a browser on the Frame itself.
if not password or self.client_address[0] == "127.0.0.1": fail_lock = threading.Lock()
return True
return (self.headers.get("X-Key") or q.get("key", [""])[0]) == password def host_ok(self):
"""Refuses requests addressed to a name that isn't this machine's (DNS rebinding) and requests a page
from another site sent."""
host = (self.headers.get("Host") or "").strip().lower()
name = urllib.parse.urlsplit("//" + host).hostname or ""
try:
ipaddress.ip_address(name)
ok = True
except ValueError:
ok = name == "localhost" or name.endswith(".local") or ("." not in name and bool(name)) \
or name == socket.gethostname().lower() \
or name in {h.lower() for h in config().get("allowed_hosts") or []}
origin = self.headers.get("Origin")
if ok and origin is not None and urllib.parse.urlsplit(origin).netloc.lower() != host:
ok = False
return ok
def who(self):
""""app" for the headset app, "web" for the page with the right password, otherwise None."""
cfg = config()
def same(given, want):
return bool(given and isinstance(want, str) and want) and hmac.compare_digest(given.encode(), want.encode())
if same(self.headers.get("X-Frametanium-Key"), cfg.get("app_key")):
return "app"
if same(self.headers.get("X-Key"), cfg.get("password")):
return "web"
return None
def check(self, fmt):
"""Whether the request may go on; answers it if not."""
if not self.host_ok():
self.reply({"error": "Open Frametanium by the Frame's address (its IP or name.local)."}, code=403)
return None
role = self.who()
if role is None:
with Handler.fail_lock: # one wrong guess a second
time.sleep(1)
# Headset apps from before the key show this "#" line as a notice.
msg = "Frametanium was updated: close this app and open it again."
self.reply(f"#\terror\t{msg}\n!\t{msg}\n" if fmt == "tsv" else {"error": "password"},
"text/plain" if fmt == "tsv" else "application/json", code=401)
return role
def do_GET(self): def do_GET(self):
url = urllib.parse.urlparse(self.path) url = urllib.parse.urlparse(self.path)
q = urllib.parse.parse_qs(url.query) q = urllib.parse.parse_qs(url.query)
fmt = q.get("fmt", ["json"])[0]
if url.path == "/": if url.path == "/":
if not self.host_ok():
self.reply("Open Frametanium by the Frame's address (its IP or name.local).", "text/plain", 403)
return
self.reply((APP_DIR / "page.html").read_bytes(), "text/html") self.reply((APP_DIR / "page.html").read_bytes(), "text/html")
return return
if not self.allowed(q): role = self.check(fmt)
self.reply({"error": "password"}, code=401) if role is None:
return return
fmt = q.get("fmt", ["json"])[0]
arg = lambda k: q.get(k, [""])[0] arg = lambda k: q.get(k, [""])[0]
if url.path == "/api/state": if url.path == "/api/state":
rows = app_state() rows = app_state()
notice = jobs.state.get("_list", {}) notice = jobs.state.get("_list", {})
if fmt == "tsv": if fmt == "tsv":
head = f"#\t{notice.get('status', '')}\t{notice.get('message', '')}\n" if notice else "" head = f"#\t{notice.get('status', '')}\t{notice.get('message', '')}\n" if notice else ""
if role == "app": # the headset app shows how to open the page in a browser
cfg = config()
head += f"@\thttp://{lan_address()}:{cfg['port']}\t{cfg['password']}\n"
self.reply(head + tsv(rows, ["id", "name", "installed", "latest", "status", "progress", "message", self.reply(head + tsv(rows, ["id", "name", "installed", "latest", "status", "progress", "message",
"can_rollback", "repo", "mode", "prereleases", "builtin", "job"]), "can_rollback", "repo", "mode", "prereleases", "builtin", "job"]),
"text/plain") "text/plain")
@@ -1293,15 +1438,19 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
url = urllib.parse.urlparse(self.path) url = urllib.parse.urlparse(self.path)
q = urllib.parse.parse_qs(url.query) q = urllib.parse.parse_qs(url.query)
if not self.allowed(q):
self.reply({"error": "password"}, code=401)
return
fmt = q.get("fmt", ["json"])[0] fmt = q.get("fmt", ["json"])[0]
if self.check(fmt) is None:
return
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")
@@ -1361,10 +1510,10 @@ class Handler(BaseHTTPRequestHandler):
elif url.path == "/api/token": elif url.path == "/api/token":
length = int(self.headers.get("Content-Length") or 0) length = int(self.headers.get("Content-Length") or 0)
token = self.rfile.read(min(length, 4096)).decode().strip() token = self.rfile.read(min(length, 4096)).decode().strip()
cfg = load_json(CONFIG_FILE, {}) with files_lock:
cfg["github_token"] = token cfg = load_json(CONFIG_FILE, {})
save_json(CONFIG_FILE, cfg) cfg["github_token"] = token
os.chmod(CONFIG_FILE, 0o600) save_json(CONFIG_FILE, cfg, private=True)
_releases_cache.clear() _releases_cache.clear()
threading.Thread(target=refresh, kwargs={"force": True}, daemon=True).start() threading.Thread(target=refresh, kwargs={"force": True}, daemon=True).start()
self.reply({"ok": True}) self.reply({"ok": True})
@@ -1375,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)
@@ -1387,10 +1536,15 @@ def checker():
def main(): def main():
ensure_secrets()
cfg = config() cfg = config()
if not CONFIG_FILE.exists(): version = own_version()
save_json(CONFIG_FILE, cfg) if version and version != cfg.get("last_version"):
os.chmod(CONFIG_FILE, 0o600) 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
+20 -11
View File
@@ -99,19 +99,28 @@ const hdr = () => key ? { "X-Key": key } : {};
const releases = {}; const releases = {};
const open = {}; // app ids whose Edit section is open, kept across redraws const open = {}; // app ids whose Edit section is open, kept across redraws
async function get(path) { // Every request carries the password (the Frametanium app in the headset shows it under its list).
const r = await fetch(path, { headers: hdr() }); async function call(path, opts = {}) {
if (r.status === 401) { const used = key;
key = prompt("Frametanium password") || ""; const r = await fetch(path, { ...opts, headers: hdr() });
try { localStorage.setItem("pmKey", key); } catch (e) {} if (r.status === 403) {
return get(path); $("notice").hidden = false;
$("notice").textContent = (await r.json()).error;
throw new Error("refused");
} }
return r.json(); if (r.status !== 401) return r.json();
} if (key === used) { // ask once, not once per request that was already on its way
async function post(path, body) { const typed = prompt((key ? "That password didn't work. " : "") +
const r = await fetch(path, { method: "POST", headers: hdr(), body }); "Frametanium password (it's under the list in the Frametanium app in the headset):");
return r.json(); 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) { function el(tag, props = {}, ...kids) {
const e = Object.assign(document.createElement(tag), props); const e = Object.assign(document.createElement(tag), props);
e.append(...kids); e.append(...kids);
+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"