4 Commits
Author SHA1 Message Date
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
6 changed files with 277 additions and 80 deletions

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
# 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.
- 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-9 and 12-16 (2: update Frametanium last in update_all; 4: Frametanium's own row needs a VERSION file).
+4 -2
View File
@@ -71,8 +71,10 @@ the bundle (`tools/bundle.sh`).
Uninstall: `systemctl --user disable --now frametanium.service` and remove Frametanium from the library.
## Notes
- The service listens on port 8643 on your network. Set `password` in `~/.config/frametanium/config.json` to require
one in the browser (the headset app connects locally and doesn't need it).
- The service listens on port 8643 on your network, and every request needs a secret: the browser page asks for the
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
[font8x8](https://github.com/dhepper/font8x8).
- Testing on another architecture: `FRAMETANIUM_EXEC_WRAPPER=qemu-aarch64-static` (with `QEMU_LD_PREFIX`) runs
+40 -7
View File
@@ -134,8 +134,12 @@ static void fill(int x, int y, int w, int h, Uint32 rgb) {
/* ---------------------------------------------------------------- service */
static void read_port(void) {
char path[512], buf[2048];
/* The service's port and the key it wants from this app, from the service's config.json (readable only by this
* 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") : "");
FILE* f = fopen(path, "r");
if (!f) return;
@@ -147,10 +151,16 @@ static void read_port(void) {
int v = atoi(p + 1);
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. */
static char* http(const char* method, const char* path, int timeout_s) {
/* Sends a request to the service; returns the body (caller frees) or NULL if it isn't reachable. *status gets the
* 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);
if (s < 0) return NULL;
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;
}
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) {
close(s);
return NULL;
@@ -181,10 +193,23 @@ static char* http(const char* method, const char* path, int timeout_s) {
free(buf);
return NULL;
}
*status = 0;
sscanf(buf, "HTTP/%*s %d", status);
memmove(buf, body + 4, strlen(body + 4) + 1);
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. */
static void urlenc(char* out, size_t sz, const char* s) {
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). */
#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) {
char* body = http("GET", "/api/state?fmt=tsv", 5);
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");
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;
App* p = &apps[n++];
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;
}
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;
int first = scroll_first(sel, max_rows);
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;
}
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);
}
@@ -760,7 +793,7 @@ int main(void) {
if (!ren) return 1;
SDL_ShowCursor(SDL_DISABLE);
SDL_StopTextInput();
read_port();
read_config();
static App apps[MAX_APPS];
static Release rels[MAX_RELEASES];
+19
View File
@@ -18,6 +18,25 @@
"mode": "game",
"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",
"name": "Majora's Mask (2 Ship 2 Harkinian)",
+170 -60
View File
@@ -13,10 +13,14 @@ Control it from the headset app (app/) or from a browser at http://<frame>:8643.
"""
import fnmatch
import hmac
import ipaddress
import json
import os
import re
import secrets
import shutil
import socket
import stat
import struct
import subprocess
@@ -46,7 +50,9 @@ STEAM_USERDATA = HOME / ".local/share/Steam/userdata"
DEFAULT_CONFIG = {
"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)
"compat_tool": "SteamLinuxRuntime_4-arm64",
"check_hours": 6,
@@ -57,28 +63,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_.-]+$")
# 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):
try:
with open(path) as 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
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)
tmp = path.with_suffix(".tmp")
with open(tmp, "w") as f:
tmp = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
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)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
def config():
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():
"""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."""
@@ -98,13 +154,14 @@ def catalog():
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."""
entries = load_json(USER_CATALOG, [])
entry = next((e for e in entries if e.get("id") == pid), None)
if entry is None:
entry = {"id": pid}
entries.append(entry)
entry.update(fields)
save_json(USER_CATALOG, entries)
with files_lock:
entries = load_json(USER_CATALOG, [])
entry = next((e for e in entries if e.get("id") == pid), None)
if entry is None:
entry = {"id": pid}
entries.append(entry)
entry.update(fields)
save_json(USER_CATALOG, entries)
def suggestions():
@@ -119,8 +176,9 @@ def unhide_builtin(repo):
port = next((p for p in load_json(APP_DIR / "catalog.json", []) if p["repo"].lower() == repo.lower()), None)
if port is None:
return None
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"))])
with files_lock:
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"]
@@ -334,14 +392,15 @@ def add_app(repo_text, asset=None, name=None, mode="auto", prereleases=None):
g, n = gameid, 2
while g in used:
g, n = f"{gameid}_{n}", n + 1
entries = load_json(USER_CATALOG, [])
entries.append({
"id": pid, "name": title, "repo": info["repo"], "asset": asset_glob(asset), "auto_asset": True,
"mode": mode if mode in ("auto", "game", "installer") else "auto",
"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)
with files_lock:
entries = load_json(USER_CATALOG, [])
entries.append({
"id": pid, "name": title, "repo": info["repo"], "asset": asset_glob(asset), "auto_asset": True,
"mode": mode if mode in ("auto", "game", "installer") else "auto",
"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)
_releases_cache.clear()
return pid
@@ -506,10 +565,11 @@ def discovered():
def discover_mark(repos, key):
prefs = load_json(DISCOVER_FILE, {"seen": [], "hidden": []})
have = {r.lower() for r in prefs[key]}
prefs[key] += [r for r in repos if r and r.lower() not in have]
save_json(DISCOVER_FILE, prefs)
with files_lock:
prefs = load_json(DISCOVER_FILE, {"seen": [], "hidden": []})
have = {r.lower() for r in prefs[key]}
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):
@@ -1002,10 +1062,11 @@ def install(pid, tag=None):
raise
message = f"Installed {rel['tag']}." + (f" {tail}" if tail else "")
installed = load_json(INSTALLED_FILE, {})
prev = installed.get(pid, {}).get("tag")
installed[pid] = {"tag": rel["tag"], "previous": prev, "time": int(time.time()), "mode": mode}
save_json(INSTALLED_FILE, installed)
with files_lock:
installed = load_json(INSTALLED_FILE, {})
prev = installed.get(pid, {}).get("tag")
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", "")))
if port.get("rom_hint") and port.get("data") and not any(data.glob("*.o2r")) and not any(data.glob("*.z64")):
@@ -1041,9 +1102,11 @@ def rollback(pid):
if mode == "installer":
jobs.set(pid, "installing", 1.0, "Running its installer")
run_installer(final, port)
installed[pid] = {"tag": cur.get("previous"), "previous": cur.get("tag"), "time": int(time.time()),
"mode": mode}
save_json(INSTALLED_FILE, installed)
with files_lock:
installed = load_json(INSTALLED_FILE, {})
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'}.")
except Exception as e:
jobs.set(pid, "error", 0.0, str(e))
@@ -1070,12 +1133,14 @@ def remove(pid, delete_files=True):
note = " Remove its tile from the library too: Manage > Remove non-Steam game."
shutil.rmtree(final, ignore_errors=True)
shutil.rmtree(final.parent / f".{final.name}.previous", ignore_errors=True)
installed.pop(pid, None)
save_json(INSTALLED_FILE, installed)
entries = [e for e in load_json(USER_CATALOG, []) if e.get("id") != pid]
if port.get("builtin"):
entries.append({"id": pid, "hidden": True})
save_json(USER_CATALOG, entries)
with files_lock:
installed = load_json(INSTALLED_FILE, {})
installed.pop(pid, None)
save_json(INSTALLED_FILE, installed)
entries = [e for e in load_json(USER_CATALOG, []) if e.get("id") != pid]
if port.get("builtin"):
entries.append({"id": pid, "hidden": True})
save_json(USER_CATALOG, entries)
jobs.state.pop(pid, None)
_latest.pop(pid, None)
jobs.set("_list", "done", 1.0, f"Removed {port['name']}.{note}")
@@ -1210,29 +1275,77 @@ class Handler(BaseHTTPRequestHandler):
else:
self.reply({"error": message}, code=code)
def allowed(self, q):
password = config().get("password") or ""
# The headset app talks to us over loopback and never needs the password.
if not password or self.client_address[0] == "127.0.0.1":
return True
return (self.headers.get("X-Key") or q.get("key", [""])[0]) == password
# Every request needs a secret in a header, from loopback too: the headset app sends the key it reads from
# config.json, the page the password. Pages from other sites can't set headers on requests here (and never get
# a CORS answer), so they can't use the API even from a browser on the Frame itself.
fail_lock = threading.Lock()
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):
url = urllib.parse.urlparse(self.path)
q = urllib.parse.parse_qs(url.query)
fmt = q.get("fmt", ["json"])[0]
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")
return
if not self.allowed(q):
self.reply({"error": "password"}, code=401)
role = self.check(fmt)
if role is None:
return
fmt = q.get("fmt", ["json"])[0]
arg = lambda k: q.get(k, [""])[0]
if url.path == "/api/state":
rows = app_state()
notice = jobs.state.get("_list", {})
if fmt == "tsv":
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",
"can_rollback", "repo", "mode", "prereleases", "builtin", "job"]),
"text/plain")
@@ -1293,10 +1406,9 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self):
url = urllib.parse.urlparse(self.path)
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]
if self.check(fmt) is None:
return
arg = lambda k: q.get(k, [""])[0]
pid = arg("id")
if url.path == "/api/refresh":
@@ -1361,10 +1473,10 @@ class Handler(BaseHTTPRequestHandler):
elif url.path == "/api/token":
length = int(self.headers.get("Content-Length") or 0)
token = self.rfile.read(min(length, 4096)).decode().strip()
cfg = load_json(CONFIG_FILE, {})
cfg["github_token"] = token
save_json(CONFIG_FILE, cfg)
os.chmod(CONFIG_FILE, 0o600)
with files_lock:
cfg = load_json(CONFIG_FILE, {})
cfg["github_token"] = token
save_json(CONFIG_FILE, cfg, private=True)
_releases_cache.clear()
threading.Thread(target=refresh, kwargs={"force": True}, daemon=True).start()
self.reply({"ok": True})
@@ -1387,10 +1499,8 @@ def checker():
def main():
ensure_secrets()
cfg = config()
if not CONFIG_FILE.exists():
save_json(CONFIG_FILE, cfg)
os.chmod(CONFIG_FILE, 0o600)
threading.Thread(target=checker, daemon=True).start()
server = ThreadingHTTPServer(("0.0.0.0", int(cfg["port"])), Handler)
server.daemon_threads = True
+20 -11
View File
@@ -99,19 +99,28 @@ 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);
// Every request carries the password (the Frametanium app in the headset shows it under its list).
async function call(path, opts = {}) {
const used = key;
const r = await fetch(path, { ...opts, headers: hdr() });
if (r.status === 403) {
$("notice").hidden = false;
$("notice").textContent = (await r.json()).error;
throw new Error("refused");
}
return r.json();
}
async function post(path, body) {
const r = await fetch(path, { method: "POST", headers: hdr(), body });
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
const typed = prompt((key ? "That password didn't work. " : "") +
"Frametanium password (it's under the list in the Frametanium app in the headset):");
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) {
const e = Object.assign(document.createElement(tag), props);
e.append(...kids);