mirror of
https://github.com/Cogential/frametanium.git
synced 2026-10-23 11:00:34 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e1564df68 | ||
|
|
80a4e62a49 | ||
|
|
6635ac1342 | ||
|
|
a3a2450a4d | ||
|
|
fbd86e26fd |
No files matched your search
@@ -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/'
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in new issue
Block a user