diff --git a/README.md b/README.md index 6bd1de7..1d908cb 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,40 @@ # Port Manager for the Steam Frame -Installs and updates native Steam Frame ports straight from their GitHub releases, from the headset or a browser. +Installs and updates Steam Frame apps and ports straight from their GitHub releases, like Obtainium: add any repo, +and Port Manager keeps it up to date. Works from the headset or a browser. -- **Install, update, or pick any version** of each port in the catalog. Pre-releases count. -- **Keeps your stuff:** saves live outside the install folder, and ROMs placed next to `run.sh` are carried over. -- **One-step rollback** to the version you had before (the previous install is kept). -- Fixes executable bits, unpacks AppImages (no FUSE needed), writes a launcher when a port needs one, - registers the game with Steam (`steam-client-create-shortcut`, Steam Linux Runtime 4 arm64) and adds library art. -- Won't touch a game that's running. +- **Add any GitHub repo** by typing `owner/name` (or pasting a link) on the headset's on-screen keyboard, from the + browser page, or by picking a suggestion made for the Frame. The built-in ports (Ship of Harkinian, 2 Ship 2 + Harkinian) are there from the start. +- **Picks the right download:** ARM64 Linux builds (`aarch64`/`arm64`, "steamframe"); checksums, Windows, macOS, + Android and x86 files are passed over. You can choose another one when adding. Zips, tarballs (gz/xz/bz2/zst), + AppImages and single programs all work. +- **Two ways to install,** picked from the download unless you choose: + - *Library app:* unpacked into `~/devkit-game/`, given a `run.sh` (its own, or one that starts the ARM64 + program it finds), added to the Steam library with art (the repo's GitHub card and the owner's avatar). + - *Its installer:* when the download has `install.sh` (or `contrib/install.sh`, `setup.sh`), it's unpacked into + `~/.local/share/port-manager/apps/` and that script runs, for tools such as SteamVR overlays. Removing the app + runs its uninstaller when it has one. +- **Install, update, update all, or pick any version.** Pre-releases are skipped unless you turn them on for an app + (they're on for apps that only have pre-releases). +- **Keeps your stuff:** saves live outside the install folder, ROMs placed next to `run.sh` are carried over, and + the previous install is kept for a **one-step rollback**. +- Won't touch an app that's running. Only add apps you trust: their programs and installers run as your user. ## Use it -- **In the headset:** open **Port Manager** in the library. D-pad to choose, **A** install/update, **Y** choose a - version, **X** previous version, **Start** check for updates, **B** close. -- **From a phone or PC:** `http://:8643`. +- **In the headset:** open **Port Manager** in the library. D-pad to choose, **A** open an app (install/update, + versions, previous version, pre-releases, remove), **Y** add an app, **Select** update all, **Start** check for + updates, **B** back/close. +- **From a phone or PC:** `http://:8643` (add apps, edit name/install style/program, remove). Updates are checked every 6 hours and on demand. -## Catalog -`catalog.json` lists the built-in ports: - -| Port | Repo | Download | -|---|---|---| -| Ocarina of Time (Ship of Harkinian) | Cogential/Shipwright-frame | `soh-steam-frame-arm64.zip` | -| Majora's Mask (2 Ship 2 Harkinian) | Cogential/2ship2harkinian-frame | `*arm64.appimage` (unpacked, launcher written) | - -Add your own in `~/.config/port-manager/catalog.json` on the Frame (same fields; the same `id` overrides a built-in -entry). A port's release needs a zip whose top folder holds `run.sh`, or an AppImage plus a `run_sh` launcher in the -catalog. +## Files +- `catalog.json`: the built-in ports. +- `suggestions.json`: apps offered on the Add screen. +- `~/.config/port-manager/catalog.json` on the Frame: the apps you added and changes to built-in ones + (`{"id": "soh", "hidden": true}` hides one; adding its repo again brings it back). +- `~/.config/port-manager/installed.json`: installed versions. ## Private repos GitHub only shows releases of private repos to signed-in users. Either make the port repos public (they contain no diff --git a/app/app.c b/app/app.c index f64505e..82eac76 100644 --- a/app/app.c +++ b/app/app.c @@ -2,8 +2,12 @@ * does the downloading and installing. It talks to the service on 127.0.0.1 with plain HTTP and reads simple * tab-separated replies. * - * Controls: D-pad/left stick to choose, A install or update, Y choose a version, X go back to the previous - * version, Start check for updates, B close (or leave the version list). Keyboard: arrows, Enter, V, P, R, Esc. */ + * Screens: the app list; an app's menu (install/update, versions, previous version, pre-releases, remove); adding + * an app (type a GitHub repo on an on-screen keyboard, paste a link, or pick a suggestion); and the found repo, + * where you pick its download and add it. + * + * Controls: D-pad/left stick to choose, A select, B back/close, Y add an app, Select update all, Start check for + * updates. Keyboard: arrows, Enter, Esc, and typing on the on-screen keyboard screen. */ #include #include @@ -20,20 +24,34 @@ #include "third_party/font8x8_basic.h" #define DEFAULT_PORT 8643 -#define MAX_PORTS 32 +#define MAX_APPS 64 #define MAX_RELEASES 40 +#define MAX_SUGGEST 32 +#define MAX_ASSETS 16 typedef struct { - char id[64], name[128], installed[64], latest[64], status[32], message[256]; + char id[64], name[128], installed[64], latest[64], status[32], message[256], repo[128], mode[16], job[16]; float progress; - int can_rollback; -} Port; + int can_rollback, prereleases, builtin; +} App; typedef struct { char tag[64], name[128], date[16]; int prerelease; } Release; +typedef struct { + char repo[128], name[128], note[160]; +} Suggestion; + +typedef struct { + char repo[128], name[128], description[300], tag[64], date[16], existing[64]; + int prereleases, nassets, asset; + char assets[MAX_ASSETS][128]; + int arm64[MAX_ASSETS]; + long sizes[MAX_ASSETS]; +} Found; + static SDL_Renderer* ren; static int W, H, S; /* screen size and text scale */ static int service_port = DEFAULT_PORT; @@ -50,7 +68,7 @@ static int text(const char* s, int x, int y, int scale, Uint32 rgb) { color(rgb); for (; *s; s++) { unsigned char c = (unsigned char)*s; - if (c >= 128) c = '?'; + if (c >= 128) continue; /* the font is ASCII only: leave other characters out */ for (int row = 0; row < 8; row++) { unsigned char bits = (unsigned char)font8x8_basic[c][row]; for (int col = 0; col < 8; col++) @@ -70,7 +88,7 @@ static int text(const char* s, int x, int y, int scale, Uint32 rgb) { /* Draws s cut to max_px (ending in "..." when cut). */ static void text_fit(const char* s, int x, int y, int scale, int max_px, Uint32 rgb) { - char buf[300]; + char buf[400]; int max_chars = max_px / (8 * scale); snprintf(buf, sizeof buf, "%s", s); if ((int)strlen(buf) > max_chars && max_chars > 3) { @@ -80,6 +98,29 @@ static void text_fit(const char* s, int x, int y, int scale, int max_px, Uint32 text(buf, x, y, scale, rgb); } +/* Draws s word-wrapped into lines of max_px, at most max_lines; returns the height used. */ +static int text_wrap(const char* s, int x, int y, int scale, int max_px, int max_lines, Uint32 rgb) { + int per = max_px / (8 * scale), lines = 0; + const char* p = s; + while (*p && lines < max_lines) { + int len = (int)strlen(p); + int take = len; + if (len > per) { + take = per; + while (take > 0 && p[take] != ' ') take--; + if (take == 0) take = per; + } + char buf[400]; + snprintf(buf, sizeof buf, "%.*s", take, p); + if (lines == max_lines - 1 && len > take && take > 3) strcpy(buf + take - 3, "..."); + text(buf, x, y + lines * 12 * scale, scale, rgb); + p += take; + while (*p == ' ') p++; + lines++; + } + return lines * 12 * scale; +} + static void fill(int x, int y, int w, int h, Uint32 rgb) { color(rgb); SDL_RenderFillRect(ren, &(SDL_Rect){ x, y, w, h }); @@ -103,10 +144,10 @@ static void read_port(void) { } /* 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) { +static char* http(const char* method, const char* path, int timeout_s) { int s = socket(AF_INET, SOCK_STREAM, 0); if (s < 0) return NULL; - struct timeval tv = { .tv_sec = 5 }; + struct timeval tv = { .tv_sec = timeout_s }; setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons(service_port) }; a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); @@ -114,9 +155,9 @@ static char* http(const char* method, const char* path) { close(s); return NULL; } - char req[1024]; + 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); - if (write(s, req, n) != n) { + if (n >= (int)sizeof req || write(s, req, n) != n) { close(s); return NULL; } @@ -138,6 +179,23 @@ static char* http(const char* method, const char* path) { return buf; } +/* Percent-encodes s for a query string. */ +static void urlenc(char* out, size_t sz, const char* s) { + static const char hex[] = "0123456789ABCDEF"; + size_t o = 0; + for (; *s && o + 4 < sz; s++) { + unsigned char c = (unsigned char)*s; + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || strchr("-_.~", c)) { + out[o++] = c; + } else { + out[o++] = '%'; + out[o++] = hex[c >> 4]; + out[o++] = hex[c & 15]; + } + } + out[o] = '\0'; +} + /* Splits one tab-separated line into fields (in place). */ static int split(char* line, char** f, int max) { int n = 0; @@ -154,14 +212,26 @@ static void copy(char* dst, size_t sz, const char* src) { snprintf(dst, sz, "%s", strcmp(src, "-") == 0 ? "" : src); } -static int load_ports(Port* ports) { - char* body = http("GET", "/api/state?fmt=tsv"); +/* 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)) + +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; int n = 0; - for (char* line = strtok(body, "\n"); line && n < MAX_PORTS; line = strtok(NULL, "\n")) { - char* f[8]; - if (split(line, f, 8) < 8) continue; - Port* p = &ports[n++]; + char* save; + notice[0] = '\0'; + *notice_busy = 0; + EACH_LINE(body, line, save) { + char* f[13]; + int k = split(line, f, 13); + if (k >= 3 && !strcmp(f[0], "#")) { + copy(notice, nsz, f[2]); + *notice_busy = !strcmp(f[1], "installing"); + continue; + } + if (k < 13 || n >= MAX_APPS) continue; + App* p = &apps[n++]; copy(p->id, sizeof p->id, f[0]); copy(p->name, sizeof p->name, f[1]); copy(p->installed, sizeof p->installed, f[2]); @@ -170,6 +240,11 @@ static int load_ports(Port* ports) { p->progress = (float)atof(f[5]); copy(p->message, sizeof p->message, f[6]); p->can_rollback = atoi(f[7]); + copy(p->repo, sizeof p->repo, f[8]); + copy(p->mode, sizeof p->mode, f[9]); + p->prereleases = atoi(f[10]); + p->builtin = atoi(f[11]); + copy(p->job, sizeof p->job, f[12]); } free(body); return n; @@ -178,21 +253,22 @@ static int load_ports(Port* ports) { static int load_releases(const char* id, Release* rels, char* err, size_t errsz) { char path[256]; snprintf(path, sizeof path, "/api/releases?fmt=tsv&id=%s", id); - char* body = http("GET", path); + char* body = http("GET", path, 25); err[0] = '\0'; if (!body) { snprintf(err, errsz, "Port Manager service isn't running."); return 0; } int n = 0; - for (char* line = strtok(body, "\n"); line && n < MAX_RELEASES; line = strtok(NULL, "\n")) { + char* save; + EACH_LINE(body, line, save) { char* f[4]; int k = split(line, f, 4); if (k >= 2 && strcmp(f[0], "!") == 0) { snprintf(err, errsz, "%s", f[1]); break; } - if (k < 4) continue; + if (k < 4 || n >= MAX_RELEASES) continue; Release* r = &rels[n++]; copy(r->tag, sizeof r->tag, f[0]); copy(r->name, sizeof r->name, f[1]); @@ -200,16 +276,92 @@ static int load_releases(const char* id, Release* rels, char* err, size_t errsz) r->prerelease = atoi(f[3]); } free(body); + if (n == 0 && !err[0]) snprintf(err, errsz, "No versions with a download for the Frame."); return n; } -static void action(const char* what, const char* id, const char* tag) { - char path[256]; - if (tag) - snprintf(path, sizeof path, "/api/%s?id=%s&tag=%s", what, id, tag); - else - snprintf(path, sizeof path, "/api/%s?id=%s", what, id); - free(http("POST", path)); +static int load_suggestions(Suggestion* sug) { + char* body = http("GET", "/api/suggestions?fmt=tsv", 5); + if (!body) return 0; + int n = 0; + char* save; + EACH_LINE(body, line, save) { + char* f[4]; + if (split(line, f, 4) < 4 || n >= MAX_SUGGEST || atoi(f[3])) continue; + Suggestion* s = &sug[n++]; + copy(s->repo, sizeof s->repo, f[0]); + copy(s->name, sizeof s->name, f[1]); + copy(s->note, sizeof s->note, f[2]); + } + free(body); + return n; +} + +/* Looks a repo up; fills fd and returns 1, or writes the problem to err and returns 0. */ +static int inspect(const char* repo, Found* fd, char* err, size_t errsz) { + char enc[600], path[700]; + urlenc(enc, sizeof enc, repo); + snprintf(path, sizeof path, "/api/inspect?fmt=tsv&repo=%s", enc); + char* body = http("GET", path, 30); + memset(fd, 0, sizeof *fd); + err[0] = '\0'; + if (!body) { + snprintf(err, errsz, "The Port Manager service didn't answer."); + return 0; + } + char* save; + int ok = 0; + EACH_LINE(body, line, save) { + char* f[9]; + int k = split(line, f, 9); + if (k >= 2 && !strcmp(f[0], "!")) { + snprintf(err, errsz, "%s", f[1]); + break; + } + if (k >= 8 && !strcmp(f[0], "=")) { + copy(fd->repo, sizeof fd->repo, f[1]); + copy(fd->name, sizeof fd->name, f[2]); + copy(fd->description, sizeof fd->description, f[3]); + copy(fd->tag, sizeof fd->tag, f[4]); + copy(fd->date, sizeof fd->date, f[5]); + fd->prereleases = atoi(f[6]); + copy(fd->existing, sizeof fd->existing, f[7]); + ok = 1; + } else if (k >= 3 && ok && fd->nassets < MAX_ASSETS) { + copy(fd->assets[fd->nassets], sizeof fd->assets[0], f[0]); + fd->sizes[fd->nassets] = atol(f[1]); + fd->arm64[fd->nassets] = atoi(f[2]); + fd->nassets++; + } + } + free(body); + if (ok && fd->nassets == 0) { + snprintf(err, errsz, "No downloads for the Frame in its latest release."); + ok = 0; + } + return ok; +} + +/* POSTs path; returns the reply's "!" error (or "") in err. */ +static void post(const char* path, char* err, size_t errsz) { + char* body = http("POST", path, 30); + if (err) { + err[0] = '\0'; + if (!body) + snprintf(err, errsz, "The Port Manager service didn't answer."); + else if (body[0] == '!' && body[1] == '\t') + snprintf(err, errsz, "%.*s", (int)strcspn(body + 2, "\n"), body + 2); + else if (strstr(body, "\"error\": \"")) + snprintf(err, errsz, "%.*s", (int)strcspn(strstr(body, "\"error\": \"") + 10, "\""), + strstr(body, "\"error\": \"") + 10); + } + free(body); +} + +static void action(const char* what, const char* id, const char* extra) { + char path[512]; + snprintf(path, sizeof path, "/api/%s?id=%s%s", what, id, extra ? extra : ""); + post(path, NULL, 0); } static void try_start_service(void) { @@ -228,19 +380,29 @@ static void try_start_service(void) { /* ---------------------------------------------------------------- drawing */ +#define BG 0x0f1216 +#define ROW 0x181d23 +#define ROW_SEL 0x1f2a36 +#define TXT 0xe8edf2 +#define MUTED 0x93a0ad +#define ACCENT 0x4fa3ff +#define OK_C 0x4cc38a +#define WARN 0xf0b429 +#define BAD 0xf2555a + static int busy_status(const char* s) { return !strcmp(s, "downloading") || !strcmp(s, "installing"); } -static Uint32 status_color(const Port* p) { - if (busy_status(p->status)) return 0x4fa3ff; - if (!strcmp(p->status, "update available")) return 0xf0b429; - if (!strcmp(p->status, "up to date")) return 0x4cc38a; - if (!strcmp(p->status, "error")) return 0xf2555a; - return 0x93a0ad; +static Uint32 status_color(const App* p) { + if (busy_status(p->status)) return ACCENT; + if (!strcmp(p->status, "update available")) return WARN; + if (!strcmp(p->status, "up to date")) return OK_C; + if (!strcmp(p->status, "error")) return BAD; + return MUTED; } -static const char* status_label(const Port* p) { +static const char* status_label(const App* p) { if (!strcmp(p->status, "downloading")) return "Downloading"; if (!strcmp(p->status, "installing")) return "Installing"; if (!strcmp(p->status, "update available")) return "Update available"; @@ -249,115 +411,242 @@ static const char* status_label(const Port* p) { return "Not installed"; } +static void clear(void) { + color(BG); + SDL_RenderClear(ren); +} + +static int title(const char* s) { + int m = W / 20; + text_fit(s, m, m, S * 2, W - 2 * m, TXT); + return m + 22 * S; +} + static void legend(const char* s) { int y = H - H / 20 - 8 * S; fill(0, y - 12 * S, W, H - y + 12 * S, 0x151a20); - text(s, W / 20, y, S, 0x93a0ad); + text_fit(s, W / 20, y, S, W - W / 10, MUTED); } -static void draw_ports(Port* ports, int n, int sel, const char* banner) { - int m = W / 20, y = m; - color(0x0f1216); - SDL_RenderClear(ren); - text("Port Manager", m, y, S * 2, 0xe8edf2); - y += 16 * S + 6 * S; +/* First visible row so that sel stays on screen. */ +static int scroll_first(int sel, int max_rows) { + return sel >= max_rows ? sel - max_rows + 1 : 0; +} + +static void draw_list(App* apps, int n, int sel, const char* banner, Uint32 banner_rgb) { + clear(); + int m = W / 20, y = title("Port Manager"); if (banner && *banner) { - text_fit(banner, m, y, S, W - 2 * m, 0xf0b429); + text_fit(banner, m, y, S, W - 2 * m, banner_rgb); y += 14 * S; } - if (n <= 0) { - text(n < 0 ? "Starting the Port Manager service..." : "No ports in the catalog.", m, y + 8 * S, S, 0x93a0ad); + if (n < 0) { + text("Starting the Port Manager service...", m, y + 8 * S, S, MUTED); + legend("B Close"); + SDL_RenderPresent(ren); + return; } int row_h = 50 * S; int max_rows = (H - y - 40 * S) / row_h; - int first = sel >= max_rows ? sel - max_rows + 1 : 0; - for (int i = first; i < n && i < first + max_rows; i++) { - Port* p = &ports[i]; + if (max_rows < 1) max_rows = 1; + int first = scroll_first(sel, max_rows); + for (int i = first; i <= n && i < first + max_rows; i++) { int x = m, w = W - 2 * m; - fill(x, y, w, row_h - 6 * S, i == sel ? 0x1f2a36 : 0x181d23); - if (i == sel) fill(x, y, 3 * S, row_h - 6 * S, 0x4fa3ff); + if (i == n) { /* the "add" row */ + fill(x, y, w, 26 * S, i == sel ? ROW_SEL : ROW); + if (i == sel) fill(x, y, 3 * S, 26 * S, ACCENT); + text("+ Add an app from GitHub", x + 10 * S, y + 9 * S, S, ACCENT); + break; + } + App* p = &apps[i]; + fill(x, y, w, row_h - 6 * S, i == sel ? ROW_SEL : ROW); + if (i == sel) fill(x, y, 3 * S, row_h - 6 * S, ACCENT); int tx = x + 10 * S, ty = y + 8 * S; const char* st = status_label(p); int sw = (int)strlen(st) * 8 * S; - text_fit(p->name, tx, ty, S, w - 30 * S - sw, 0xe8edf2); + text_fit(p->name, tx, ty, S, w - 30 * S - sw, TXT); text(st, x + w - 10 * S - sw, ty, S, status_color(p)); ty += 13 * S; - char line[200]; - snprintf(line, sizeof line, "Installed: %s Latest: %s", p->installed[0] ? p->installed : "-", + char line[300]; + snprintf(line, sizeof line, "%s Installed: %s Latest: %s", p->repo, p->installed[0] ? p->installed : "-", p->latest[0] ? p->latest : "-"); - text_fit(line, tx, ty, S, w - 20 * S, 0x93a0ad); + text_fit(line, tx, ty, S, w - 20 * S, MUTED); ty += 12 * S; if (busy_status(p->status)) { int bw = w - 20 * S; fill(tx, ty + 2 * S, bw, 4 * S, 0x2a313a); - fill(tx, ty + 2 * S, (int)(bw * p->progress), 4 * S, 0x4fa3ff); + fill(tx, ty + 2 * S, (int)(bw * p->progress), 4 * S, ACCENT); } else if (p->message[0]) { - text_fit(p->message, tx, ty, S, w - 20 * S, !strcmp(p->status, "error") ? 0xf2555a : 0x4cc38a); + text_fit(p->message, tx, ty, S, w - 20 * S, !strcmp(p->job, "error") ? BAD : OK_C); } y += row_h; } - legend("A Install/Update Y Versions X Previous Start Check B Close"); + legend("A Open Y Add app Select Update all Start Check B Close"); SDL_RenderPresent(ren); } -static void draw_releases(const Port* p, Release* rels, int n, int sel, const char* err) { - int m = W / 20, y = m; - color(0x0f1216); - SDL_RenderClear(ren); - text_fit(p->name, m, y, S * 2, W - 2 * m, 0xe8edf2); - y += 16 * S + 6 * S; - text("Choose a version to install:", m, y, S, 0x93a0ad); - y += 16 * S; - if (err[0]) text_fit(err, m, y, S, W - 2 * m, 0xf2555a); - int row_h = 16 * S; +/* A simple list of choices under a heading. */ +static void draw_menu(const char* heading, const char* sub, const char** items, int n, int sel, const char* err, + const char* keys) { + clear(); + int m = W / 20, y = title(heading); + if (sub && *sub) { + text_fit(sub, m, y, S, W - 2 * m, MUTED); + y += 16 * S; + } + if (err && *err) { + y += text_wrap(err, m, y, S, W - 2 * m, 3, BAD) + 4 * S; + } + int row_h = 18 * S; int max_rows = (H - y - 40 * S) / row_h; - int first = sel >= max_rows ? sel - max_rows + 1 : 0; + if (max_rows < 1) max_rows = 1; + int first = scroll_first(sel, max_rows); for (int i = first; i < n && i < first + max_rows; i++) { - Release* r = &rels[i]; - if (i == sel) fill(m, y - 4 * S, W - 2 * m, row_h, 0x1f2a36); - char line[200]; - snprintf(line, sizeof line, "%s %s%s%s", r->tag, r->date, !strcmp(r->tag, p->latest) ? " (latest)" : "", - !strcmp(r->tag, p->installed) ? " (installed)" : ""); - text_fit(line, m + 10 * S, y, S, W - 2 * m - 20 * S, i == sel ? 0xe8edf2 : 0x93a0ad); + if (i == sel) { + fill(m, y - 5 * S, W - 2 * m, row_h - 2 * S, ROW_SEL); + fill(m, y - 5 * S, 3 * S, row_h - 2 * S, ACCENT); + } + text_fit(items[i], m + 10 * S, y, S, W - 2 * m - 20 * S, i == sel ? TXT : MUTED); y += row_h; } - legend("A Install this version B Back"); + legend(keys); + SDL_RenderPresent(ren); +} + +static void draw_wait(const char* heading, const char* what) { + clear(); + int m = W / 20, y = title(heading); + text_fit(what, m, y + 8 * S, S, W - 2 * m, MUTED); + SDL_RenderPresent(ren); +} + +/* ---- on-screen keyboard */ + +static const char* KB_ROWS[] = { "1234567890", "qwertyuiop", "asdfghjkl-", "zxcvbnm_./" }; +static const char* KB_KEYS[] = { "Delete", "Paste", "Clear", "Done" }; +#define KB_SPECIAL_ROW 4 + +static void draw_keyboard(const char* entry, int row, int col, const char* err) { + clear(); + int m = W / 20, y = title("Add an app from GitHub"); + text("Type the repo, like owner/name (a full github.com link works too):", m, y, S, MUTED); + y += 16 * S; + fill(m, y, W - 2 * m, 20 * S, ROW); + char shown[300]; + snprintf(shown, sizeof shown, "%s_", entry); + int maxc = (W - 2 * m - 20 * S) / (8 * S); + int len = (int)strlen(shown); + text(shown + (len > maxc ? len - maxc : 0), m + 10 * S, y + 6 * S, S, TXT); + y += 30 * S; + if (err && *err) { + text_fit(err, m, y, S, W - 2 * m, BAD); + } + y += 14 * S; + int key = 30 * S, gap = 4 * S; + int kw = 10 * key + 9 * gap; + int x0 = (W - kw) / 2; + for (int r = 0; r < 4; r++) { + for (int c = 0; c < 10; c++) { + int x = x0 + c * (key + gap), on = r == row && c == col; + fill(x, y, key, key, on ? ACCENT : ROW_SEL); + char ch[2] = { KB_ROWS[r][c], 0 }; + text(ch, x + key / 2 - 4 * S, y + key / 2 - 4 * S, S, on ? 0xffffff : TXT); + } + y += key + gap; + } + int sw = (kw - 3 * gap) / 4; + for (int c = 0; c < 4; c++) { + int x = x0 + c * (sw + gap), on = row == KB_SPECIAL_ROW && c == col; + fill(x, y, sw, key, on ? ACCENT : ROW_SEL); + int tw = (int)strlen(KB_KEYS[c]) * 8 * S; + text(KB_KEYS[c], x + (sw - tw) / 2, y + key / 2 - 4 * S, S, on ? 0xffffff : TXT); + } + legend("A Type X Delete Start Done B Cancel"); + SDL_RenderPresent(ren); +} + +static void draw_found(const Found* fd, const char* err) { + clear(); + int m = W / 20, y = title(fd->name); + text_fit(fd->repo, m, y, S, W - 2 * m, ACCENT); + y += 14 * S; + if (fd->description[0]) y += text_wrap(fd->description, m, y, S, W - 2 * m, 3, MUTED) + 6 * S; + char line[400]; + snprintf(line, sizeof line, "Latest release: %s (%s)%s", fd->tag, fd->date, + fd->prereleases ? " pre-release" : ""); + text_fit(line, m, y, S, W - 2 * m, TXT); + y += 20 * S; + if (fd->existing[0]) { + text("It's already on your list.", m, y, S, OK_C); + legend("A Show it B Back"); + SDL_RenderPresent(ren); + return; + } + text("Download:", m, y, S, MUTED); + y += 14 * S; + fill(m, y - 5 * S, W - 2 * m, 18 * S, ROW_SEL); + snprintf(line, sizeof line, "%s %s (%.1f MB)%s %s", fd->nassets > 1 ? "<" : " ", fd->assets[fd->asset], + fd->sizes[fd->asset] / 1048576.0, fd->arm64[fd->asset] ? "" : " not ARM64", fd->nassets > 1 ? ">" : ""); + text_fit(line, m + 10 * S, y, S, W - 2 * m - 20 * S, fd->arm64[fd->asset] ? TXT : WARN); + y += 22 * S; + if (fd->nassets > 1) { + snprintf(line, sizeof line, "%d downloads to choose from; Left/Right to switch.", fd->nassets); + text(line, m, y, S, MUTED); + y += 14 * S; + } + text_wrap("Port Manager unpacks it and either adds it to your Steam library or, if it comes with an install.sh, " + "runs that. Only add apps you trust.", m, y, S, W - 2 * m, 3, MUTED); + y += 40 * S; + if (err && *err) text_wrap(err, m, y, S, W - 2 * m, 3, BAD); + legend(fd->nassets > 1 ? "A Add and install Left/Right Download B Back" : "A Add and install B Back"); SDL_RenderPresent(ren); } /* ---------------------------------------------------------------- main */ -enum { NONE, UP, DOWN, OK, BACK, VERSIONS, PREVIOUS, REFRESH }; +enum { NONE, UP, DOWN, LEFT, RIGHT, OK, BACK, ADD, UPDATE_ALL, REFRESH, DELETE }; +enum { LIST, MENU, VERSIONS, CONFIRM, ADDING, KEYBOARD, FOUND }; static int map_button(int b) { switch (b) { case SDL_CONTROLLER_BUTTON_DPAD_UP: return UP; case SDL_CONTROLLER_BUTTON_DPAD_DOWN: return DOWN; + case SDL_CONTROLLER_BUTTON_DPAD_LEFT: return LEFT; + case SDL_CONTROLLER_BUTTON_DPAD_RIGHT: return RIGHT; case SDL_CONTROLLER_BUTTON_A: return OK; case SDL_CONTROLLER_BUTTON_B: return BACK; - case SDL_CONTROLLER_BUTTON_Y: return VERSIONS; - case SDL_CONTROLLER_BUTTON_X: return PREVIOUS; + case SDL_CONTROLLER_BUTTON_Y: return ADD; + case SDL_CONTROLLER_BUTTON_X: return DELETE; + case SDL_CONTROLLER_BUTTON_BACK: return UPDATE_ALL; case SDL_CONTROLLER_BUTTON_START: return REFRESH; default: return NONE; } } -static int map_key(SDL_Keycode k) { +static int map_key(SDL_Keycode k, int typing) { switch (k) { case SDLK_UP: return UP; case SDLK_DOWN: return DOWN; - case SDLK_RETURN: return OK; - case SDLK_ESCAPE: case SDLK_BACKSPACE: return BACK; - case SDLK_v: return VERSIONS; - case SDLK_p: return PREVIOUS; + case SDLK_LEFT: return LEFT; + case SDLK_RIGHT: return RIGHT; + case SDLK_RETURN: return typing ? REFRESH : OK; /* Enter finishes typing */ + case SDLK_ESCAPE: return BACK; + case SDLK_BACKSPACE: return typing ? DELETE : BACK; + default: break; + } + if (typing) return NONE; + switch (k) { + case SDLK_a: return ADD; + case SDLK_u: return UPDATE_ALL; case SDLK_r: return REFRESH; default: return NONE; } } int main(void) { - /* PM_SCREENSHOT= draws one frame of the list into memory, saves it and exits (testing without a - * display); PM_SIZE=WxH sets its size, PM_SCREEN=versions shows the version list for the first port. */ + /* PM_SCREENSHOT= draws one frame into memory, saves it and exits (testing without a display); + * PM_SIZE=WxH sets its size, PM_SCREEN=menu|versions|add|keyboard|found picks the screen (for the first app; + * PM_REPO is the repo for "found", PM_TYPED the text for "keyboard"). */ const char* shot = getenv("PM_SCREENSHOT"); SDL_Surface* shot_surface = NULL; if (shot) { @@ -379,83 +668,349 @@ int main(void) { } if (!ren) return 1; SDL_ShowCursor(SDL_DISABLE); + SDL_StopTextInput(); read_port(); - Port ports[MAX_PORTS]; - Release rels[MAX_RELEASES]; - int nports = -1, sel = 0, nrels = 0, rsel = 0, in_versions = 0, tried_start = 0; - char err[256] = "", banner[256] = ""; + static App apps[MAX_APPS]; + static Release rels[MAX_RELEASES]; + static Suggestion sug[MAX_SUGGEST]; + static Found fd; + int napps = -1, sel = 0, nrels = 0, rsel = 0, msel = 0, asel = 0, nsug = 0, tried_start = 0; + int screen = LIST, kb_row = 1, kb_col = 0, notice_busy = 0; + char err[300] = "", banner[300] = "", notice[300] = "", entry[256] = "", want_id[64] = ""; + char hint_name[128] = ""; Uint32 next_poll = 0, axis_ready = 0; + const char* menu_items[8]; + int menu_acts[8], nmenu = 0; + char menu_text[8][160]; + enum { M_INSTALL, M_VERSIONS, M_PREVIOUS, M_PRE, M_REMOVE }; + + if (shot && getenv("PM_SCREEN")) { + const char* sc = getenv("PM_SCREEN"); + napps = load_apps(apps, notice, sizeof notice, ¬ice_busy); + if (!strcmp(sc, "menu")) screen = MENU; + if (!strcmp(sc, "versions") && napps > 0) { + nrels = load_releases(apps[0].id, rels, err, sizeof err); + screen = VERSIONS; + } + if (!strcmp(sc, "add")) { + nsug = load_suggestions(sug); + screen = ADDING; + } + if (!strcmp(sc, "keyboard")) { + snprintf(entry, sizeof entry, "%s", getenv("PM_TYPED") ? getenv("PM_TYPED") : ""); + kb_row = 2, kb_col = 3; + screen = KEYBOARD; + } + if (!strcmp(sc, "found")) { + inspect(getenv("PM_REPO") ? getenv("PM_REPO") : "", &fd, err, sizeof err); + screen = FOUND; + } + if (!strcmp(sc, "confirm")) screen = CONFIRM; + next_poll = 0xffffffff; + } for (;;) { int act = NONE; + const char* typed = NULL; SDL_Event e; while (SDL_PollEvent(&e)) { if (e.type == SDL_QUIT) goto done; if (e.type == SDL_CONTROLLERDEVICEADDED) SDL_GameControllerOpen(e.cdevice.which); if (e.type == SDL_CONTROLLERBUTTONDOWN) act = map_button(e.cbutton.button); - if (e.type == SDL_KEYDOWN) act = map_key(e.key.keysym.sym); - if (e.type == SDL_CONTROLLERAXISMOTION && e.caxis.axis == SDL_CONTROLLER_AXIS_LEFTY && - SDL_GetTicks() >= axis_ready && abs(e.caxis.value) > 24000) { - act = e.caxis.value < 0 ? UP : DOWN; - axis_ready = SDL_GetTicks() + 250; + if (e.type == SDL_KEYDOWN) act = map_key(e.key.keysym.sym, screen == KEYBOARD); + if (e.type == SDL_TEXTINPUT && screen == KEYBOARD) typed = e.text.text; + if (e.type == SDL_CONTROLLERAXISMOTION && SDL_GetTicks() >= axis_ready && abs(e.caxis.value) > 24000) { + if (e.caxis.axis == SDL_CONTROLLER_AXIS_LEFTY) act = e.caxis.value < 0 ? UP : DOWN; + if (e.caxis.axis == SDL_CONTROLLER_AXIS_LEFTX) act = e.caxis.value < 0 ? LEFT : RIGHT; + if (act != NONE) axis_ready = SDL_GetTicks() + 220; } } if (SDL_GetTicks() >= next_poll) { - nports = load_ports(ports); - if (nports < 0 && !tried_start) { + napps = load_apps(apps, notice, sizeof notice, ¬ice_busy); + if (napps < 0 && !tried_start) { tried_start = 1; try_start_service(); } - int busy = 0; - for (int i = 0; i < nports; i++) busy |= busy_status(ports[i].status); + int busy = notice_busy; + for (int i = 0; i < napps; i++) busy |= busy_status(apps[i].status); next_poll = SDL_GetTicks() + (busy ? 400 : 2000); - if (sel >= nports) sel = nports > 0 ? nports - 1 : 0; + if (want_id[0]) /* jump to an app that was just added */ + for (int i = 0; i < napps; i++) + if (!strcmp(apps[i].id, want_id)) { + sel = i; + want_id[0] = '\0'; + } + if (sel > napps) sel = napps > 0 ? napps : 0; snprintf(banner, sizeof banner, "%s", - nports < 0 && tried_start && SDL_GetTicks() > 8000 + napps < 0 && tried_start && SDL_GetTicks() > 8000 ? "The Port Manager service isn't running. Restart the Frame and try again." - : ""); + : notice); } + App* cur = sel < napps ? &apps[sel] : NULL; - if (in_versions) { - if (act == UP && rsel > 0) rsel--; - if (act == DOWN && rsel < nrels - 1) rsel++; - if (act == BACK) in_versions = 0; - if (act == OK && nrels > 0) { - action("install", ports[sel].id, rels[rsel].tag); - in_versions = 0; - next_poll = 0; - } - } else { + switch (screen) { + case LIST: if (act == UP && sel > 0) sel--; - if (act == DOWN && sel < nports - 1) sel++; + if (act == DOWN && sel < napps) sel++; if (act == BACK) goto done; - if (nports > 0) { - if (act == OK) action("install", ports[sel].id, NULL); - if (act == PREVIOUS && ports[sel].can_rollback) action("rollback", ports[sel].id, NULL); - if (act == REFRESH) free(http("POST", "/api/refresh")); - if (act == VERSIONS) { - nrels = load_releases(ports[sel].id, rels, err, sizeof err); + if (napps < 0) break; + if (act == OK && cur) { + screen = MENU; + msel = 0; + err[0] = '\0'; + } else if (act == ADD || (act == OK && !cur)) { + draw_wait("Add an app from GitHub", "Loading suggestions..."); + nsug = load_suggestions(sug); + asel = 0; + err[0] = '\0'; + screen = ADDING; + } + if (act == REFRESH) post("/api/refresh", NULL, 0); + if (act == UPDATE_ALL) post("/api/update_all", NULL, 0); + if (act != NONE) next_poll = SDL_GetTicks() + 150; + break; + + case MENU: + if (!cur) { + screen = LIST; + break; + } + nmenu = 0; + if (cur->latest[0]) { + if (!cur->installed[0]) + snprintf(menu_text[nmenu], 160, "Install %s", cur->latest); + else if (!strcmp(cur->status, "update available")) + snprintf(menu_text[nmenu], 160, "Update to %s", cur->latest); + else + snprintf(menu_text[nmenu], 160, "Reinstall %s", cur->latest); + menu_acts[nmenu++] = M_INSTALL; + } + snprintf(menu_text[nmenu], 160, "Choose a version..."); + menu_acts[nmenu++] = M_VERSIONS; + if (cur->can_rollback) { + snprintf(menu_text[nmenu], 160, "Go back to the previous version"); + menu_acts[nmenu++] = M_PREVIOUS; + } + snprintf(menu_text[nmenu], 160, "Pre-releases: %s", cur->prereleases ? "included" : "skipped"); + menu_acts[nmenu++] = M_PRE; + snprintf(menu_text[nmenu], 160, "Remove..."); + menu_acts[nmenu++] = M_REMOVE; + for (int i = 0; i < nmenu; i++) menu_items[i] = menu_text[i]; + if (msel >= nmenu) msel = nmenu - 1; + if (act == UP && msel > 0) msel--; + if (act == DOWN && msel < nmenu - 1) msel++; + if (act == BACK) screen = LIST; + if (act == OK) { + int m = menu_acts[msel]; + if (m == M_INSTALL) { + action("install", cur->id, NULL); + screen = LIST; + } else if (m == M_VERSIONS) { + draw_wait(cur->name, "Loading versions..."); + nrels = load_releases(cur->id, rels, err, sizeof err); rsel = 0; for (int i = 0; i < nrels; i++) - if (!strcmp(rels[i].tag, ports[sel].installed)) rsel = i; - in_versions = 1; + if (!strcmp(rels[i].tag, cur->installed)) rsel = i; + screen = VERSIONS; + } else if (m == M_PREVIOUS) { + action("rollback", cur->id, NULL); + screen = LIST; + } else if (m == M_PRE) { + action("settings", cur->id, cur->prereleases ? "&prereleases=0" : "&prereleases=1"); + cur->prereleases = !cur->prereleases; + } else if (m == M_REMOVE) { + screen = CONFIRM; } - if (act != NONE) next_poll = SDL_GetTicks() + 150; + next_poll = SDL_GetTicks() + 150; } + break; + + case VERSIONS: + if (act == UP && rsel > 0) rsel--; + if (act == DOWN && rsel < nrels - 1) rsel++; + if (act == BACK) screen = MENU; + if (act == OK && nrels > 0 && cur) { + char extra[100], enc[80]; + urlenc(enc, sizeof enc, rels[rsel].tag); + snprintf(extra, sizeof extra, "&tag=%s", enc); + action("install", cur->id, extra); + screen = LIST; + next_poll = 0; + } + break; + + case CONFIRM: + if (act == BACK) screen = MENU; + if (act == OK && cur) { + action("remove", cur->id, NULL); + screen = LIST; + next_poll = SDL_GetTicks() + 300; + } + break; + + case ADDING: { + int has_clip = SDL_HasClipboardText(); + int nitems = 1 + has_clip + nsug; + if (act == UP && asel > 0) asel--; + if (act == DOWN && asel < nitems - 1) asel++; + if (act == BACK) screen = LIST; + if (act == OK) { + const char* repo = NULL; + hint_name[0] = '\0'; + if (asel == 0) { + screen = KEYBOARD; + err[0] = '\0'; + } else if (has_clip && asel == 1) { + char* clip = SDL_GetClipboardText(); + snprintf(entry, sizeof entry, "%s", clip ? clip : ""); + SDL_free(clip); + repo = entry; + } else { + Suggestion* s = &sug[asel - 1 - has_clip]; + repo = s->repo; + snprintf(hint_name, sizeof hint_name, "%s", s->name); + } + if (repo) { + draw_wait("Add an app from GitHub", "Looking it up on GitHub..."); + if (inspect(repo, &fd, err, sizeof err)) { + if (hint_name[0]) snprintf(fd.name, sizeof fd.name, "%s", hint_name); + err[0] = '\0'; + screen = FOUND; + } + } + } + if (screen == KEYBOARD) SDL_StartTextInput(); + break; + } + + case KEYBOARD: { + size_t len = strlen(entry); + if (typed && len + strlen(typed) < sizeof entry - 1) strcat(entry, typed); + if (act == UP) kb_row = kb_row > 0 ? kb_row - 1 : KB_SPECIAL_ROW; + if (act == DOWN) kb_row = kb_row < KB_SPECIAL_ROW ? kb_row + 1 : 0; + int cols = kb_row == KB_SPECIAL_ROW ? 4 : 10; + if (kb_col >= cols) kb_col = cols - 1; + if (act == LEFT) kb_col = (kb_col + cols - 1) % cols; + if (act == RIGHT) kb_col = (kb_col + 1) % cols; + int done_typing = act == REFRESH; + if (act == DELETE && len) entry[len - 1] = '\0'; + if (act == OK) { + if (kb_row < KB_SPECIAL_ROW) { + if (len < sizeof entry - 2) { + entry[len] = KB_ROWS[kb_row][kb_col]; + entry[len + 1] = '\0'; + } + } else if (kb_col == 0 && len) { + entry[len - 1] = '\0'; + } else if (kb_col == 1) { + char* clip = SDL_GetClipboardText(); + if (clip && len + strlen(clip) < sizeof entry - 1) strcat(entry, clip); + SDL_free(clip); + } else if (kb_col == 2) { + entry[0] = '\0'; + } else if (kb_col == 3) { + done_typing = 1; + } + } + if (act == BACK) { + SDL_StopTextInput(); + screen = ADDING; + } + if (done_typing && entry[0]) { + draw_wait("Add an app from GitHub", "Looking it up on GitHub..."); + if (inspect(entry, &fd, err, sizeof err)) { + err[0] = '\0'; + SDL_StopTextInput(); + screen = FOUND; + } + } + break; + } + + case FOUND: + if (act == BACK) screen = ADDING; + if (fd.existing[0]) { + if (act == OK) { + snprintf(want_id, sizeof want_id, "%s", fd.existing); + screen = LIST; + next_poll = 0; + } + break; + } + if (act == LEFT && fd.nassets > 1) fd.asset = (fd.asset + fd.nassets - 1) % fd.nassets; + if (act == RIGHT && fd.nassets > 1) fd.asset = (fd.asset + 1) % fd.nassets; + if (act == OK) { + char r[300], a[300], n[300], path[1100]; + urlenc(r, sizeof r, fd.repo); + urlenc(a, sizeof a, fd.assets[fd.asset]); + urlenc(n, sizeof n, fd.name); + snprintf(path, sizeof path, "/api/add?fmt=tsv&install=1&repo=%s&asset=%s&name=%s", r, a, n); + draw_wait(fd.name, "Adding it..."); + char* body = http("POST", path, 30); + if (!body) { + snprintf(err, sizeof err, "The Port Manager service didn't answer."); + } else if (body[0] == '!') { + snprintf(err, sizeof err, "%.*s", (int)strcspn(body + 2, "\n"), body + 2); + } else { + if (body[0] == '=') snprintf(want_id, sizeof want_id, "%.*s", (int)strcspn(body + 2, "\n"), body + 2); + screen = LIST; + entry[0] = '\0'; + next_poll = 0; + } + free(body); + } + break; } SDL_GetRendererOutputSize(ren, &W, &H); S = H >= 1000 ? 3 : 2; - if (shot && getenv("PM_SCREEN") && !strcmp(getenv("PM_SCREEN"), "versions") && nports > 0 && !in_versions) { - nrels = load_releases(ports[0].id, rels, err, sizeof err); - in_versions = 1; + cur = sel < napps ? &apps[sel] : NULL; + if (screen == MENU && cur) { + char sub[300]; + snprintf(sub, sizeof sub, "%s Installed: %s Latest: %s", cur->repo, + cur->installed[0] ? cur->installed : "-", cur->latest[0] ? cur->latest : "-"); + draw_menu(cur->name, sub, menu_items, nmenu, msel, cur->message[0] && !strcmp(cur->job, "error") ? cur->message : "", + "A Select B Back"); + } else if (screen == VERSIONS && cur) { + const char* items[MAX_RELEASES]; + static char lines[MAX_RELEASES][200]; + for (int i = 0; i < nrels; i++) { + snprintf(lines[i], sizeof lines[i], "%s %s%s%s%s", rels[i].tag, rels[i].date, + rels[i].prerelease ? " pre-release" : "", !strcmp(rels[i].tag, cur->latest) ? " (latest)" : "", + !strcmp(rels[i].tag, cur->installed) ? " (installed)" : ""); + items[i] = lines[i]; + } + draw_menu(cur->name, "Choose a version to install:", items, nrels, rsel, err, "A Install this version B Back"); + } else if (screen == CONFIRM && cur) { + const char* items[] = { "Yes, remove it" }; + char sub[300]; + snprintf(sub, sizeof sub, "Remove %s?", cur->name); + draw_menu(cur->name, sub, items, 1, 0, + cur->installed[0] ? "Its files are deleted. Saves and settings kept outside its folder stay." : "", + "A Remove B Cancel"); + } else if (screen == ADDING) { + const char* items[2 + MAX_SUGGEST]; + static char lines[MAX_SUGGEST][300]; + int k = 0, has_clip = SDL_HasClipboardText(); + items[k++] = "Type a GitHub repo..."; + if (has_clip) items[k++] = "Paste a link from the clipboard"; + for (int i = 0; i < nsug; i++) { + snprintf(lines[i], sizeof lines[i], "%s - %s", sug[i].name, sug[i].note); + items[k++] = lines[i]; + } + draw_menu("Add an app from GitHub", nsug ? "Type a repo, or pick one made for the Frame:" : "", items, k, asel, + err, "A Select B Back"); + } else if (screen == KEYBOARD) { + draw_keyboard(entry, kb_row, kb_col, err); + } else if (screen == FOUND) { + draw_found(&fd, err); + } else { + if (screen != LIST) screen = LIST; + int is_err = strstr(banner, "isn't running") != NULL; + draw_list(apps, napps, sel, banner, is_err ? WARN : OK_C); } - if (in_versions) - draw_releases(&ports[sel], rels, nrels, rsel, err); - else - draw_ports(ports, nports, sel, banner); if (shot) { SDL_SaveBMP(shot_surface, shot); goto done; diff --git a/catalog.json b/catalog.json index 2f0808a..1df9ba2 100644 --- a/catalog.json +++ b/catalog.json @@ -7,9 +7,15 @@ "type": "zip", "gameid": "Ocarina_of_Time_SoH", "launch": "run.sh", - "keep": ["*.z64", "*.n64", "*.v64"], + "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." + "rom_hint": "Send your Ocarina of Time ROM with Frame Inbox, or put a .z64 in ~/.local/share/soh.", + "prereleases": true, + "mode": "game" }, { "id": "2ship", @@ -19,7 +25,9 @@ "type": "appimage", "gameid": "Majoras_Mask_2Ship", "launch": "run.sh", - "keep": ["*.z64"], + "keep": [ + "*.z64" + ], "data": "~/.local/share/2ship", "rom_hint": "Send your Majora's Mask ROM with Frame Inbox, or put a .z64 in ~/.local/share/2ship.", "run_sh": [ @@ -35,6 +43,8 @@ "fi", "chmod +x \"$D/usr/bin/2s2h.elf\" 2>/dev/null", "cd \"$D/usr/bin\" && exec ./2s2h.elf \"$@\"" - ] + ], + "prereleases": true, + "mode": "game" } ] diff --git a/deploy.sh b/deploy.sh index 390ce88..077a773 100755 --- a/deploy.sh +++ b/deploy.sh @@ -18,7 +18,7 @@ $SSH "$USER_AT" true || { echo "Can't reach $HOST over SSH. Is the Frame on and echo "== Service" $SSH "$USER_AT" 'rm -rf ~/.cache/frame-port-manager-src && mkdir -p ~/.cache/frame-port-manager-src' -rsync -a -e "$SSH" manager.py page.html catalog.json frame-port-manager.service install.sh art "$USER_AT:.cache/frame-port-manager-src/" +rsync -a -e "$SSH" manager.py page.html catalog.json suggestions.json frame-port-manager.service install.sh art "$USER_AT:.cache/frame-port-manager-src/" $SSH "$USER_AT" 'sh ~/.cache/frame-port-manager-src/install.sh' echo "== Headset app" diff --git a/frame-port-manager.service b/frame-port-manager.service index 69b6f1f..70aabe1 100644 --- a/frame-port-manager.service +++ b/frame-port-manager.service @@ -1,5 +1,5 @@ [Unit] -Description=Port Manager (installs and updates Steam Frame ports from GitHub releases) +Description=Port Manager (installs and updates Steam Frame apps and ports from GitHub releases) After=network-online.target [Service] diff --git a/install.sh b/install.sh index 0d32912..bc45415 100755 --- a/install.sh +++ b/install.sh @@ -4,7 +4,7 @@ set -e here="$(cd "$(dirname "$0")" && pwd)" dest="$HOME/.local/share/frame-port-manager" mkdir -p "$dest" "$HOME/.config/systemd/user" -cp "$here/manager.py" "$here/page.html" "$here/catalog.json" "$dest/" +cp "$here/manager.py" "$here/page.html" "$here/catalog.json" "$here/suggestions.json" "$dest/" rm -rf "$dest/art" && cp -r "$here/art" "$dest/art" cp "$here/frame-port-manager.service" "$HOME/.config/systemd/user/" systemctl --user daemon-reload diff --git a/manager.py b/manager.py index ce40c07..13a3cf1 100755 --- a/manager.py +++ b/manager.py @@ -1,9 +1,13 @@ #!/usr/bin/env python3 -"""Port Manager: installs and updates native Steam Frame ports from their GitHub releases. +"""Port Manager: installs and updates Steam Frame apps and ports from their GitHub releases, like Obtainium. -A small web service (standard library only) that runs on the Frame. It reads each port's releases, downloads the -chosen one, unpacks it into ~/devkit-game/, keeps ROMs and saves, fixes executable bits, registers the -game with Steam and gives it library art. The previous install is kept for a one-step rollback. +A small web service (standard library only) that runs on the Frame. It tracks a list of GitHub repos: the built-in +ports in catalog.json plus any repo you add. For each it reads the releases, picks the download meant for the Frame +(ARM64 Linux), unpacks it and either + - adds it to the Steam library (mode "game": ~/devkit-game/, a run.sh launcher, library art), or + - runs the project's own installer (mode "installer": install.sh, contrib/install.sh or setup.sh), for tools like + SteamVR overlays that set themselves up. +ROMs and saves are kept, and the previous install is kept for a one-step rollback. Control it from the headset app (app/) or from a browser at http://:8643. """ @@ -17,6 +21,7 @@ import stat import struct import subprocess import sys +import tarfile import threading import time import urllib.error @@ -33,6 +38,8 @@ CONFIG_FILE = CONFIG_DIR / "config.json" INSTALLED_FILE = CONFIG_DIR / "installed.json" USER_CATALOG = CONFIG_DIR / "catalog.json" CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", HOME / ".cache")) / "port-manager" +DATA_DIR = Path(os.environ.get("XDG_DATA_HOME", HOME / ".local/share")) / "port-manager" +APPS_DIR = DATA_DIR / "apps" # installs of "installer" apps (their installers may point back into them) GAMES_DIR = Path(os.environ.get("PORT_MANAGER_GAMES", HOME / "devkit-game")) CREATE_SHORTCUT = HOME / "devkit-utils" / "steam-client-create-shortcut" STEAM_USERDATA = HOME / ".local/share/Steam/userdata" @@ -46,6 +53,8 @@ DEFAULT_CONFIG = { } CHUNK = 1024 * 1024 RELEASES_TTL = 600 +INSTALLERS = ["install.sh", "contrib/install.sh", "scripts/install.sh", "setup.sh"] +REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") def load_json(path, default): @@ -64,17 +73,50 @@ def save_json(path, data): os.replace(tmp, path) + + def config(): return {**DEFAULT_CONFIG, **load_json(CONFIG_FILE, {})} def catalog(): - """The built-in ports, plus the user's own entries (same id replaces a built-in one).""" - ports = {p["id"]: p for p in load_json(APP_DIR / "catalog.json", [])} + """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.""" + apps = {} + for p in load_json(APP_DIR / "catalog.json", []): + apps[p["id"]] = {**p, "builtin": True} for p in load_json(USER_CATALOG, []): if "id" in p: - ports[p["id"]] = {**ports.get(p["id"], {}), **p} - return ports + apps[p["id"]] = {**apps.get(p["id"], {}), **p} + return {k: v for k, v in apps.items() if not v.get("hidden") and v.get("repo")} + + +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) + + +def suggestions(): + """Apps worth adding: the built-in ports (so a removed one can come back) and the ones in suggestions.json.""" + ports = [{"repo": p["repo"], "name": p["name"], "note": "Built-in port"} + for p in load_json(APP_DIR / "catalog.json", [])] + return ports + load_json(APP_DIR / "suggestions.json", []) + + +def unhide_builtin(repo): + """Puts a removed built-in port back on the list; returns its id, or None if the repo isn't one.""" + 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"))]) + return port["id"] # ---------------------------------------------------------------- GitHub @@ -94,8 +136,9 @@ def gh_json(url): return json.load(r) except urllib.error.HTTPError as e: if e.code == 404: - raise RuntimeError("Repo not found. If it's private, add a GitHub token in Settings.") from e - if e.code in (401, 403): + raise RuntimeError("Repo not found. Check the address; if it's private, add a GitHub token in " + "Settings.") from e + if e.code in (401, 403, 429): raise RuntimeError("GitHub refused the request (token or rate limit). Try again later.") from e raise RuntimeError(f"GitHub error {e.code}") from e except OSError as e: @@ -104,12 +147,82 @@ def gh_json(url): raise RuntimeError("The GitHub token isn't valid. Check it in Settings.") from e +def parse_repo(text): + """owner/repo from "owner/repo", "github.com/owner/repo" or any github.com URL inside a repo.""" + t = (text or "").strip() + t = re.sub(r"^(https?://)?(www\.)?github\.com/", "", t, flags=re.I) + t = re.split(r"[?#]", t)[0] + parts = [p for p in t.split("/") if p] + if len(parts) < 2: + raise RuntimeError("Enter a GitHub repo like owner/name or github.com/owner/name.") + repo = parts[0] + "/" + re.sub(r"\.git$", "", parts[1]) + if not REPO_RE.match(repo): + raise RuntimeError("That doesn't look like a GitHub repo address.") + return repo + + +# ---- choosing the download + +ARCHIVES = [(".tar.gz", "tar"), (".tgz", "tar"), (".tar.xz", "tar"), (".txz", "tar"), (".tar.bz2", "tar"), + (".tar.zst", "tar"), (".tar", "tar"), (".zip", "zip"), (".appimage", "appimage")] +NOT_DOWNLOADS = re.compile(r"\.(sha\d*|sha\d+sum|md5|sig|asc|minisig|pem|txt|md|json|ya?ml|html|pdf|png|jpe?g|" + r"apk|aab|ipa|exe|msi|msix|dmg|pkg|deb|rpm|flatpak|snap|7z|rar|whl|jar|so|dll)$", re.I) +NOT_PROGRAMS = re.compile(r"(sums?$|checksum|readme|license|changelog|manifest|notes)", re.I) +OTHER_SYSTEMS = re.compile(r"(windows|win32|win64|\bwin\b|macos|darwin|osx|android|\bios\b|apple|mingw|msvc)", re.I) +OTHER_CPUS = re.compile(r"(x86[_-]?64|amd64|\bx64\b|i[36]86|\bx86\b|armv7|armhf|\barm32\b|riscv)", re.I) +OUR_CPU = re.compile(r"(aarch64|arm64)", re.I) + + +def asset_type(name): + low = name.lower() + for ext, kind in ARCHIVES: + if low.endswith(ext): + return kind + # A file with no archive extension is a program on its own (e.g. tool-aarch64-linux-gnu). + return None if NOT_DOWNLOADS.search(low) or re.search(r"\.[a-z][a-z0-9]{0,4}$", low) else "binary" + + +def score_asset(name): + """How likely a release file is the Frame's download: None if it can't be one (checksums, other systems...).""" + if NOT_DOWNLOADS.search(name) or NOT_PROGRAMS.search(name) or asset_type(name) is None \ + or OTHER_SYSTEMS.search(name): + return None + score = 0 + if OUR_CPU.search(name): + score += 50 + elif OTHER_CPUS.search(name): + score -= 60 # still offered (some run through FEX), but only when nothing else fits + if re.search(r"steam[-_. ]?frame|frame", name, re.I): + score += 20 + if re.search(r"linux", name, re.I): + score += 10 + score += {"appimage": 6, "tar": 4, "zip": 3, "binary": 0}[asset_type(name)] + return score + + +def asset_glob(name): + """A pattern that finds the same download in later releases: version numbers become *.""" + pat = re.sub(r"v?\d+(\.\d+)+(-?(rc|beta|alpha)\.?\d*)?", "*", name) + return re.sub(r"\*+", "*", pat) + + +def choose_asset(port, assets): + pattern = port.get("asset") + if pattern: + hit = next((a for a in assets if fnmatch.fnmatch(a["name"], pattern)), None) + if hit or not port.get("auto_asset"): + return hit + scored = [(score_asset(a["name"]), a) for a in assets] + scored = [(s, a) for s, a in scored if s is not None and s > -30] + return max(scored, key=lambda x: x[0])[1] if scored else None + + _releases_cache = {} def releases(port, force=False): - """Releases newest first, with the asset that matches the port's pattern.""" - key = port["repo"] + """Releases newest first, each with the download chosen for the Frame.""" + key = (port["repo"], port.get("asset")) hit = _releases_cache.get(key) if hit and not force and time.time() - hit[0] < RELEASES_TTL: return hit[1] @@ -117,7 +230,7 @@ def releases(port, force=False): for rel in gh_json(f"https://api.github.com/repos/{port['repo']}/releases?per_page=30"): if rel.get("draft"): continue - asset = next((a for a in rel.get("assets", []) if fnmatch.fnmatch(a["name"], port["asset"])), None) + asset = choose_asset(port, rel.get("assets", [])) if asset is None: continue out.append({ @@ -134,6 +247,88 @@ def releases(port, force=False): return out +def latest_release(port, rels): + """The release an update goes to: the newest one, skipping pre-releases unless the app takes them.""" + if port.get("prereleases"): + return rels[0] if rels else None + return next((r for r in rels if not r["prerelease"]), None) + + +def nice_name(repo_name): + words = re.split(r"[-_ ]+", repo_name) + if repo_name == repo_name.lower(): + words = [w[:1].upper() + w[1:] for w in words] + return " ".join(w for w in words if w) + + +def inspect_repo(text): + """What adding a repo would install: its name, the newest usable release and every download it could use.""" + repo = parse_repo(text) + info = gh_json(f"https://api.github.com/repos/{repo}") + repo = info.get("full_name") or repo + rels = [r for r in gh_json(f"https://api.github.com/repos/{repo}/releases?per_page=15") if not r.get("draft")] + if not rels: + raise RuntimeError(f"{repo} has no releases to install.") + usable = [r for r in rels if any(score_asset(a["name"]) is not None for a in r.get("assets", []))] + if not usable: + raise RuntimeError(f"{repo}'s releases have no Linux downloads (only source code or other systems).") + stable = next((r for r in usable if not r.get("prerelease")), None) + rel = stable or usable[0] + assets = sorted(({"name": a["name"], "size": a.get("size", 0), "score": score_asset(a["name"])} + for a in rel["assets"] if score_asset(a["name"]) is not None), + key=lambda a: -a["score"]) + for a in assets: + a["arm64"] = bool(OUR_CPU.search(a["name"])) or not OTHER_CPUS.search(a["name"]) + existing = next((pid for pid, p in catalog().items() if p["repo"].lower() == repo.lower()), None) + return { + "repo": repo, + "name": nice_name(info.get("name") or repo.split("/")[1]), + "description": re.sub(r"\s+", " ", (info.get("description") or "")).strip(), + "tag": rel["tag_name"], + "date": (rel.get("published_at") or "")[:10], + "prereleases": stable is None, + "assets": assets, + "existing": existing, + } + + +def add_app(repo_text, asset=None, name=None, mode="auto", prereleases=None): + """Adds a repo to the list and returns its id (an existing one's id if it's already there).""" + builtin = unhide_builtin(parse_repo(repo_text)) + if builtin: + return builtin + info = inspect_repo(repo_text) + if info["existing"]: + return info["existing"] + names = [a["name"] for a in info["assets"]] + if asset and asset not in names: + raise RuntimeError(f"{asset} isn't in the latest release.") + asset = asset or names[0] + base = re.sub(r"[^a-z0-9]+", "-", info["repo"].split("/")[1].lower()).strip("-") or "app" + pid, n = base, 2 + taken = set(catalog()) | {e.get("id") for e in load_json(USER_CATALOG, [])} + while pid in taken: + pid, n = f"{base}-{n}", n + 1 + title = (name or info["name"]).strip()[:80] or info["name"] + gameid = re.sub(r"[^A-Za-z0-9]+", "_", title).strip("_") or "App" + if gameid[0].isdigit(): + gameid = "App_" + gameid + used = {p.get("gameid") for p in catalog().values()} + 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) + _releases_cache.clear() + return pid + + # ---------------------------------------------------------------- Steam @@ -188,6 +383,7 @@ def parse_vdf(data): return {} + def register_with_steam(port, folder): if not CREATE_SHORTCUT.exists(): return "Installed. Add it to Steam with FrameDrop or Add a Non-Steam Game (pick run.sh)." @@ -205,9 +401,36 @@ def register_with_steam(port, folder): return None +def fetch(url, dest, limit=8 * 1024 * 1024): + req = urllib.request.Request(url, headers={"User-Agent": "frame-port-manager"}) + with urllib.request.urlopen(req, timeout=20) as r: + data = r.read(limit) + if not data.startswith(b"\x89PNG") and not data.startswith(b"\xff\xd8"): + raise OSError("not an image") + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(data) + + +def art_folder(port): + """The app's library art: the built-in ports ship theirs, added apps get their GitHub preview card and the + owner's avatar (fetched once).""" + if port.get("builtin"): + return APP_DIR / "art" / port["id"] + art = DATA_DIR / "art" / port["id"] + if not (art / "wide.png").exists(): + owner, name = port["repo"].split("/") + try: + fetch(f"https://opengraph.githubassets.com/1/{owner}/{name}", art / "wide.png") + shutil.copyfile(art / "wide.png", art / "hero.png") + fetch(f"https://github.com/{owner}.png?size=256", art / "icon.png") + except OSError: + pass + return art + + def add_art(port): - """Copies the port's library art into Steam's grid folder, without replacing art the user set.""" - art = APP_DIR / "art" / port["id"] + """Copies the app's library art into Steam's grid folder, without replacing art the user set.""" + art = art_folder(port) if not art.is_dir(): return appid = None @@ -270,13 +493,7 @@ def extract_zip(archive, dest): mode = (info.external_attr >> 16) & 0o777 if mode and not info.is_dir(): os.chmod(target, mode) - # A single top-level folder (e.g. soh-steam-frame/) becomes the install folder itself. - entries = [e for e in dest.iterdir()] - if len(entries) == 1 and entries[0].is_dir(): - inner = entries[0] - for e in list(inner.iterdir()): - shutil.move(str(e), dest / e.name) - inner.rmdir() + flatten(dest) def extract_appimage(archive, dest): @@ -296,35 +513,215 @@ def extract_appimage(archive, dest): archive.unlink() + +def flatten(dest): + """A single top-level folder (e.g. soh-steam-frame/) becomes the install folder itself.""" + entries = list(dest.iterdir()) + if len(entries) == 1 and entries[0].is_dir() and not entries[0].is_symlink(): + inner = dest / ".pm-inner" + os.replace(entries[0], inner) + for e in list(inner.iterdir()): + shutil.move(str(e), dest / e.name) + inner.rmdir() + + +def extract_tar(archive, dest): + if archive.name.lower().endswith(".tar.zst"): + r = subprocess.run(["tar", "--zstd", "-xf", str(archive), "-C", str(dest), "--no-same-owner"], + capture_output=True, timeout=1800) + if r.returncode != 0: + raise RuntimeError("Couldn't unpack the .tar.zst download.") + else: + with tarfile.open(archive) as t: + root = dest.resolve() + for m in t.getmembers(): + target = (dest / m.name).resolve() + if root not in target.parents and target != root: + raise RuntimeError("The download contains unsafe paths.") + if m.issym() or m.islnk(): + link = (target.parent / m.linkname).resolve() if m.issym() else (dest / m.linkname).resolve() + if root not in link.parents: + raise RuntimeError("The download contains unsafe links.") + if m.isdev(): + raise RuntimeError("The download contains device files.") + if hasattr(tarfile, "data_filter"): + t.extractall(dest, filter="data") + else: + t.extractall(dest) + flatten(dest) + + +def elf_info(path): + """(machine, is_program) for an ELF file, else None. Programs have an interpreter or are static executables; + shared libraries have neither.""" + try: + with open(path, "rb") as f: + head = f.read(64) + if len(head) < 64 or head[:4] != b"\x7fELF" or head[4] != 2: + return None + e_type, machine = struct.unpack_from(" best[0]: + best = (score, p) + return best[1] if best else None + + +def write_launcher(staging, port): + """Makes sure the folder has the run.sh Steam starts: the port's own, one from the catalog, or one that starts + the program found in the download.""" + launch = staging / port.get("launch", "run.sh") + if port.get("run_sh"): + launch.write_text("\n".join(port["run_sh"]) + "\n") + return None + if launch.exists(): + return None + if port.get("program"): + target = staging / port["program"] + if not target.exists(): + raise RuntimeError(f"The download has no {port['program']} (the program set for this app).") + elif (staging / "AppRun").exists(): + target = staging / "AppRun" + else: + target = find_program(staging, port) + if target is None: + raise RuntimeError("Couldn't find a program to start in this download. Set one in the web page " + "(Edit), or install it with its installer instead.") + rel = target.relative_to(staging) + folder = "/" + str(rel.parent) if str(rel.parent) != "." else "" + launch.write_text( + "#!/bin/sh\n" + f"# Starts {port['name']} (written by Port Manager).\n" + 'D="$(cd "$(dirname "$0")" && pwd)"\n' + f'chmod +x "$D/{rel}" 2>/dev/null\n' + f'cd "$D{folder}" && exec "./{rel.name}" "$@"\n') + return str(rel) + + +def find_installer(root, port): + for name in ([port["installer"]] if port.get("installer") else []) + INSTALLERS: + if (root / name).is_file(): + return root / name + return None + + +def run_installer(folder, port, uninstall=False): + """Runs the app's own installer (or its uninstaller) and returns its last lines. Raises if it fails.""" + script = find_installer(folder, port) + if script is None: + raise RuntimeError("The download has no install.sh.") + args = [str(script)] + if uninstall: + if (script.parent / "uninstall.sh").is_file(): + args = [str(script.parent / "uninstall.sh")] + elif "--uninstall" in script.read_text(errors="replace"): + args.append("--uninstall") + else: + return "" + log = CACHE_DIR / f"{port['id']}-{'uninstall' if uninstall else 'install'}.log" + CACHE_DIR.mkdir(parents=True, exist_ok=True) + os.chmod(script, os.stat(script).st_mode | stat.S_IXUSR) + with open(log, "wb") as out: + try: + r = subprocess.run((["bash"] if args[0].endswith(".sh") else []) + args, cwd=script.parent, + stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT, timeout=900) + except subprocess.TimeoutExpired as e: + raise RuntimeError(f"Its installer didn't finish in 15 minutes (log: {log}).") from e + lines = [l.strip() for l in log.read_text(errors="replace").splitlines() if l.strip()] + tail = " ".join(lines[-2:])[:200] + if r.returncode != 0: + raise RuntimeError(f"Its installer failed: {tail or 'no output'} (log: {log})") + return tail + + class Jobs: """One install at a time; state is shown by both front ends.""" def __init__(self): self.lock = threading.Lock() - self.state = {} # port id -> {"status", "progress", "message"} + self.state = {} # app id -> {"status", "progress", "message"} def set(self, pid, status, progress=0.0, message=""): self.state[pid] = {"status": status, "progress": progress, "message": message} def busy(self): - return any(s["status"] in ("downloading", "installing") for s in self.state.values()) + return any(s["status"] in ("downloading", "installing", "waiting") for s in self.state.values()) jobs = Jobs() +def app_mode(port): + return port.get("mode") or "game" + + +def target_dir(port, mode=None): + mode = mode or app_mode(port) + return GAMES_DIR / port["gameid"] if mode == "game" else APPS_DIR / port["id"] + + def install(pid, tag=None): port = catalog()[pid] + staging = archive = None try: rels = releases(port, force=True) if not rels: - raise RuntimeError("This repo has no release with a matching download yet.") - rel = rels[0] if tag is None else next((r for r in rels if r["tag"] == tag), None) + raise RuntimeError("This repo has no release with a download for the Frame yet.") + rel = latest_release(port, rels) if tag is None else next((r for r in rels if r["tag"] == tag), None) if rel is None: - raise RuntimeError(f"Release {tag} not found.") - final = GAMES_DIR / port["gameid"] - if final.exists() and game_running(final): - raise RuntimeError("Close the game first.") + raise RuntimeError(f"Release {tag} not found." if tag else "Only pre-releases so far. Turn on " + "pre-releases for this app, or pick a version.") # Download CACHE_DIR.mkdir(parents=True, exist_ok=True) @@ -345,24 +742,36 @@ def install(pid, tag=None): if total: jobs.set(pid, "downloading", done / total, f"Downloading {rel['tag']}") - # Unpack into a staging folder next to the final one (same filesystem, so the swap is a rename) + # Unpack into a staging folder (moved into place with a rename once it's ready) jobs.set(pid, "installing", 1.0, "Unpacking") - GAMES_DIR.mkdir(parents=True, exist_ok=True) - staging = GAMES_DIR / f".{port['gameid']}.staging" + stage_root = DATA_DIR / "staging" + stage_root.mkdir(parents=True, exist_ok=True) + staging = stage_root / pid shutil.rmtree(staging, ignore_errors=True) staging.mkdir() - if port.get("type", "zip") == "appimage": + kind = port.get("type") or asset_type(rel["asset_name"]) + if kind == "appimage": moved = staging / archive.name shutil.move(str(archive), moved) extract_appimage(moved, staging) + elif kind == "tar": + extract_tar(archive, staging) + elif kind == "binary": + shutil.move(str(archive), staging / rel["asset_name"]) else: extract_zip(archive, staging) - archive.unlink(missing_ok=True) - if port.get("run_sh"): - launcher = staging / port.get("launch", "run.sh") - launcher.write_text("\n".join(port["run_sh"]) + "\n") - if not (staging / port.get("launch", "run.sh")).exists(): - raise RuntimeError(f"The download has no {port.get('launch', 'run.sh')}.") + archive.unlink(missing_ok=True) + make_executables(staging) + + mode = app_mode(port) + if mode == "auto": + mode = "installer" if find_installer(staging, port) and not port.get("run_sh") else "game" + update_user_entry(pid, mode=mode) + port["mode"] = mode + final = target_dir(port, mode) + if final.exists() and game_running(final): + raise RuntimeError("Close the app first.") + program = write_launcher(staging, port) if mode == "game" else None # Carry over files the user put in the old install (ROMs next to run.sh) if final.exists(): @@ -372,58 +781,128 @@ def install(pid, tag=None): shutil.copy2(f, staging / f.name) make_executables(staging) - # Swap: the old install becomes the "previous" one - previous = GAMES_DIR / f".{port['gameid']}.previous" + # Swap: the old install becomes the "previous" one. Staging is on the same filesystem as the games folder + # on the Frame (both in the home directory); a move covers the case where it isn't. + final.parent.mkdir(parents=True, exist_ok=True) + previous = final.parent / f".{final.name}.previous" if final.exists(): if game_running(final): - raise RuntimeError("Close the game first.") + raise RuntimeError("Close the app first.") shutil.rmtree(previous, ignore_errors=True) os.replace(final, previous) - os.replace(staging, final) + shutil.move(str(staging), final) + staging = None - jobs.set(pid, "installing", 1.0, "Adding to Steam") - note = register_with_steam(port, final) - add_art(port) + if mode == "game": + jobs.set(pid, "installing", 1.0, "Adding to Steam") + note = register_with_steam(port, final) + add_art(port) + message = note or f"Installed {rel['tag']}. It's in your library." + if program and not note: + message += f" Starts {program}." + else: + jobs.set(pid, "installing", 1.0, "Running its installer") + try: + tail = run_installer(final, port) + except RuntimeError: + # Put back what was there before, so the list shows what's really installed. + shutil.rmtree(final, ignore_errors=True) + if previous.exists(): + os.replace(previous, final) + 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())} + installed[pid] = {"tag": rel["tag"], "previous": prev, "time": int(time.time()), "mode": mode} save_json(INSTALLED_FILE, installed) - message = note or f"Installed {rel['tag']}." 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")): message += " " + port["rom_hint"] jobs.set(pid, "done", 1.0, message) except Exception as e: # report every failure on screen rather than dying - shutil.rmtree(GAMES_DIR / f".{port['gameid']}.staging", ignore_errors=True) jobs.set(pid, "error", 0.0, str(e)) + finally: + if staging is not None: + shutil.rmtree(staging, ignore_errors=True) + if archive is not None: + archive.unlink(missing_ok=True) def rollback(pid): port = catalog()[pid] - final = GAMES_DIR / port["gameid"] - previous = GAMES_DIR / f".{port['gameid']}.previous" try: + installed = load_json(INSTALLED_FILE, {}) + cur = installed.get(pid, {}) + mode = cur.get("mode") or app_mode(port) + final = target_dir(port, mode) + previous = final.parent / f".{final.name}.previous" if not previous.exists(): raise RuntimeError("There's no previous version to go back to.") if final.exists() and game_running(final): - raise RuntimeError("Close the game first.") - swap = GAMES_DIR / f".{port['gameid']}.swap" + raise RuntimeError("Close the app first.") + swap = final.parent / f".{final.name}.swap" if final.exists(): os.replace(final, swap) os.replace(previous, final) if swap.exists(): os.replace(swap, previous) - installed = load_json(INSTALLED_FILE, {}) - cur = installed.get(pid, {}) - installed[pid] = {"tag": cur.get("previous"), "previous": cur.get("tag"), "time": int(time.time())} + 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) 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)) +def remove(pid, delete_files=True): + """Takes an app off the list. Its files go too (an installer app's uninstaller runs first); saves and settings + outside its folder stay.""" + port = catalog()[pid] + try: + installed = load_json(INSTALLED_FILE, {}) + mode = installed.get(pid, {}).get("mode") or app_mode(port) + note = "" + if delete_files and mode != "auto": + final = target_dir(port, mode) + if final.exists() and game_running(final): + raise RuntimeError("Close the app first.") + if mode == "installer" and final.exists(): + jobs.set(pid, "installing", 1.0, "Running its uninstaller") + run_installer(final, port, uninstall=True) + if final.exists() and mode == "game": + 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) + jobs.state.pop(pid, None) + _latest.pop(pid, None) + jobs.set("_list", "done", 1.0, f"Removed {port['name']}.{note}") + except Exception as e: + jobs.set(pid, "error", 0.0, str(e)) + + +def update_all(_pid=None): + """Updates every installed app that has a newer release, one after another.""" + jobs.set("_list", "installing", 0.0, "Updating everything") + count = 0 + for row in app_state(): + if row["status"] == "update available": + install(row["id"]) + count += 1 + jobs.set("_list", "done", 1.0, f"Updated {count} app{'s' if count != 1 else ''}." if count + else "Everything is up to date.") + + def start(fn, pid, *args): with jobs.lock: if jobs.busy(): @@ -436,33 +915,39 @@ def start(fn, pid, *args): # ---------------------------------------------------------------- state -_latest = {} # port id -> (tag, error) +_latest = {} # app id -> (tag, error) def refresh(force=False): for pid, port in catalog().items(): try: rels = releases(port, force=force) - _latest[pid] = (rels[0]["tag"] if rels else None, None if rels else "No releases yet") + rel = latest_release(port, rels) + _latest[pid] = (rel["tag"] if rel else None, + None if rel else ("Only pre-releases so far" if rels else "No releases for the Frame yet")) except RuntimeError as e: _latest[pid] = (None, str(e)) -def port_state(): +def app_state(): installed = load_json(INSTALLED_FILE, {}) out = [] for pid, port in catalog().items(): - folder = GAMES_DIR / port["gameid"] - tag = installed.get(pid, {}).get("tag") if folder.exists() else None - if folder.exists() and not tag: + mode = installed.get(pid, {}).get("mode") or app_mode(port) + folder = target_dir(port, mode) if mode != "auto" else None + present = bool(folder and folder.exists()) + tag = installed.get(pid, {}).get("tag") if present else None + if present and not tag: tag = "unknown" + if pid not in _latest: + refresh_one(pid, port) latest, err = _latest.get(pid, (None, None)) job = jobs.state.get(pid, {}) - if job.get("status") in ("downloading", "installing"): + if job.get("status") in ("downloading", "installing", "waiting"): status = job["status"] - elif err and not latest: + elif err and not latest and not present: status = "error" - elif not folder.exists(): + elif not present: status = "not installed" elif latest and tag != latest: status = "update available" @@ -472,11 +957,28 @@ def port_state(): "id": pid, "name": port["name"], "installed": tag, "latest": latest, "status": status, "progress": job.get("progress", 0.0), "message": job.get("message") or err or "", "job": job.get("status", ""), - "can_rollback": (GAMES_DIR / f".{port['gameid']}.previous").exists(), + "can_rollback": bool(folder and (folder.parent / f".{folder.name}.previous").exists()), + "repo": port["repo"], "mode": mode, "prereleases": bool(port.get("prereleases")), + "builtin": bool(port.get("builtin")), "description": port.get("description", ""), + "program": port.get("program", ""), }) return out +def refresh_one(pid, port): + """Fills in the latest version of an app that hasn't been checked yet (just added), in the background.""" + _latest[pid] = (None, None) + + def work(): + try: + rels = releases(port) + rel = latest_release(port, rels) + _latest[pid] = (rel["tag"] if rel else None, None if rel else "Only pre-releases so far") + except RuntimeError as e: + _latest[pid] = (None, str(e)) + threading.Thread(target=work, daemon=True).start() + + def tsv(rows, keys): """Tab-separated lines for the headset app (no JSON parser needed there).""" def clean(v): @@ -486,7 +988,7 @@ def tsv(rows, keys): return "1" if v else "0" if isinstance(v, float): return f"{v:.3f}" - return re.sub(r"[\t\r\n]+", " ", str(v)) + return re.sub(r"[\t\r\n]+", " ", str(v)) or "-" return "\n".join("\t".join(clean(r.get(k)) for k in keys) for r in rows) + "\n" @@ -494,7 +996,7 @@ def tsv(rows, keys): class Handler(BaseHTTPRequestHandler): - server_version = "PortManager/1.0" + server_version = "PortManager/2.0" def log_message(self, fmt, *args): sys.stderr.write("%s %s\n" % (self.address_string(), fmt % args)) @@ -509,6 +1011,12 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) + def fail(self, message, fmt, code=400): + if fmt == "tsv": + self.reply(f"!\t{message}\n", "text/plain") + 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. @@ -526,29 +1034,50 @@ class Handler(BaseHTTPRequestHandler): self.reply({"error": "password"}, code=401) return fmt = q.get("fmt", ["json"])[0] + arg = lambda k: q.get(k, [""])[0] if url.path == "/api/state": - rows = port_state() + rows = app_state() + notice = jobs.state.get("_list", {}) if fmt == "tsv": - self.reply(tsv(rows, ["id", "name", "installed", "latest", "status", "progress", "message", - "can_rollback"]), "text/plain") + head = f"#\t{notice.get('status', '')}\t{notice.get('message', '')}\n" if notice else "" + self.reply(head + tsv(rows, ["id", "name", "installed", "latest", "status", "progress", "message", + "can_rollback", "repo", "mode", "prereleases", "builtin", "job"]), + "text/plain") else: - self.reply({"ports": rows, "token_set": bool(config().get("github_token")), - "busy": jobs.busy()}) + self.reply({"apps": rows, "token_set": bool(config().get("github_token")), "busy": jobs.busy(), + "notice": notice}) elif url.path == "/api/releases": - pid = q.get("id", [""])[0] - port = catalog().get(pid) + port = catalog().get(arg("id")) if not port: - self.reply({"error": "unknown port"}, code=404) + self.fail("Unknown app.", fmt, 404) return try: rels = releases(port) except RuntimeError as e: - self.reply({"error": str(e)} if fmt != "tsv" else f"!\t{e}\n", "application/json" if fmt != "tsv" else "text/plain") + self.fail(str(e), fmt) return if fmt == "tsv": self.reply(tsv(rels, ["tag", "name", "date", "prerelease"]), "text/plain") else: self.reply({"releases": rels}) + elif url.path == "/api/inspect": + try: + info = inspect_repo(arg("repo")) + except RuntimeError as e: + self.fail(str(e), fmt) + return + if fmt == "tsv": + head = tsv([info], ["repo", "name", "description", "tag", "date", "prereleases", "existing"]) + self.reply("=\t" + head + tsv(info["assets"], ["name", "size", "arm64"]), "text/plain") + else: + self.reply(info) + elif url.path == "/api/suggestions": + have = {p["repo"].lower() for p in catalog().values()} + rows = [{**s, "added": s["repo"].lower() in have} for s in suggestions()] + if fmt == "tsv": + self.reply(tsv(rows, ["repo", "name", "note", "added"]), "text/plain") + else: + self.reply({"suggestions": rows}) else: self.reply({"error": "not found"}, code=404) @@ -558,17 +1087,60 @@ class Handler(BaseHTTPRequestHandler): if not self.allowed(q): self.reply({"error": "password"}, code=401) return - pid = q.get("id", [""])[0] + fmt = q.get("fmt", ["json"])[0] + arg = lambda k: q.get(k, [""])[0] + pid = arg("id") if url.path == "/api/refresh": + _releases_cache.clear() threading.Thread(target=refresh, kwargs={"force": True}, daemon=True).start() self.reply({"ok": True}) - elif url.path in ("/api/install", "/api/rollback"): + elif url.path == "/api/update_all": + ok = start(update_all, "_list") + self.reply({"ok": ok, "error": None if ok else "Something else is installing."}) + elif url.path in ("/api/install", "/api/rollback", "/api/remove"): if pid not in catalog(): - self.reply({"error": "unknown port"}, code=404) + self.fail("Unknown app.", fmt, 404) return - tag = q.get("tag", [None])[0] or None - ok = start(install, pid, tag) if url.path == "/api/install" else start(rollback, pid) - self.reply({"ok": ok, "error": None if ok else "Another install is running."}) + if url.path == "/api/install": + ok = start(install, pid, arg("tag") or None) + elif url.path == "/api/rollback": + ok = start(rollback, pid) + else: + ok = start(remove, pid, arg("keep_files") != "1") + self.reply({"ok": ok, "error": None if ok else "Something else is installing."}) + elif url.path == "/api/add": + if jobs.busy(): + self.fail("Wait for the current install to finish.", fmt) + return + try: + new = add_app(arg("repo"), arg("asset") or None, arg("name") or None, arg("mode") or "auto") + except RuntimeError as e: + self.fail(str(e), fmt) + return + if arg("install") == "1": + start(install, new) + self.reply(f"=\t{new}\n" if fmt == "tsv" else {"ok": True, "id": new}, + "text/plain" if fmt == "tsv" else "application/json") + elif url.path == "/api/settings": + port = catalog().get(pid) + if not port: + self.fail("Unknown app.", fmt, 404) + return + fields = {} + if "prereleases" in q: + fields["prereleases"] = arg("prereleases") == "1" + if arg("name").strip(): + fields["name"] = arg("name").strip()[:80] + if arg("mode") in ("auto", "game", "installer"): + fields["mode"] = arg("mode") + if "program" in q: + fields["program"] = arg("program").strip().lstrip("/") + if "asset" in q and arg("asset").strip() and not port.get("builtin"): + fields["asset"] = arg("asset").strip() + update_user_entry(pid, **fields) + _releases_cache.clear() + _latest.pop(pid, None) + self.reply({"ok": True}) elif url.path == "/api/token": length = int(self.headers.get("Content-Length") or 0) token = self.rfile.read(min(length, 4096)).decode().strip() diff --git a/page.html b/page.html index c4e4ab0..308fa98 100644 --- a/page.html +++ b/page.html @@ -12,41 +12,65 @@ font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } main { max-width: 760px; margin: 0 auto; padding: 20px 16px 48px; } h1 { font-size: 1.5rem; margin: 0 0 4px; } - .sub { color: var(--muted); margin: 0 0 20px; } + h2 { font-size: 1.05rem; margin: 0 0 10px; } + a { color: var(--accent); } + .sub { color: var(--muted); margin: 0 0 16px; } .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 16px; margin-bottom: 14px; } .top { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; flex-wrap: wrap; } .name { font-weight: 650; font-size: 1.05rem; } + .repo { color: var(--muted); font-size: .85rem; text-decoration: none; } .badge { font-size: .85rem; padding: 2px 10px; border-radius: 99px; border: 1px solid var(--line); color: var(--muted); white-space: nowrap; } .badge.update { color: var(--warn); border-color: var(--warn); } .badge.ok { color: var(--ok); border-color: var(--ok); } .badge.error { color: var(--bad); border-color: var(--bad); } .badge.busy { color: var(--accent); border-color: var(--accent); } .ver { color: var(--muted); font-size: .92rem; margin: 6px 0 10px; } - .msg { font-size: .92rem; margin: 8px 0 0; } + .msg { font-size: .92rem; margin: 8px 0 0; overflow-wrap: anywhere; } .msg.error { color: var(--bad); } .msg.done { color: var(--ok); } .row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } + .grow { flex: 1; min-width: 200px; } button, select, input { font: inherit; border-radius: 8px; } button { padding: 8px 14px; border: 1px solid var(--line); background: #222a33; color: var(--text); cursor: pointer; } button.primary { background: var(--accent); border-color: var(--accent); color: #fff; } + button.danger { color: var(--bad); } button:disabled { opacity: .5; cursor: default; } select, input { background: var(--bg); color: var(--text); border: 1px solid var(--line); padding: 8px 10px; max-width: 100%; } progress { width: 100%; height: 8px; margin-top: 10px; accent-color: var(--accent); } details summary { cursor: pointer; color: var(--muted); } + details[open] summary { margin-bottom: 10px; } .hint { color: var(--muted); font-size: .9rem; } - input[type=password] { flex: 1; min-width: 200px; } + .chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } + .chip { text-align: left; padding: 8px 12px; max-width: 100%; } + .chip small { display: block; color: var(--muted); font-size: .8rem; } + .found { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 12px; } + .notice { color: var(--ok); margin: 0 0 12px; } + label { display: flex; gap: 8px; align-items: center; }
-

