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:
CogentialandClaude Opus 5.5 committed 2026-10-05 13:59:43 -04:00
commit 65c6ad3eaa
17 files changed
+1637

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
__pycache__/
dist/
+54
View File
@@ -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.
+468
View File
@@ -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;
}
+24
View File
@@ -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"
+152
View File
@@ -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
};
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+40
View File
@@ -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 \"$@\""
]
}
]
Executable
+45
View File
@@ -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."
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+167
View File
@@ -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>
+53
View File
@@ -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)