mirror of
https://github.com/Cogential/frametanium.git
synced 2026-10-17 20:00:27 +02:00
Port Manager: install and update Steam Frame ports from GitHub releases
manager.py is a standard-library service on the Frame (port 8643). It lists each catalog port's releases, downloads the chosen asset (zip, or an AppImage it unpacks with --appimage-extract and gives a launcher), stages it next to ~/devkit-game/<gameid> and swaps it in, keeping ROMs placed next to run.sh and the previous install for rollback. It refuses while the game runs, fixes executable bits, registers the shortcut with Steam and adds library art. A token in the settings unlocks private repos. app/ is the controller-driven headset front end (C + SDL2, talks to the service over loopback with tab-separated replies); page.html is the browser one. deploy.sh installs both over SSH. Tested on the laptop against the real releases: install, version switch, ROM carry-over, rollback, the running-game guard, the AppImage path (under qemu), and both front ends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHDaz86aayqMULHsjByEmT
This commit is contained in:
commit
65c6ad3eaa
17 files changed
+1637
No files matched your search
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
dist/
|
||||
@@ -0,0 +1,54 @@
|
||||
# Port Manager for the Steam Frame
|
||||
|
||||
Installs and updates native Steam Frame ports straight from their GitHub releases, 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.
|
||||
|
||||
## 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://<frame-ip>:8643`.
|
||||
|
||||
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.
|
||||
|
||||
## Private repos
|
||||
GitHub only shows releases of private repos to signed-in users. Either make the port repos public (they contain no
|
||||
ROMs), or save a token in the web page's Settings: a fine-grained token limited to those repos with read-only
|
||||
**Contents** access is enough. It's stored in `~/.config/port-manager/config.json` (mode 600) and never sent back
|
||||
to the page.
|
||||
|
||||
## Install
|
||||
From a computer that can SSH into the Frame:
|
||||
|
||||
```sh
|
||||
app/build-arm64.sh # cross-compiles the headset app into dist/app
|
||||
./deploy.sh [host] # host defaults to frame.local
|
||||
```
|
||||
|
||||
Service only, on the Frame: copy this folder over and run `sh install.sh`.
|
||||
Uninstall: `systemctl --user disable --now frame-port-manager.service` and remove Port Manager from the library.
|
||||
|
||||
## Notes
|
||||
- The service listens on port 8643 on your network. Set `password` in `~/.config/port-manager/config.json` to require
|
||||
one in the browser (the headset app connects locally and doesn't need it).
|
||||
- Standard-library Python only. The headset app is C + SDL2 with the public-domain
|
||||
[font8x8](https://github.com/dhepper/font8x8).
|
||||
- Testing on another architecture: `PORT_MANAGER_EXEC_WRAPPER=qemu-aarch64-static` (with `QEMU_LD_PREFIX`) runs
|
||||
ARM64 AppImages for unpacking; `PORT_MANAGER_GAMES` changes the install folder.
|
||||
@@ -0,0 +1,468 @@
|
||||
/* Port Manager for the headset: a controller-driven front end for the Port Manager service (manager.py), which
|
||||
* 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. */
|
||||
|
||||
#include <SDL.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/time.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "third_party/font8x8_basic.h"
|
||||
|
||||
#define DEFAULT_PORT 8643
|
||||
#define MAX_PORTS 32
|
||||
#define MAX_RELEASES 40
|
||||
|
||||
typedef struct {
|
||||
char id[64], name[128], installed[64], latest[64], status[32], message[256];
|
||||
float progress;
|
||||
int can_rollback;
|
||||
} Port;
|
||||
|
||||
typedef struct {
|
||||
char tag[64], name[128], date[16];
|
||||
int prerelease;
|
||||
} Release;
|
||||
|
||||
static SDL_Renderer* ren;
|
||||
static int W, H, S; /* screen size and text scale */
|
||||
static int service_port = DEFAULT_PORT;
|
||||
|
||||
/* ---------------------------------------------------------------- text */
|
||||
|
||||
static void color(Uint32 rgb) {
|
||||
SDL_SetRenderDrawColor(ren, (rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff, 255);
|
||||
}
|
||||
|
||||
static int text(const char* s, int x, int y, int scale, Uint32 rgb) {
|
||||
static SDL_Rect rects[4096];
|
||||
int n = 0, cx = x;
|
||||
color(rgb);
|
||||
for (; *s; s++) {
|
||||
unsigned char c = (unsigned char)*s;
|
||||
if (c >= 128) c = '?';
|
||||
for (int row = 0; row < 8; row++) {
|
||||
unsigned char bits = (unsigned char)font8x8_basic[c][row];
|
||||
for (int col = 0; col < 8; col++)
|
||||
if (bits & (1 << col)) {
|
||||
rects[n++] = (SDL_Rect){ cx + col * scale, y + row * scale, scale, scale };
|
||||
if (n == 4096) {
|
||||
SDL_RenderFillRects(ren, rects, n);
|
||||
n = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
cx += 8 * scale;
|
||||
}
|
||||
if (n) SDL_RenderFillRects(ren, rects, n);
|
||||
return cx - x;
|
||||
}
|
||||
|
||||
/* 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];
|
||||
int max_chars = max_px / (8 * scale);
|
||||
snprintf(buf, sizeof buf, "%s", s);
|
||||
if ((int)strlen(buf) > max_chars && max_chars > 3) {
|
||||
buf[max_chars - 3] = '\0';
|
||||
strcat(buf, "...");
|
||||
}
|
||||
text(buf, x, y, scale, rgb);
|
||||
}
|
||||
|
||||
static void fill(int x, int y, int w, int h, Uint32 rgb) {
|
||||
color(rgb);
|
||||
SDL_RenderFillRect(ren, &(SDL_Rect){ x, y, w, h });
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- service */
|
||||
|
||||
static void read_port(void) {
|
||||
char path[512], buf[2048];
|
||||
snprintf(path, sizeof path, "%s/.config/port-manager/config.json", getenv("HOME") ? getenv("HOME") : "");
|
||||
FILE* f = fopen(path, "r");
|
||||
if (!f) return;
|
||||
size_t n = fread(buf, 1, sizeof buf - 1, f);
|
||||
fclose(f);
|
||||
buf[n] = '\0';
|
||||
const char* p = strstr(buf, "\"port\"");
|
||||
if (p && (p = strchr(p, ':'))) {
|
||||
int v = atoi(p + 1);
|
||||
if (v > 0 && v < 65536) service_port = v;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sends a request to the service; returns the body (caller frees) or NULL if it isn't reachable. */
|
||||
static char* http(const char* method, const char* path) {
|
||||
int s = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (s < 0) return NULL;
|
||||
struct timeval tv = { .tv_sec = 5 };
|
||||
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);
|
||||
if (connect(s, (struct sockaddr*)&a, sizeof a) != 0) {
|
||||
close(s);
|
||||
return NULL;
|
||||
}
|
||||
char req[1024];
|
||||
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) {
|
||||
close(s);
|
||||
return NULL;
|
||||
}
|
||||
size_t cap = 65536, len = 0;
|
||||
char* buf = malloc(cap);
|
||||
ssize_t r;
|
||||
while ((r = read(s, buf + len, cap - len - 1)) > 0) {
|
||||
len += r;
|
||||
if (len + 1 >= cap) buf = realloc(buf, cap *= 2);
|
||||
}
|
||||
close(s);
|
||||
buf[len] = '\0';
|
||||
char* body = strstr(buf, "\r\n\r\n");
|
||||
if (!body) {
|
||||
free(buf);
|
||||
return NULL;
|
||||
}
|
||||
memmove(buf, body + 4, strlen(body + 4) + 1);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/* Splits one tab-separated line into fields (in place). */
|
||||
static int split(char* line, char** f, int max) {
|
||||
int n = 0;
|
||||
f[n++] = line;
|
||||
for (char* p = line; *p && n < max; p++)
|
||||
if (*p == '\t') {
|
||||
*p = '\0';
|
||||
f[n++] = p + 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
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");
|
||||
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++];
|
||||
copy(p->id, sizeof p->id, f[0]);
|
||||
copy(p->name, sizeof p->name, f[1]);
|
||||
copy(p->installed, sizeof p->installed, f[2]);
|
||||
copy(p->latest, sizeof p->latest, f[3]);
|
||||
copy(p->status, sizeof p->status, f[4]);
|
||||
p->progress = (float)atof(f[5]);
|
||||
copy(p->message, sizeof p->message, f[6]);
|
||||
p->can_rollback = atoi(f[7]);
|
||||
}
|
||||
free(body);
|
||||
return n;
|
||||
}
|
||||
|
||||
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);
|
||||
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* 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;
|
||||
Release* r = &rels[n++];
|
||||
copy(r->tag, sizeof r->tag, f[0]);
|
||||
copy(r->name, sizeof r->name, f[1]);
|
||||
copy(r->date, sizeof r->date, f[2]);
|
||||
r->prerelease = atoi(f[3]);
|
||||
}
|
||||
free(body);
|
||||
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 void try_start_service(void) {
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
int devnull = open("/dev/null", O_WRONLY);
|
||||
if (devnull >= 0) {
|
||||
dup2(devnull, 1);
|
||||
dup2(devnull, 2);
|
||||
}
|
||||
execlp("systemctl", "systemctl", "--user", "start", "frame-port-manager.service", (char*)NULL);
|
||||
_exit(127);
|
||||
}
|
||||
if (pid > 0) waitpid(pid, NULL, 0);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- drawing */
|
||||
|
||||
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 const char* status_label(const Port* p) {
|
||||
if (!strcmp(p->status, "downloading")) return "Downloading";
|
||||
if (!strcmp(p->status, "installing")) return "Installing";
|
||||
if (!strcmp(p->status, "update available")) return "Update available";
|
||||
if (!strcmp(p->status, "up to date")) return "Up to date";
|
||||
if (!strcmp(p->status, "error")) return "Problem";
|
||||
return "Not installed";
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
if (banner && *banner) {
|
||||
text_fit(banner, m, y, S, W - 2 * m, 0xf0b429);
|
||||
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);
|
||||
}
|
||||
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];
|
||||
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);
|
||||
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(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 : "-",
|
||||
p->latest[0] ? p->latest : "-");
|
||||
text_fit(line, tx, ty, S, w - 20 * S, 0x93a0ad);
|
||||
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);
|
||||
} else if (p->message[0]) {
|
||||
text_fit(p->message, tx, ty, S, w - 20 * S, !strcmp(p->status, "error") ? 0xf2555a : 0x4cc38a);
|
||||
}
|
||||
y += row_h;
|
||||
}
|
||||
legend("A Install/Update Y Versions X Previous 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;
|
||||
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++) {
|
||||
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);
|
||||
y += row_h;
|
||||
}
|
||||
legend("A Install this version B Back");
|
||||
SDL_RenderPresent(ren);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- main */
|
||||
|
||||
enum { NONE, UP, DOWN, OK, BACK, VERSIONS, PREVIOUS, REFRESH };
|
||||
|
||||
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_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_START: return REFRESH;
|
||||
default: return NONE;
|
||||
}
|
||||
}
|
||||
|
||||
static int map_key(SDL_Keycode k) {
|
||||
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_r: return REFRESH;
|
||||
default: return NONE;
|
||||
}
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
/* PM_SCREENSHOT=<file.bmp> 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. */
|
||||
const char* shot = getenv("PM_SCREENSHOT");
|
||||
SDL_Surface* shot_surface = NULL;
|
||||
if (shot) {
|
||||
int sw = 1280, sh = 720;
|
||||
if (getenv("PM_SIZE")) sscanf(getenv("PM_SIZE"), "%dx%d", &sw, &sh);
|
||||
SDL_Init(0);
|
||||
shot_surface = SDL_CreateRGBSurfaceWithFormat(0, sw, sh, 32, SDL_PIXELFORMAT_ARGB8888);
|
||||
ren = SDL_CreateSoftwareRenderer(shot_surface);
|
||||
} else {
|
||||
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) != 0) {
|
||||
fprintf(stderr, "SDL_Init: %s\n", SDL_GetError());
|
||||
return 1;
|
||||
}
|
||||
Uint32 flags = getenv("PM_WINDOWED") ? 0 : SDL_WINDOW_FULLSCREEN_DESKTOP;
|
||||
SDL_Window* win = SDL_CreateWindow("Port Manager", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 1280, 720, flags);
|
||||
if (!win) return 1;
|
||||
ren = SDL_CreateRenderer(win, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC);
|
||||
if (!ren) ren = SDL_CreateRenderer(win, -1, SDL_RENDERER_SOFTWARE);
|
||||
}
|
||||
if (!ren) return 1;
|
||||
SDL_ShowCursor(SDL_DISABLE);
|
||||
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] = "";
|
||||
Uint32 next_poll = 0, axis_ready = 0;
|
||||
|
||||
for (;;) {
|
||||
int act = NONE;
|
||||
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 (SDL_GetTicks() >= next_poll) {
|
||||
nports = load_ports(ports);
|
||||
if (nports < 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);
|
||||
next_poll = SDL_GetTicks() + (busy ? 400 : 2000);
|
||||
if (sel >= nports) sel = nports > 0 ? nports - 1 : 0;
|
||||
snprintf(banner, sizeof banner, "%s",
|
||||
nports < 0 && tried_start && SDL_GetTicks() > 8000
|
||||
? "The Port Manager service isn't running. Restart the Frame and try again."
|
||||
: "");
|
||||
}
|
||||
|
||||
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 {
|
||||
if (act == UP && sel > 0) sel--;
|
||||
if (act == DOWN && sel < nports - 1) 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);
|
||||
rsel = 0;
|
||||
for (int i = 0; i < nrels; i++)
|
||||
if (!strcmp(rels[i].tag, ports[sel].installed)) rsel = i;
|
||||
in_versions = 1;
|
||||
}
|
||||
if (act != NONE) next_poll = SDL_GetTicks() + 150;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
SDL_Delay(16);
|
||||
}
|
||||
done:
|
||||
SDL_Quit();
|
||||
return 0;
|
||||
}
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
# Cross-compiles the headset app for the Steam Frame (ARM64 Linux) into ../dist/app.
|
||||
# Needs the NDK clang, the Ubuntu 24.04 arm64 sysroot and our SDL2 build (see the Lighthouse CLAUDE.md).
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
LLVM=${LLVM:-$HOME/Android/Sdk/ndk/28.2.13676358/toolchains/llvm/prebuilt/linux-x86_64/bin}
|
||||
SYSROOT=${SYSROOT:-/data/cogent/sysroots/noble-arm64}
|
||||
SDL=${SDL:-/data/cogent/sysroots/sdl2-arm64}
|
||||
OUT=../dist/app
|
||||
mkdir -p "$OUT/lib"
|
||||
"$LLVM/clang" --target=aarch64-linux-gnu --sysroot="$SYSROOT" -fuse-ld=lld -O2 -Wall -march=armv8.2-a \
|
||||
-I"$SDL/include/SDL2" -D_REENTRANT app.c -L"$SDL/lib" -lSDL2-2.0 -Wl,-rpath,'$ORIGIN/lib' \
|
||||
-o "$OUT/port-manager"
|
||||
"$LLVM/llvm-strip" "$OUT/port-manager"
|
||||
cp -L "$SDL/lib/libSDL2-2.0.so.0" "$OUT/lib/"
|
||||
cat > "$OUT/run.sh" <<'RUN'
|
||||
#!/bin/sh
|
||||
# Steam launches this. FrameDrop-style installs lose the executable bit, so restore it first.
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
chmod +x ./port-manager 2>/dev/null
|
||||
exec ./port-manager "$@"
|
||||
RUN
|
||||
chmod +x "$OUT/run.sh"
|
||||
echo "built $OUT"
|
||||
Vendored
+152
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 8x8 monochrome bitmap fonts for rendering
|
||||
* Author: Daniel Hepper <daniel@hepper.net>
|
||||
*
|
||||
* License: Public Domain
|
||||
*
|
||||
* Based on:
|
||||
* // Summary: font8x8.h
|
||||
* // 8x8 monochrome bitmap fonts for rendering
|
||||
* //
|
||||
* // Author:
|
||||
* // Marcel Sondaar
|
||||
* // International Business Machines (public domain VGA fonts)
|
||||
* //
|
||||
* // License:
|
||||
* // Public Domain
|
||||
*
|
||||
* Fetched from: http://dimensionalrift.homelinux.net/combuster/mos3/?p=viewsource&file=/modules/gfx/font8_8.asm
|
||||
**/
|
||||
|
||||
// Constant: font8x8_basic
|
||||
// Contains an 8x8 font map for unicode points U+0000 - U+007F (basic latin)
|
||||
char font8x8_basic[128][8] = {
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0000 (nul)
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0001
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0002
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0003
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0004
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0005
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0006
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0007
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0008
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0009
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000A
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000B
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000C
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000D
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000E
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000F
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0010
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0011
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0012
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0013
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0014
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0015
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0016
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0017
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0018
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0019
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001A
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001B
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001C
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001D
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001E
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001F
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0020 (space)
|
||||
{ 0x18, 0x3C, 0x3C, 0x18, 0x18, 0x00, 0x18, 0x00}, // U+0021 (!)
|
||||
{ 0x36, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0022 (")
|
||||
{ 0x36, 0x36, 0x7F, 0x36, 0x7F, 0x36, 0x36, 0x00}, // U+0023 (#)
|
||||
{ 0x0C, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x0C, 0x00}, // U+0024 ($)
|
||||
{ 0x00, 0x63, 0x33, 0x18, 0x0C, 0x66, 0x63, 0x00}, // U+0025 (%)
|
||||
{ 0x1C, 0x36, 0x1C, 0x6E, 0x3B, 0x33, 0x6E, 0x00}, // U+0026 (&)
|
||||
{ 0x06, 0x06, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0027 (')
|
||||
{ 0x18, 0x0C, 0x06, 0x06, 0x06, 0x0C, 0x18, 0x00}, // U+0028 (()
|
||||
{ 0x06, 0x0C, 0x18, 0x18, 0x18, 0x0C, 0x06, 0x00}, // U+0029 ())
|
||||
{ 0x00, 0x66, 0x3C, 0xFF, 0x3C, 0x66, 0x00, 0x00}, // U+002A (*)
|
||||
{ 0x00, 0x0C, 0x0C, 0x3F, 0x0C, 0x0C, 0x00, 0x00}, // U+002B (+)
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x06}, // U+002C (,)
|
||||
{ 0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x00}, // U+002D (-)
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x00}, // U+002E (.)
|
||||
{ 0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, 0x01, 0x00}, // U+002F (/)
|
||||
{ 0x3E, 0x63, 0x73, 0x7B, 0x6F, 0x67, 0x3E, 0x00}, // U+0030 (0)
|
||||
{ 0x0C, 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x3F, 0x00}, // U+0031 (1)
|
||||
{ 0x1E, 0x33, 0x30, 0x1C, 0x06, 0x33, 0x3F, 0x00}, // U+0032 (2)
|
||||
{ 0x1E, 0x33, 0x30, 0x1C, 0x30, 0x33, 0x1E, 0x00}, // U+0033 (3)
|
||||
{ 0x38, 0x3C, 0x36, 0x33, 0x7F, 0x30, 0x78, 0x00}, // U+0034 (4)
|
||||
{ 0x3F, 0x03, 0x1F, 0x30, 0x30, 0x33, 0x1E, 0x00}, // U+0035 (5)
|
||||
{ 0x1C, 0x06, 0x03, 0x1F, 0x33, 0x33, 0x1E, 0x00}, // U+0036 (6)
|
||||
{ 0x3F, 0x33, 0x30, 0x18, 0x0C, 0x0C, 0x0C, 0x00}, // U+0037 (7)
|
||||
{ 0x1E, 0x33, 0x33, 0x1E, 0x33, 0x33, 0x1E, 0x00}, // U+0038 (8)
|
||||
{ 0x1E, 0x33, 0x33, 0x3E, 0x30, 0x18, 0x0E, 0x00}, // U+0039 (9)
|
||||
{ 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00}, // U+003A (:)
|
||||
{ 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x06}, // U+003B (;)
|
||||
{ 0x18, 0x0C, 0x06, 0x03, 0x06, 0x0C, 0x18, 0x00}, // U+003C (<)
|
||||
{ 0x00, 0x00, 0x3F, 0x00, 0x00, 0x3F, 0x00, 0x00}, // U+003D (=)
|
||||
{ 0x06, 0x0C, 0x18, 0x30, 0x18, 0x0C, 0x06, 0x00}, // U+003E (>)
|
||||
{ 0x1E, 0x33, 0x30, 0x18, 0x0C, 0x00, 0x0C, 0x00}, // U+003F (?)
|
||||
{ 0x3E, 0x63, 0x7B, 0x7B, 0x7B, 0x03, 0x1E, 0x00}, // U+0040 (@)
|
||||
{ 0x0C, 0x1E, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x00}, // U+0041 (A)
|
||||
{ 0x3F, 0x66, 0x66, 0x3E, 0x66, 0x66, 0x3F, 0x00}, // U+0042 (B)
|
||||
{ 0x3C, 0x66, 0x03, 0x03, 0x03, 0x66, 0x3C, 0x00}, // U+0043 (C)
|
||||
{ 0x1F, 0x36, 0x66, 0x66, 0x66, 0x36, 0x1F, 0x00}, // U+0044 (D)
|
||||
{ 0x7F, 0x46, 0x16, 0x1E, 0x16, 0x46, 0x7F, 0x00}, // U+0045 (E)
|
||||
{ 0x7F, 0x46, 0x16, 0x1E, 0x16, 0x06, 0x0F, 0x00}, // U+0046 (F)
|
||||
{ 0x3C, 0x66, 0x03, 0x03, 0x73, 0x66, 0x7C, 0x00}, // U+0047 (G)
|
||||
{ 0x33, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x33, 0x00}, // U+0048 (H)
|
||||
{ 0x1E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+0049 (I)
|
||||
{ 0x78, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E, 0x00}, // U+004A (J)
|
||||
{ 0x67, 0x66, 0x36, 0x1E, 0x36, 0x66, 0x67, 0x00}, // U+004B (K)
|
||||
{ 0x0F, 0x06, 0x06, 0x06, 0x46, 0x66, 0x7F, 0x00}, // U+004C (L)
|
||||
{ 0x63, 0x77, 0x7F, 0x7F, 0x6B, 0x63, 0x63, 0x00}, // U+004D (M)
|
||||
{ 0x63, 0x67, 0x6F, 0x7B, 0x73, 0x63, 0x63, 0x00}, // U+004E (N)
|
||||
{ 0x1C, 0x36, 0x63, 0x63, 0x63, 0x36, 0x1C, 0x00}, // U+004F (O)
|
||||
{ 0x3F, 0x66, 0x66, 0x3E, 0x06, 0x06, 0x0F, 0x00}, // U+0050 (P)
|
||||
{ 0x1E, 0x33, 0x33, 0x33, 0x3B, 0x1E, 0x38, 0x00}, // U+0051 (Q)
|
||||
{ 0x3F, 0x66, 0x66, 0x3E, 0x36, 0x66, 0x67, 0x00}, // U+0052 (R)
|
||||
{ 0x1E, 0x33, 0x07, 0x0E, 0x38, 0x33, 0x1E, 0x00}, // U+0053 (S)
|
||||
{ 0x3F, 0x2D, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+0054 (T)
|
||||
{ 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3F, 0x00}, // U+0055 (U)
|
||||
{ 0x33, 0x33, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00}, // U+0056 (V)
|
||||
{ 0x63, 0x63, 0x63, 0x6B, 0x7F, 0x77, 0x63, 0x00}, // U+0057 (W)
|
||||
{ 0x63, 0x63, 0x36, 0x1C, 0x1C, 0x36, 0x63, 0x00}, // U+0058 (X)
|
||||
{ 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x0C, 0x1E, 0x00}, // U+0059 (Y)
|
||||
{ 0x7F, 0x63, 0x31, 0x18, 0x4C, 0x66, 0x7F, 0x00}, // U+005A (Z)
|
||||
{ 0x1E, 0x06, 0x06, 0x06, 0x06, 0x06, 0x1E, 0x00}, // U+005B ([)
|
||||
{ 0x03, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x40, 0x00}, // U+005C (\)
|
||||
{ 0x1E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x1E, 0x00}, // U+005D (])
|
||||
{ 0x08, 0x1C, 0x36, 0x63, 0x00, 0x00, 0x00, 0x00}, // U+005E (^)
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF}, // U+005F (_)
|
||||
{ 0x0C, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0060 (`)
|
||||
{ 0x00, 0x00, 0x1E, 0x30, 0x3E, 0x33, 0x6E, 0x00}, // U+0061 (a)
|
||||
{ 0x07, 0x06, 0x06, 0x3E, 0x66, 0x66, 0x3B, 0x00}, // U+0062 (b)
|
||||
{ 0x00, 0x00, 0x1E, 0x33, 0x03, 0x33, 0x1E, 0x00}, // U+0063 (c)
|
||||
{ 0x38, 0x30, 0x30, 0x3e, 0x33, 0x33, 0x6E, 0x00}, // U+0064 (d)
|
||||
{ 0x00, 0x00, 0x1E, 0x33, 0x3f, 0x03, 0x1E, 0x00}, // U+0065 (e)
|
||||
{ 0x1C, 0x36, 0x06, 0x0f, 0x06, 0x06, 0x0F, 0x00}, // U+0066 (f)
|
||||
{ 0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x1F}, // U+0067 (g)
|
||||
{ 0x07, 0x06, 0x36, 0x6E, 0x66, 0x66, 0x67, 0x00}, // U+0068 (h)
|
||||
{ 0x0C, 0x00, 0x0E, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+0069 (i)
|
||||
{ 0x30, 0x00, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E}, // U+006A (j)
|
||||
{ 0x07, 0x06, 0x66, 0x36, 0x1E, 0x36, 0x67, 0x00}, // U+006B (k)
|
||||
{ 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+006C (l)
|
||||
{ 0x00, 0x00, 0x33, 0x7F, 0x7F, 0x6B, 0x63, 0x00}, // U+006D (m)
|
||||
{ 0x00, 0x00, 0x1F, 0x33, 0x33, 0x33, 0x33, 0x00}, // U+006E (n)
|
||||
{ 0x00, 0x00, 0x1E, 0x33, 0x33, 0x33, 0x1E, 0x00}, // U+006F (o)
|
||||
{ 0x00, 0x00, 0x3B, 0x66, 0x66, 0x3E, 0x06, 0x0F}, // U+0070 (p)
|
||||
{ 0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x78}, // U+0071 (q)
|
||||
{ 0x00, 0x00, 0x3B, 0x6E, 0x66, 0x06, 0x0F, 0x00}, // U+0072 (r)
|
||||
{ 0x00, 0x00, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x00}, // U+0073 (s)
|
||||
{ 0x08, 0x0C, 0x3E, 0x0C, 0x0C, 0x2C, 0x18, 0x00}, // U+0074 (t)
|
||||
{ 0x00, 0x00, 0x33, 0x33, 0x33, 0x33, 0x6E, 0x00}, // U+0075 (u)
|
||||
{ 0x00, 0x00, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00}, // U+0076 (v)
|
||||
{ 0x00, 0x00, 0x63, 0x6B, 0x7F, 0x7F, 0x36, 0x00}, // U+0077 (w)
|
||||
{ 0x00, 0x00, 0x63, 0x36, 0x1C, 0x36, 0x63, 0x00}, // U+0078 (x)
|
||||
{ 0x00, 0x00, 0x33, 0x33, 0x33, 0x3E, 0x30, 0x1F}, // U+0079 (y)
|
||||
{ 0x00, 0x00, 0x3F, 0x19, 0x0C, 0x26, 0x3F, 0x00}, // U+007A (z)
|
||||
{ 0x38, 0x0C, 0x0C, 0x07, 0x0C, 0x0C, 0x38, 0x00}, // U+007B ({)
|
||||
{ 0x18, 0x18, 0x18, 0x00, 0x18, 0x18, 0x18, 0x00}, // U+007C (|)
|
||||
{ 0x07, 0x0C, 0x0C, 0x38, 0x0C, 0x0C, 0x07, 0x00}, // U+007D (})
|
||||
{ 0x6E, 0x3B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+007E (~)
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} // U+007F
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,40 @@
|
||||
[
|
||||
{
|
||||
"id": "soh",
|
||||
"name": "Ocarina of Time (Ship of Harkinian)",
|
||||
"repo": "Cogential/Shipwright-frame",
|
||||
"asset": "soh-steam-frame-arm64.zip",
|
||||
"type": "zip",
|
||||
"gameid": "Ocarina_of_Time_SoH",
|
||||
"launch": "run.sh",
|
||||
"keep": ["*.z64", "*.n64", "*.v64"],
|
||||
"data": "~/.local/share/soh",
|
||||
"rom_hint": "Send your Ocarina of Time ROM with Frame Inbox, or put a .z64 in ~/.local/share/soh."
|
||||
},
|
||||
{
|
||||
"id": "2ship",
|
||||
"name": "Majora's Mask (2 Ship 2 Harkinian)",
|
||||
"repo": "Cogential/2ship2harkinian-frame",
|
||||
"asset": "*arm64.appimage",
|
||||
"type": "appimage",
|
||||
"gameid": "Majoras_Mask_2Ship",
|
||||
"launch": "run.sh",
|
||||
"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": [
|
||||
"#!/bin/sh",
|
||||
"# Steam Frame launcher for 2 Ship 2 Harkinian (written by Port Manager). Data, saves and the built mm.o2r",
|
||||
"# live in ~/.local/share/2ship, so reinstalling this folder keeps them.",
|
||||
"D=\"$(cd \"$(dirname \"$0\")\" && pwd)\"",
|
||||
"export SHIP_HOME=\"${SHIP_HOME:-$HOME/.local/share/2ship}\"",
|
||||
"mkdir -p \"$SHIP_HOME\"",
|
||||
"# Hand a ROM shipped next to this script to the extractor until mm.o2r exists.",
|
||||
"if [ ! -f \"$SHIP_HOME/mm.o2r\" ]; then",
|
||||
" for rom in \"$D\"/*.z64; do [ -f \"$rom\" ] && cp -n \"$rom\" \"$SHIP_HOME/\"; done",
|
||||
"fi",
|
||||
"chmod +x \"$D/usr/bin/2s2h.elf\" 2>/dev/null",
|
||||
"cd \"$D/usr/bin\" && exec ./2s2h.elf \"$@\""
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/bin/sh
|
||||
# Installs or updates Port Manager on a Steam Frame from this computer over SSH: the service, and the
|
||||
# "Port Manager" app in the Steam library with its art.
|
||||
#
|
||||
# Usage: ./deploy.sh [host] host defaults to frame.local
|
||||
# Env: SSH_KEY (default ~/.ssh/frame_devkit), SSH_USER (default steamos)
|
||||
# Build the app first with app/build-arm64.sh.
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
HOST="${1:-frame.local}"
|
||||
KEY="${SSH_KEY:-$HOME/.ssh/frame_devkit}"
|
||||
USER_AT="${SSH_USER:-steamos}@$HOST"
|
||||
SSH="ssh -i $KEY -o ConnectTimeout=10"
|
||||
GAMEID=Port_Manager
|
||||
|
||||
[ -x dist/app/port-manager ] || { echo "Build the app first: app/build-arm64.sh"; exit 1; }
|
||||
$SSH "$USER_AT" true || { echo "Can't reach $HOST over SSH. Is the Frame on and on this network?"; exit 1; }
|
||||
|
||||
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/"
|
||||
$SSH "$USER_AT" 'sh ~/.cache/frame-port-manager-src/install.sh'
|
||||
|
||||
echo "== Headset app"
|
||||
rsync -a --delete -e "$SSH" dist/app/ "$USER_AT:devkit-game/$GAMEID/"
|
||||
$SSH "$USER_AT" "python3 ~/devkit-utils/steam-client-create-shortcut --parms '{\"gameid\": \"$GAMEID\", \"directory\": \"/home/steamos/devkit-game/$GAMEID\", \"argv\": [\"./run.sh\"], \"env\": {}, \"settings\": {\"compat_tool\": \"SteamLinuxRuntime_4-arm64\", \"steam_play\": \"0\"}}'" >/dev/null
|
||||
|
||||
echo "== Library art"
|
||||
APPID=""
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
APPID=$($SSH "$USER_AT" "python3 - $GAMEID" < tools/find_appid.py) && [ -n "$APPID" ] && break
|
||||
sleep 2
|
||||
done
|
||||
if [ -n "$APPID" ]; then
|
||||
for USERDIR in $($SSH "$USER_AT" 'ls -d ~/.local/share/Steam/userdata/[1-9]*/config 2>/dev/null'); do
|
||||
$SSH "$USER_AT" "mkdir -p $USERDIR/grid"
|
||||
for pair in portrait.png:p.png wide.png:.png hero.png:_hero.png logo.png:_logo.png icon.png:_icon.png; do
|
||||
rsync -a -e "$SSH" "art/app/${pair%%:*}" "$USER_AT:$USERDIR/grid/${APPID}${pair##*:}"
|
||||
done
|
||||
done
|
||||
echo "Art added for app id $APPID (shows after Steam restarts)."
|
||||
else
|
||||
echo "Couldn't find the shortcut's app id yet; run this again later to add the art."
|
||||
fi
|
||||
echo "Done. Open \"Port Manager\" from the library, or http://$HOST:8643 in a browser."
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Port Manager (installs and updates Steam Frame ports from GitHub releases)
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 %h/.local/share/frame-port-manager/manager.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# Installs the Port Manager service on a Steam Frame for the current user. Run it on the Frame from this folder.
|
||||
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/"
|
||||
rm -rf "$dest/art" && cp -r "$here/art" "$dest/art"
|
||||
cp "$here/frame-port-manager.service" "$HOME/.config/systemd/user/"
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable frame-port-manager.service >/dev/null 2>&1
|
||||
systemctl --user restart frame-port-manager.service
|
||||
sleep 1
|
||||
ip=$(python3 -c 'import socket;s=socket.socket(2,2);s.connect(("192.0.2.1",9));print(s.getsockname()[0])' 2>/dev/null || hostname)
|
||||
echo "Port Manager is running: http://$ip:8643"
|
||||
echo "Uninstall: systemctl --user disable --now frame-port-manager.service"
|
||||
Executable
+605
@@ -0,0 +1,605 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Port Manager: installs and updates native Steam Frame ports from their GitHub releases.
|
||||
|
||||
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/<gameid>, 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.
|
||||
|
||||
Control it from the headset app (app/) or from a browser at http://<frame>:8643.
|
||||
"""
|
||||
|
||||
import fnmatch
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
HOME = Path.home()
|
||||
APP_DIR = Path(__file__).resolve().parent
|
||||
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", HOME / ".config")) / "port-manager"
|
||||
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"
|
||||
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"
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"port": 8643,
|
||||
"password": "", # for the web page; empty means anyone on the network can use it
|
||||
"github_token": "", # needed only for private repos (read-only access to their releases is enough)
|
||||
"compat_tool": "SteamLinuxRuntime_4-arm64",
|
||||
"check_hours": 6,
|
||||
}
|
||||
CHUNK = 1024 * 1024
|
||||
RELEASES_TTL = 600
|
||||
|
||||
|
||||
def load_json(path, default):
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def save_json(path, data):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
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", [])}
|
||||
for p in load_json(USER_CATALOG, []):
|
||||
if "id" in p:
|
||||
ports[p["id"]] = {**ports.get(p["id"], {}), **p}
|
||||
return ports
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- GitHub
|
||||
|
||||
|
||||
def gh_request(url, accept="application/vnd.github+json"):
|
||||
headers = {"Accept": accept, "User-Agent": "frame-port-manager", "X-GitHub-Api-Version": "2022-11-28"}
|
||||
token = config().get("github_token")
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return urllib.request.Request(url, headers=headers)
|
||||
|
||||
|
||||
def gh_json(url):
|
||||
try:
|
||||
with urllib.request.urlopen(gh_request(url), timeout=20) as r:
|
||||
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("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:
|
||||
raise RuntimeError("Can't reach GitHub. Is the Frame online?") from e
|
||||
except ValueError as e: # e.g. a token with a newline or other characters a header can't hold
|
||||
raise RuntimeError("The GitHub token isn't valid. Check it in Settings.") from e
|
||||
|
||||
|
||||
_releases_cache = {}
|
||||
|
||||
|
||||
def releases(port, force=False):
|
||||
"""Releases newest first, with the asset that matches the port's pattern."""
|
||||
key = port["repo"]
|
||||
hit = _releases_cache.get(key)
|
||||
if hit and not force and time.time() - hit[0] < RELEASES_TTL:
|
||||
return hit[1]
|
||||
out = []
|
||||
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)
|
||||
if asset is None:
|
||||
continue
|
||||
out.append({
|
||||
"tag": rel["tag_name"],
|
||||
"name": rel.get("name") or rel["tag_name"],
|
||||
"date": (rel.get("published_at") or "")[:10],
|
||||
"prerelease": bool(rel.get("prerelease")),
|
||||
"asset_name": asset["name"],
|
||||
"asset_url": asset["url"], # API URL: works for private repos with a token
|
||||
"size": asset.get("size", 0),
|
||||
"notes": (rel.get("body") or "")[:4000],
|
||||
})
|
||||
_releases_cache[key] = (time.time(), out)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Steam
|
||||
|
||||
|
||||
def find_appid(gameid):
|
||||
"""The Steam app id of a devkit shortcut, from shortcuts.vdf (binary VDF)."""
|
||||
for path in STEAM_USERDATA.glob("*/config/shortcuts.vdf"):
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
except OSError:
|
||||
continue
|
||||
for s in parse_vdf(data).values():
|
||||
if isinstance(s, dict) and s.get("DevkitGameID") == gameid:
|
||||
return s.get("appid")
|
||||
return None
|
||||
|
||||
|
||||
def parse_vdf(data):
|
||||
pos = 0
|
||||
|
||||
def cstr():
|
||||
nonlocal pos
|
||||
end = data.index(b"\0", pos)
|
||||
s = data[pos:end].decode("utf-8", "replace")
|
||||
pos = end + 1
|
||||
return s
|
||||
|
||||
def obj():
|
||||
nonlocal pos
|
||||
out = {}
|
||||
while pos < len(data):
|
||||
t = data[pos]
|
||||
pos += 1
|
||||
if t == 8:
|
||||
return out
|
||||
key = cstr()
|
||||
if t == 0:
|
||||
out[key] = obj()
|
||||
elif t == 1:
|
||||
out[key] = cstr()
|
||||
elif t == 2:
|
||||
out[key] = struct.unpack("<I", data[pos:pos + 4])[0]
|
||||
pos += 4
|
||||
else:
|
||||
raise ValueError(f"unknown VDF type {t}")
|
||||
return out
|
||||
|
||||
try:
|
||||
pos = 1
|
||||
cstr()
|
||||
return obj()
|
||||
except (ValueError, IndexError):
|
||||
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)."
|
||||
parms = {
|
||||
"gameid": port["gameid"],
|
||||
"directory": str(folder),
|
||||
"argv": ["./" + port.get("launch", "run.sh")],
|
||||
"env": {},
|
||||
"settings": {"compat_tool": config()["compat_tool"], "steam_play": "0"},
|
||||
}
|
||||
r = subprocess.run([sys.executable, str(CREATE_SHORTCUT), "--parms", json.dumps(parms)],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
if r.returncode != 0 or '"success"' not in r.stdout:
|
||||
return "Installed, but Steam didn't accept the shortcut. Restart Steam and try again."
|
||||
return None
|
||||
|
||||
|
||||
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"]
|
||||
if not art.is_dir():
|
||||
return
|
||||
appid = None
|
||||
for _ in range(10): # Steam writes the shortcut to shortcuts.vdf shortly after registering it
|
||||
appid = find_appid(port["gameid"])
|
||||
if appid:
|
||||
break
|
||||
time.sleep(2)
|
||||
if not appid:
|
||||
return
|
||||
names = {"portrait.png": f"{appid}p.png", "wide.png": f"{appid}.png", "hero.png": f"{appid}_hero.png",
|
||||
"logo.png": f"{appid}_logo.png", "icon.png": f"{appid}_icon.png"}
|
||||
for grid in STEAM_USERDATA.glob("[1-9]*/config"):
|
||||
(grid / "grid").mkdir(exist_ok=True)
|
||||
for src, dst in names.items():
|
||||
if (art / src).exists() and not (grid / "grid" / dst).exists():
|
||||
shutil.copyfile(art / src, grid / "grid" / dst)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- install
|
||||
|
||||
|
||||
def game_running(folder):
|
||||
folder = str(folder.resolve()) + os.sep
|
||||
for pid in os.listdir("/proc"):
|
||||
if not pid.isdigit():
|
||||
continue
|
||||
try:
|
||||
exe = os.readlink(f"/proc/{pid}/exe")
|
||||
except OSError:
|
||||
continue
|
||||
if exe.startswith(folder):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def make_executables(root):
|
||||
"""Marks ELF programs and #! scripts executable (Windows-made zips lose the bit)."""
|
||||
for dirpath, _, files in os.walk(root):
|
||||
for name in files:
|
||||
p = os.path.join(dirpath, name)
|
||||
try:
|
||||
if os.path.islink(p):
|
||||
continue
|
||||
with open(p, "rb") as f:
|
||||
head = f.read(4)
|
||||
if head == b"\x7fELF" or head[:2] == b"#!":
|
||||
os.chmod(p, os.stat(p).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def extract_zip(archive, dest):
|
||||
with zipfile.ZipFile(archive) as z:
|
||||
for info in z.infolist():
|
||||
target = (dest / info.filename).resolve()
|
||||
if dest.resolve() not in target.parents and target != dest.resolve():
|
||||
raise RuntimeError("The download contains unsafe paths.")
|
||||
z.extract(info, 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()
|
||||
|
||||
|
||||
def extract_appimage(archive, dest):
|
||||
os.chmod(archive, 0o755)
|
||||
# PORT_MANAGER_EXEC_WRAPPER (e.g. qemu-aarch64-static) lets tests on another architecture run the AppImage.
|
||||
wrapper = os.environ.get("PORT_MANAGER_EXEC_WRAPPER", "").split()
|
||||
try:
|
||||
r = subprocess.run(wrapper + [str(archive), "--appimage-extract"], cwd=dest, capture_output=True, timeout=600)
|
||||
except OSError as e:
|
||||
raise RuntimeError(f"Couldn't run the AppImage to unpack it ({e.strerror}).") from e
|
||||
root = dest / "squashfs-root"
|
||||
if r.returncode != 0 or not root.is_dir():
|
||||
raise RuntimeError("Couldn't unpack the AppImage.")
|
||||
for e in list(root.iterdir()):
|
||||
shutil.move(str(e), dest / e.name)
|
||||
root.rmdir()
|
||||
archive.unlink()
|
||||
|
||||
|
||||
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"}
|
||||
|
||||
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())
|
||||
|
||||
|
||||
jobs = Jobs()
|
||||
|
||||
|
||||
def install(pid, tag=None):
|
||||
port = catalog()[pid]
|
||||
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)
|
||||
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.")
|
||||
|
||||
# Download
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
archive = CACHE_DIR / rel["asset_name"]
|
||||
if shutil.disk_usage(CACHE_DIR).free < rel["size"] * 3 + 512 * 1024 * 1024:
|
||||
raise RuntimeError("Not enough free space.")
|
||||
jobs.set(pid, "downloading", 0.0, f"Downloading {rel['tag']}")
|
||||
req = gh_request(rel["asset_url"], accept="application/octet-stream")
|
||||
with urllib.request.urlopen(req, timeout=60) as r, open(archive, "wb") as f:
|
||||
total = int(r.headers.get("Content-Length") or rel["size"] or 0)
|
||||
done = 0
|
||||
while True:
|
||||
block = r.read(CHUNK)
|
||||
if not block:
|
||||
break
|
||||
f.write(block)
|
||||
done += len(block)
|
||||
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)
|
||||
jobs.set(pid, "installing", 1.0, "Unpacking")
|
||||
GAMES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
staging = GAMES_DIR / f".{port['gameid']}.staging"
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
staging.mkdir()
|
||||
if port.get("type", "zip") == "appimage":
|
||||
moved = staging / archive.name
|
||||
shutil.move(str(archive), moved)
|
||||
extract_appimage(moved, staging)
|
||||
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')}.")
|
||||
|
||||
# Carry over files the user put in the old install (ROMs next to run.sh)
|
||||
if final.exists():
|
||||
for pattern in port.get("keep", []):
|
||||
for f in final.glob(pattern):
|
||||
if f.is_file() and not (staging / f.name).exists():
|
||||
shutil.copy2(f, staging / f.name)
|
||||
make_executables(staging)
|
||||
|
||||
# Swap: the old install becomes the "previous" one
|
||||
previous = GAMES_DIR / f".{port['gameid']}.previous"
|
||||
if final.exists():
|
||||
if game_running(final):
|
||||
raise RuntimeError("Close the game first.")
|
||||
shutil.rmtree(previous, ignore_errors=True)
|
||||
os.replace(final, previous)
|
||||
os.replace(staging, final)
|
||||
|
||||
jobs.set(pid, "installing", 1.0, "Adding to Steam")
|
||||
note = register_with_steam(port, final)
|
||||
add_art(port)
|
||||
|
||||
installed = load_json(INSTALLED_FILE, {})
|
||||
prev = installed.get(pid, {}).get("tag")
|
||||
installed[pid] = {"tag": rel["tag"], "previous": prev, "time": int(time.time())}
|
||||
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))
|
||||
|
||||
|
||||
def rollback(pid):
|
||||
port = catalog()[pid]
|
||||
final = GAMES_DIR / port["gameid"]
|
||||
previous = GAMES_DIR / f".{port['gameid']}.previous"
|
||||
try:
|
||||
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"
|
||||
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())}
|
||||
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 start(fn, pid, *args):
|
||||
with jobs.lock:
|
||||
if jobs.busy():
|
||||
return False
|
||||
jobs.set(pid, "installing", 0.0, "Starting")
|
||||
threading.Thread(target=fn, args=(pid, *args), daemon=True).start()
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- state
|
||||
|
||||
|
||||
_latest = {} # port 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")
|
||||
except RuntimeError as e:
|
||||
_latest[pid] = (None, str(e))
|
||||
|
||||
|
||||
def port_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:
|
||||
tag = "unknown"
|
||||
latest, err = _latest.get(pid, (None, None))
|
||||
job = jobs.state.get(pid, {})
|
||||
if job.get("status") in ("downloading", "installing"):
|
||||
status = job["status"]
|
||||
elif err and not latest:
|
||||
status = "error"
|
||||
elif not folder.exists():
|
||||
status = "not installed"
|
||||
elif latest and tag != latest:
|
||||
status = "update available"
|
||||
else:
|
||||
status = "up to date"
|
||||
out.append({
|
||||
"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(),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def tsv(rows, keys):
|
||||
"""Tab-separated lines for the headset app (no JSON parser needed there)."""
|
||||
def clean(v):
|
||||
if v is None:
|
||||
return "-"
|
||||
if isinstance(v, bool):
|
||||
return "1" if v else "0"
|
||||
if isinstance(v, float):
|
||||
return f"{v:.3f}"
|
||||
return re.sub(r"[\t\r\n]+", " ", str(v))
|
||||
return "\n".join("\t".join(clean(r.get(k)) for k in keys) for r in rows) + "\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- web
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "PortManager/1.0"
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
sys.stderr.write("%s %s\n" % (self.address_string(), fmt % args))
|
||||
|
||||
def reply(self, body, ctype="application/json", code=200):
|
||||
if not isinstance(body, bytes):
|
||||
body = (json.dumps(body) if ctype == "application/json" else body).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype + ("; charset=utf-8" if ctype.startswith("text") else ""))
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def allowed(self, q):
|
||||
password = config().get("password") or ""
|
||||
# The headset app talks to us over loopback and never needs the password.
|
||||
if not password or self.client_address[0] == "127.0.0.1":
|
||||
return True
|
||||
return (self.headers.get("X-Key") or q.get("key", [""])[0]) == password
|
||||
|
||||
def do_GET(self):
|
||||
url = urllib.parse.urlparse(self.path)
|
||||
q = urllib.parse.parse_qs(url.query)
|
||||
if url.path == "/":
|
||||
self.reply((APP_DIR / "page.html").read_bytes(), "text/html")
|
||||
return
|
||||
if not self.allowed(q):
|
||||
self.reply({"error": "password"}, code=401)
|
||||
return
|
||||
fmt = q.get("fmt", ["json"])[0]
|
||||
if url.path == "/api/state":
|
||||
rows = port_state()
|
||||
if fmt == "tsv":
|
||||
self.reply(tsv(rows, ["id", "name", "installed", "latest", "status", "progress", "message",
|
||||
"can_rollback"]), "text/plain")
|
||||
else:
|
||||
self.reply({"ports": rows, "token_set": bool(config().get("github_token")),
|
||||
"busy": jobs.busy()})
|
||||
elif url.path == "/api/releases":
|
||||
pid = q.get("id", [""])[0]
|
||||
port = catalog().get(pid)
|
||||
if not port:
|
||||
self.reply({"error": "unknown port"}, code=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")
|
||||
return
|
||||
if fmt == "tsv":
|
||||
self.reply(tsv(rels, ["tag", "name", "date", "prerelease"]), "text/plain")
|
||||
else:
|
||||
self.reply({"releases": rels})
|
||||
else:
|
||||
self.reply({"error": "not found"}, code=404)
|
||||
|
||||
def do_POST(self):
|
||||
url = urllib.parse.urlparse(self.path)
|
||||
q = urllib.parse.parse_qs(url.query)
|
||||
if not self.allowed(q):
|
||||
self.reply({"error": "password"}, code=401)
|
||||
return
|
||||
pid = q.get("id", [""])[0]
|
||||
if url.path == "/api/refresh":
|
||||
threading.Thread(target=refresh, kwargs={"force": True}, daemon=True).start()
|
||||
self.reply({"ok": True})
|
||||
elif url.path in ("/api/install", "/api/rollback"):
|
||||
if pid not in catalog():
|
||||
self.reply({"error": "unknown port"}, code=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."})
|
||||
elif url.path == "/api/token":
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
token = self.rfile.read(min(length, 4096)).decode().strip()
|
||||
cfg = load_json(CONFIG_FILE, {})
|
||||
cfg["github_token"] = token
|
||||
save_json(CONFIG_FILE, cfg)
|
||||
os.chmod(CONFIG_FILE, 0o600)
|
||||
_releases_cache.clear()
|
||||
threading.Thread(target=refresh, kwargs={"force": True}, daemon=True).start()
|
||||
self.reply({"ok": True})
|
||||
else:
|
||||
self.reply({"error": "not found"}, code=404)
|
||||
|
||||
|
||||
def checker():
|
||||
while True:
|
||||
refresh(force=True)
|
||||
time.sleep(max(1, float(config().get("check_hours", 6))) * 3600)
|
||||
|
||||
|
||||
def main():
|
||||
cfg = config()
|
||||
if not CONFIG_FILE.exists():
|
||||
save_json(CONFIG_FILE, cfg)
|
||||
os.chmod(CONFIG_FILE, 0o600)
|
||||
threading.Thread(target=checker, daemon=True).start()
|
||||
server = ThreadingHTTPServer(("0.0.0.0", int(cfg["port"])), Handler)
|
||||
server.daemon_threads = True
|
||||
print(f"Port Manager on port {cfg['port']}", flush=True)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,167 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Port Manager</title>
|
||||
<style>
|
||||
:root { --bg: #0f1216; --panel: #181d23; --line: #2a313a; --text: #e8edf2; --muted: #93a0ad;
|
||||
--accent: #4fa3ff; --ok: #4cc38a; --warn: #f0b429; --bad: #f2555a; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text);
|
||||
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; }
|
||||
.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; }
|
||||
.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.error { color: var(--bad); } .msg.done { color: var(--ok); }
|
||||
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
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: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); }
|
||||
.hint { color: var(--muted); font-size: .9rem; }
|
||||
input[type=password] { flex: 1; min-width: 200px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="top"><h1>Port Manager</h1><button id="refresh">Check for updates</button></div>
|
||||
<p class="sub">Install and update your Steam Frame ports from their GitHub releases. Saves and ROMs are kept.</p>
|
||||
<div id="ports"></div>
|
||||
<div class="panel">
|
||||
<details>
|
||||
<summary>Settings</summary>
|
||||
<p class="hint">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.</p>
|
||||
<div class="row"><input id="token" type="password" placeholder="github_pat_…" autocomplete="off">
|
||||
<button id="savetoken">Save token</button></div>
|
||||
<p class="hint" id="tokenstate"></p>
|
||||
</details>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
let key = "";
|
||||
try { key = localStorage.getItem("pmKey") || ""; } catch (e) {}
|
||||
const $ = id => document.getElementById(id);
|
||||
const q = encodeURIComponent;
|
||||
const hdr = () => key ? { "X-Key": key } : {};
|
||||
const releases = {};
|
||||
|
||||
async function get(path) {
|
||||
const r = await fetch(path, { headers: hdr() });
|
||||
if (r.status === 401) {
|
||||
key = prompt("Port Manager password") || "";
|
||||
try { localStorage.setItem("pmKey", key); } catch (e) {}
|
||||
return get(path);
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
async function post(path, body) {
|
||||
const r = await fetch(path, { method: "POST", headers: hdr(), body });
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function badge(p) {
|
||||
if (p.status === "downloading" || p.status === "installing") return ["busy", p.status === "downloading" ? "Downloading" : "Installing"];
|
||||
if (p.status === "update available") return ["update", "Update available"];
|
||||
if (p.status === "up to date") return ["ok", "Up to date"];
|
||||
if (p.status === "error") return ["error", "Problem"];
|
||||
return ["", "Not installed"];
|
||||
}
|
||||
|
||||
async function loadReleases(p, sel) {
|
||||
if (!releases[p.id]) {
|
||||
const r = await get(`/api/releases?id=${q(p.id)}`);
|
||||
releases[p.id] = r.releases || [];
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function card(p, busy) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "panel";
|
||||
const [cls, label] = badge(p);
|
||||
div.innerHTML = `
|
||||
<div class="top"><span class="name"></span><span class="badge ${cls}">${label}</span></div>
|
||||
<div class="ver"></div>
|
||||
<div class="row">
|
||||
<button class="primary act"></button>
|
||||
<select class="pick"><option>Loading versions…</option></select>
|
||||
<button class="inst">Install this version</button>
|
||||
<button class="back">Previous version</button>
|
||||
</div>
|
||||
<p class="msg"></p>`;
|
||||
div.querySelector(".name").textContent = p.name;
|
||||
div.querySelector(".ver").textContent =
|
||||
`Installed: ${p.installed === null ? "—" : p.installed} · Latest: ${p.latest === null ? "—" : p.latest}`;
|
||||
const act = div.querySelector(".act");
|
||||
act.textContent = p.status === "not 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;
|
||||
if (!p.latest) act.disabled = true;
|
||||
act.onclick = () => post(`/api/install?id=${q(p.id)}`).then(poll);
|
||||
const sel = div.querySelector(".pick");
|
||||
loadReleases(p, sel);
|
||||
div.querySelector(".inst").onclick = () => post(`/api/install?id=${q(p.id)}&tag=${q(sel.value)}`).then(poll);
|
||||
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" : "");
|
||||
if (working) {
|
||||
const bar = document.createElement("progress");
|
||||
bar.max = 1; bar.value = p.progress;
|
||||
div.append(bar);
|
||||
}
|
||||
return div;
|
||||
}
|
||||
|
||||
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");
|
||||
box.innerHTML = "";
|
||||
for (const p of s.ports) box.append(card(p, s.busy));
|
||||
}
|
||||
$("tokenstate").textContent = s.token_set ? "A token is saved." : "No token saved.";
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(poll, s.busy ? 700 : 5000);
|
||||
}
|
||||
|
||||
$("refresh").onclick = async () => {
|
||||
for (const k in releases) delete releases[k];
|
||||
await post("/api/refresh");
|
||||
setTimeout(poll, 2500);
|
||||
};
|
||||
$("savetoken").onclick = async () => {
|
||||
await post("/api/token", $("token").value.trim());
|
||||
$("token").value = "";
|
||||
for (const k in releases) delete releases[k];
|
||||
setTimeout(poll, 2500);
|
||||
};
|
||||
poll();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Prints the Steam app id of a devkit shortcut, read from shortcuts.vdf. Usage: python3 - <DevkitGameID>"""
|
||||
import glob
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
|
||||
want = sys.argv[1]
|
||||
|
||||
|
||||
def parse(data):
|
||||
pos = 0
|
||||
|
||||
def cstr():
|
||||
nonlocal pos
|
||||
end = data.index(b"\0", pos)
|
||||
s = data[pos:end].decode("utf-8", "replace")
|
||||
pos = end + 1
|
||||
return s
|
||||
|
||||
def obj():
|
||||
nonlocal pos
|
||||
out = {}
|
||||
while True:
|
||||
t = data[pos]
|
||||
pos += 1
|
||||
if t == 8:
|
||||
return out
|
||||
key = cstr()
|
||||
if t == 0:
|
||||
out[key] = obj()
|
||||
elif t == 1:
|
||||
out[key] = cstr()
|
||||
elif t == 2:
|
||||
out[key] = struct.unpack("<I", data[pos:pos + 4])[0]
|
||||
pos += 4
|
||||
else:
|
||||
raise ValueError(f"unknown type {t}")
|
||||
|
||||
pos = 1
|
||||
cstr()
|
||||
return obj()
|
||||
|
||||
|
||||
for path in glob.glob(os.path.expanduser("~/.local/share/Steam/userdata/*/config/shortcuts.vdf")):
|
||||
try:
|
||||
shortcuts = parse(open(path, "rb").read())
|
||||
except (OSError, ValueError, IndexError):
|
||||
continue
|
||||
for s in shortcuts.values():
|
||||
if s.get("DevkitGameID") == want:
|
||||
print(s.get("appid"))
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
Reference in new issue
Block a user