Port Manager

-

Install and update your Steam Frame ports from their GitHub releases. Saves and ROMs are kept.

-
+

Port Manager

+
+

Install and update Steam Frame apps and ports from their GitHub releases. Saves and ROMs are kept.

+ + +
+

Add an app from GitHub

+
+
+ + +
+
+ +
+
Settings

Private repos need a GitHub token with read access to their contents (a fine-grained token limited to those repos is enough). It's stored only on the Frame and never shown again.

-
+

@@ -59,6 +83,7 @@ const $ = id => document.getElementById(id); const q = encodeURIComponent; 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() }); @@ -73,6 +98,11 @@ async function post(path, body) { const r = await fetch(path, { method: "POST", headers: hdr(), body }); return r.json(); } +function el(tag, props = {}, ...kids) { + const e = Object.assign(document.createElement(tag), props); + e.append(...kids); + return e; +} function badge(p) { if (p.status === "downloading" || p.status === "installing") return ["busy", p.status === "downloading" ? "Downloading" : "Installing"]; @@ -81,6 +111,7 @@ function badge(p) { if (p.status === "error") return ["error", "Problem"]; return ["", "Not installed"]; } +const modeText = { game: "In the Steam library", installer: "Set up by its installer", auto: "Decided on first install" }; async function loadReleases(p, sel) { if (!releases[p.id]) { @@ -89,19 +120,18 @@ async function loadReleases(p, sel) { } sel.innerHTML = ""; for (const r of releases[p.id]) { - const o = document.createElement("option"); - o.value = r.tag; - o.textContent = `${r.tag}${r.tag === p.latest ? " (latest)" : ""}${r.tag === p.installed ? " (installed)" : ""} · ${r.date}`; - sel.append(o); + sel.append(el("option", { value: r.tag, textContent: + `${r.tag}${r.prerelease ? " (pre-release)" : ""}${r.tag === p.latest ? " (latest)" : ""}${r.tag === p.installed ? " (installed)" : ""} · ${r.date}` })); } + if (!sel.options.length) sel.append(el("option", { textContent: "No versions" })); } function card(p, busy) { - const div = document.createElement("div"); - div.className = "panel"; + const div = el("div", { className: "panel" }); const [cls, label] = badge(p); div.innerHTML = ` -
${label}
+

+ ${label}
@@ -109,12 +139,28 @@ function card(p, busy) {
-

`; +

+
Edit +
+

+ +

+
How it's installed: +
+

+
+

Used for library apps without their own run.sh. Takes effect on the next install.

+
+
`; div.querySelector(".name").textContent = p.name; + const link = div.querySelector(".repo"); + link.textContent = p.repo; link.href = `https://github.com/${p.repo}`; div.querySelector(".ver").textContent = - `Installed: ${p.installed === null ? "—" : p.installed} · Latest: ${p.latest === null ? "—" : p.latest}`; + `Installed: ${p.installed === null ? "—" : p.installed} · Latest: ${p.latest === null ? "—" : p.latest} · ${modeText[p.mode] || p.mode}`; const act = div.querySelector(".act"); - act.textContent = p.status === "not installed" ? "Install" : p.status === "update available" ? "Update" : "Reinstall"; + act.textContent = p.status === "not installed" || p.status === "error" && !p.installed ? "Install" + : p.status === "update available" ? "Update" : "Reinstall"; const working = p.status === "downloading" || p.status === "installing"; for (const b of div.querySelectorAll("button")) b.disabled = busy; div.querySelector(".back").disabled = busy || !p.can_rollback; @@ -126,35 +172,117 @@ function card(p, busy) { div.querySelector(".back").onclick = () => post(`/api/rollback?id=${q(p.id)}`).then(poll); const msg = div.querySelector(".msg"); msg.textContent = p.message; - msg.className = "msg " + (p.status === "error" || (p.message && p.job === "error") ? "error" : p.job === "done" ? "done" : ""); + msg.className = "msg " + (p.job === "error" || p.status === "error" ? "error" : p.job === "done" ? "done" : ""); if (working) { - const bar = document.createElement("progress"); - bar.max = 1; bar.value = p.progress; - div.append(bar); + const bar = el("progress", { max: 1, value: p.progress }); + div.insertBefore(bar, msg); } + const edit = div.querySelector(".edit"); + edit.open = !!open[p.id]; + edit.ontoggle = () => { open[p.id] = edit.open; }; + div.querySelector(".ename").value = p.name; + div.querySelector(".epre").checked = p.prereleases; + div.querySelector(".emode").value = p.mode; + div.querySelector(".eprog").value = p.program || ""; + div.querySelector(".save").onclick = async () => { + const pre = div.querySelector(".epre").checked ? 1 : 0; + await post(`/api/settings?id=${q(p.id)}&name=${q(div.querySelector(".ename").value)}&prereleases=${pre}` + + `&mode=${q(div.querySelector(".emode").value)}&program=${q(div.querySelector(".eprog").value)}`); + delete releases[p.id]; last = ""; poll(); + }; + div.querySelector(".rm").onclick = async () => { + const what = p.installed ? " Its files are deleted too (saves and settings kept elsewhere stay)." : ""; + if (!confirm(`Remove ${p.name}?${what}`)) return; + await post(`/api/remove?id=${q(p.id)}`); + delete open[p.id]; setTimeout(poll, 600); + }; return div; } +// ---- adding apps + +async function lookup(repo, name) { + $("adderr").hidden = true; + $("found").hidden = false; + $("found").textContent = "Looking it up…"; + const info = await get(`/api/inspect?repo=${q(repo)}`); + if (info.error) { + $("found").hidden = true; + $("adderr").textContent = info.error; $("adderr").hidden = false; + return; + } + const f = $("found"); + f.innerHTML = ""; + f.append( + el("div", { className: "name", textContent: info.name }), + el("a", { className: "repo", href: `https://github.com/${info.repo}`, target: "_blank", textContent: info.repo }), + el("p", { className: "hint", textContent: (info.description || "") }), + el("p", { className: "hint", textContent: `Latest: ${info.tag} · ${info.date}${info.prereleases ? " (pre-release; pre-releases will be included)" : ""}` })); + if (info.existing) { + f.append(el("p", { className: "msg done", textContent: "It's already on your list." })); + return; + } + const nameIn = el("input", { className: "grow", value: name || info.name }); + const assetSel = el("select"); + for (const a of info.assets) + assetSel.append(el("option", { value: a.name, textContent: `${a.name} (${(a.size / 1048576).toFixed(1)} MB)${a.arm64 ? "" : " – not ARM64"}` })); + const modeSel = el("select"); + for (const [v, t] of [["auto", "Decide from the download"], ["game", "Add to the Steam library"], ["installer", "Run its install.sh"]]) + modeSel.append(el("option", { value: v, textContent: t })); + const add = async install => { + const r = await post(`/api/add?repo=${q(info.repo)}&asset=${q(assetSel.value)}&name=${q(nameIn.value)}&mode=${q(modeSel.value)}&install=${install}`); + if (r.error) { $("adderr").textContent = r.error; $("adderr").hidden = false; return; } + f.hidden = true; $("repo").value = ""; last = ""; poll(); loadSuggestions(); + }; + f.append( + el("div", { className: "row" }, "Name", nameIn), el("p"), + el("div", { className: "row" }, "Download", assetSel), el("p"), + el("div", { className: "row" }, "Install", modeSel), el("p"), + el("div", { className: "row" }, + el("button", { className: "primary", textContent: "Add and install", onclick: () => add(1) }), + el("button", { textContent: "Add", onclick: () => add(0) }))); + if (!info.assets.some(a => a.arm64)) + f.append(el("p", { className: "msg error", textContent: "None of its downloads say ARM64, so they may not run on the Frame." })); +} + +async function loadSuggestions() { + const s = await get("/api/suggestions"); + const box = $("suggest"); + box.innerHTML = ""; + for (const x of (s.suggestions || []).filter(x => !x.added)) { + box.append(el("button", { className: "chip", onclick: () => { $("repo").value = x.repo; lookup(x.repo, x.name); } }, + x.name, el("small", { textContent: x.note }))); + } + if (box.children.length) box.prepend(el("div", { className: "hint", style: "width:100%", textContent: "Suggested for the Frame:" })); +} + let timer = null, last = ""; async function poll() { const s = await get("/api/state"); const snapshot = JSON.stringify(s); if (snapshot !== last) { // redraw only on changes, so an open version list isn't reset last = snapshot; - const box = $("ports"); + const box = $("apps"); box.innerHTML = ""; - for (const p of s.ports) box.append(card(p, s.busy)); + for (const p of s.apps) box.append(card(p, s.busy)); + const n = s.notice || {}; + $("notice").hidden = !n.message; + $("notice").textContent = n.message || ""; + $("updateall").disabled = s.busy; } $("tokenstate").textContent = s.token_set ? "A token is saved." : "No token saved."; clearTimeout(timer); timer = setTimeout(poll, s.busy ? 700 : 5000); } +$("lookup").onclick = () => lookup($("repo").value); +$("repo").onkeydown = e => { if (e.key === "Enter") lookup($("repo").value); }; $("refresh").onclick = async () => { for (const k in releases) delete releases[k]; await post("/api/refresh"); setTimeout(poll, 2500); }; +$("updateall").onclick = () => post("/api/update_all").then(() => setTimeout(poll, 300)); $("savetoken").onclick = async () => { await post("/api/token", $("token").value.trim()); $("token").value = ""; @@ -162,6 +290,7 @@ $("savetoken").onclick = async () => { setTimeout(poll, 2500); }; poll(); +loadSuggestions(); diff --git a/suggestions.json b/suggestions.json new file mode 100644 index 0000000..1d1855f --- /dev/null +++ b/suggestions.json @@ -0,0 +1,22 @@ +[ + { + "repo": "sasaken1102r/frame-perf-overlay", + "name": "Frame Perf Overlay", + "note": "fps, CPU/GPU, temperatures and battery in the SteamVR dashboard" + }, + { + "repo": "sasaken1102r/frame-mic-tuner", + "name": "Frame Mic Tuner", + "note": "Switch the mic's echo cancellation and noise suppression from the dashboard" + }, + { + "repo": "beko-kerbecotton/framecolor", + "name": "Frame Color", + "note": "Adjust the display's colours from the dashboard (restarts SteamVR)" + }, + { + "repo": "stephen-moskal/openmw-vr-steam-frame", + "name": "OpenMW-VR", + "note": "Morrowind in VR on the headset; needs Morrowind installed from Steam" + } +]