Files
CogentialandClaude Opus 5.5 dca76585bc Discover new Steam Frame apps on GitHub
Searches GitHub (steam-frame topic, "steam frame" in names/descriptions) and the
awesome-steam-frame list, checks each project's releases for a download the Frame can
install, and lists them with New badges, in the headset app (X / Discover row) and on the
web page. Cached, rechecked only when a repo changes, and limited per run so update
checks keep enough of GitHub's unauthenticated rate limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHDaz86aayqMULHsjByEmT
2026-10-07 16:41:35 -04:00

1169 lines
45 KiB
C

/* Frametanium for the headset: a controller-driven front end for the Frametanium 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.
*
* Screens: the app list; an app's menu (install/update, versions, previous version, pre-releases, remove); adding
* an app (type a GitHub repo on an on-screen keyboard, paste a link, or pick a suggestion); discover (new Steam Frame
* projects on GitHub with a release to install); and the found repo, where you pick its download and add it.
*
* Controls: D-pad/left stick to choose, A select, B back/close, Y add an app, X discover, Select update all, Start
* check for updates. Keyboard: arrows, Enter, Esc, and typing on the on-screen keyboard screen. */
#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_APPS 64
#define MAX_RELEASES 40
#define MAX_SUGGEST 32
#define MAX_ASSETS 16
#define MAX_DISC 64
typedef struct {
char id[64], name[128], installed[64], latest[64], status[32], message[256], repo[128], mode[16], job[16];
float progress;
int can_rollback, prereleases, builtin;
} App;
typedef struct {
char tag[64], name[128], date[16];
int prerelease;
} Release;
typedef struct {
char repo[128], name[128], note[160];
} Suggestion;
typedef struct {
char repo[128], name[128], description[300], tag[64], date[16], asset[128];
int stars, arm64, prerelease, is_new;
} Disc;
typedef struct {
char repo[128], name[128], description[300], tag[64], date[16], existing[64];
int prereleases, nassets, asset;
char assets[MAX_ASSETS][128];
int arm64[MAX_ASSETS];
long sizes[MAX_ASSETS];
} Found;
static SDL_Renderer* ren;
static int W, H, S; /* screen size and text scale */
static int service_port = DEFAULT_PORT;
/* ---------------------------------------------------------------- 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) continue; /* the font is ASCII only: leave other characters out */
for (int row = 0; row < 8; row++) {
unsigned char bits = (unsigned char)font8x8_basic[c][row];
for (int col = 0; col < 8; col++)
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[400];
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);
}
/* Draws s word-wrapped into lines of max_px, at most max_lines; returns the height used. */
static int text_wrap(const char* s, int x, int y, int scale, int max_px, int max_lines, Uint32 rgb) {
int per = max_px / (8 * scale), lines = 0;
const char* p = s;
while (*p && lines < max_lines) {
int len = (int)strlen(p);
int take = len;
if (len > per) {
take = per;
while (take > 0 && p[take] != ' ') take--;
if (take == 0) take = per;
}
char buf[400];
snprintf(buf, sizeof buf, "%.*s", take, p);
if (lines == max_lines - 1 && len > take && take > 3) strcpy(buf + take - 3, "...");
text(buf, x, y + lines * 12 * scale, scale, rgb);
p += take;
while (*p == ' ') p++;
lines++;
}
return lines * 12 * scale;
}
static void fill(int x, int y, int w, int h, Uint32 rgb) {
color(rgb);
SDL_RenderFillRect(ren, &(SDL_Rect){ x, y, w, h });
}
/* ---------------------------------------------------------------- service */
static void read_port(void) {
char path[512], buf[2048];
snprintf(path, sizeof path, "%s/.config/frametanium/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 timeout_s) {
int s = socket(AF_INET, SOCK_STREAM, 0);
if (s < 0) return NULL;
struct timeval tv = { .tv_sec = timeout_s };
setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons(service_port) };
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (connect(s, (struct sockaddr*)&a, sizeof a) != 0) {
close(s);
return NULL;
}
char req[2048];
int n = snprintf(req, sizeof req, "%s %s HTTP/1.0\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n", method, path);
if (n >= (int)sizeof req || 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;
}
/* Percent-encodes s for a query string. */
static void urlenc(char* out, size_t sz, const char* s) {
static const char hex[] = "0123456789ABCDEF";
size_t o = 0;
for (; *s && o + 4 < sz; s++) {
unsigned char c = (unsigned char)*s;
if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || strchr("-_.~", c)) {
out[o++] = c;
} else {
out[o++] = '%';
out[o++] = hex[c >> 4];
out[o++] = hex[c & 15];
}
}
out[o] = '\0';
}
/* Splits one tab-separated line into fields (in place). */
static int split(char* line, char** f, int max) {
int n = 0;
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);
}
/* Lines of a reply, one at a time (strtok_r so callers can nest). */
#define EACH_LINE(body, line, save) for (char* line = strtok_r(body, "\n", &save); line; line = strtok_r(NULL, "\n", &save))
static int load_apps(App* apps, char* notice, size_t nsz, int* notice_busy) {
char* body = http("GET", "/api/state?fmt=tsv", 5);
if (!body) return -1;
int n = 0;
char* save;
notice[0] = '\0';
*notice_busy = 0;
EACH_LINE(body, line, save) {
char* f[13];
int k = split(line, f, 13);
if (k >= 3 && !strcmp(f[0], "#")) {
copy(notice, nsz, f[2]);
*notice_busy = !strcmp(f[1], "installing");
continue;
}
if (k < 13 || n >= MAX_APPS) continue;
App* p = &apps[n++];
copy(p->id, sizeof p->id, f[0]);
copy(p->name, sizeof p->name, f[1]);
copy(p->installed, sizeof p->installed, f[2]);
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]);
copy(p->repo, sizeof p->repo, f[8]);
copy(p->mode, sizeof p->mode, f[9]);
p->prereleases = atoi(f[10]);
p->builtin = atoi(f[11]);
copy(p->job, sizeof p->job, f[12]);
}
free(body);
return n;
}
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, 25);
err[0] = '\0';
if (!body) {
snprintf(err, errsz, "Frametanium service isn't running.");
return 0;
}
int n = 0;
char* save;
EACH_LINE(body, line, save) {
char* f[4];
int k = split(line, f, 4);
if (k >= 2 && strcmp(f[0], "!") == 0) {
snprintf(err, errsz, "%s", f[1]);
break;
}
if (k < 4 || n >= MAX_RELEASES) 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);
if (n == 0 && !err[0]) snprintf(err, errsz, "No versions with a download for the Frame.");
return n;
}
static int load_suggestions(Suggestion* sug) {
char* body = http("GET", "/api/suggestions?fmt=tsv", 5);
if (!body) return 0;
int n = 0;
char* save;
EACH_LINE(body, line, save) {
char* f[4];
if (split(line, f, 4) < 4 || n >= MAX_SUGGEST || atoi(f[3])) continue;
Suggestion* s = &sug[n++];
copy(s->repo, sizeof s->repo, f[0]);
copy(s->name, sizeof s->name, f[1]);
copy(s->note, sizeof s->note, f[2]);
}
free(body);
return n;
}
/* New Steam Frame projects found on GitHub; running says a search is going, msg is its progress. */
static int load_discover(Disc* d, int* running, char* msg, size_t msz, int* nnew) {
char* body = http("GET", "/api/discover?fmt=tsv", 5);
*running = 0;
*nnew = 0;
msg[0] = '\0';
if (!body) return -1;
int n = 0;
char* save;
EACH_LINE(body, line, save) {
char* f[11];
int k = split(line, f, 11);
if (k >= 4 && !strcmp(f[0], "#")) {
*running = atoi(f[1]);
copy(msg, msz, f[2]);
*nnew = atoi(f[3]);
continue;
}
if (k < 10 || n >= MAX_DISC) continue;
Disc* x = &d[n++];
copy(x->repo, sizeof x->repo, f[0]);
copy(x->name, sizeof x->name, f[1]);
copy(x->description, sizeof x->description, f[2]);
x->stars = atoi(f[3]);
copy(x->tag, sizeof x->tag, f[4]);
copy(x->date, sizeof x->date, f[5]);
copy(x->asset, sizeof x->asset, f[6]);
x->arm64 = atoi(f[7]);
x->prerelease = atoi(f[8]);
x->is_new = atoi(f[9]);
}
free(body);
return n;
}
/* Looks a repo up; fills fd and returns 1, or writes the problem to err and returns 0. */
static int inspect(const char* repo, Found* fd, char* err, size_t errsz) {
char enc[600], path[700];
urlenc(enc, sizeof enc, repo);
snprintf(path, sizeof path, "/api/inspect?fmt=tsv&repo=%s", enc);
char* body = http("GET", path, 30);
memset(fd, 0, sizeof *fd);
err[0] = '\0';
if (!body) {
snprintf(err, errsz, "The Frametanium service didn't answer.");
return 0;
}
char* save;
int ok = 0;
EACH_LINE(body, line, save) {
char* f[9];
int k = split(line, f, 9);
if (k >= 2 && !strcmp(f[0], "!")) {
snprintf(err, errsz, "%s", f[1]);
break;
}
if (k >= 8 && !strcmp(f[0], "=")) {
copy(fd->repo, sizeof fd->repo, f[1]);
copy(fd->name, sizeof fd->name, f[2]);
copy(fd->description, sizeof fd->description, f[3]);
copy(fd->tag, sizeof fd->tag, f[4]);
copy(fd->date, sizeof fd->date, f[5]);
fd->prereleases = atoi(f[6]);
copy(fd->existing, sizeof fd->existing, f[7]);
ok = 1;
} else if (k >= 3 && ok && fd->nassets < MAX_ASSETS) {
copy(fd->assets[fd->nassets], sizeof fd->assets[0], f[0]);
fd->sizes[fd->nassets] = atol(f[1]);
fd->arm64[fd->nassets] = atoi(f[2]);
fd->nassets++;
}
}
free(body);
if (ok && fd->nassets == 0) {
snprintf(err, errsz, "No downloads for the Frame in its latest release.");
ok = 0;
}
return ok;
}
/* POSTs path; returns the reply's "!" error (or "") in err. */
static void post(const char* path, char* err, size_t errsz) {
char* body = http("POST", path, 30);
if (err) {
err[0] = '\0';
if (!body)
snprintf(err, errsz, "The Frametanium service didn't answer.");
else if (body[0] == '!' && body[1] == '\t')
snprintf(err, errsz, "%.*s", (int)strcspn(body + 2, "\n"), body + 2);
else if (strstr(body, "\"error\": \""))
snprintf(err, errsz, "%.*s", (int)strcspn(strstr(body, "\"error\": \"") + 10, "\""),
strstr(body, "\"error\": \"") + 10);
}
free(body);
}
static void action(const char* what, const char* id, const char* extra) {
char path[512];
snprintf(path, sizeof path, "/api/%s?id=%s%s", what, id, extra ? extra : "");
post(path, NULL, 0);
}
static void try_start_service(void) {
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", "frametanium.service", (char*)NULL);
_exit(127);
}
if (pid > 0) waitpid(pid, NULL, 0);
}
/* ---------------------------------------------------------------- drawing */
#define BG 0x0f1216
#define ROW 0x181d23
#define ROW_SEL 0x1f2a36
#define TXT 0xe8edf2
#define MUTED 0x93a0ad
#define ACCENT 0x4fa3ff
#define OK_C 0x4cc38a
#define WARN 0xf0b429
#define BAD 0xf2555a
static int busy_status(const char* s) {
return !strcmp(s, "downloading") || !strcmp(s, "installing");
}
static Uint32 status_color(const App* p) {
if (busy_status(p->status)) return ACCENT;
if (!strcmp(p->status, "update available")) return WARN;
if (!strcmp(p->status, "up to date")) return OK_C;
if (!strcmp(p->status, "error")) return BAD;
return MUTED;
}
static const char* status_label(const App* p) {
if (!strcmp(p->status, "downloading")) return "Downloading";
if (!strcmp(p->status, "installing")) return "Installing";
if (!strcmp(p->status, "update available")) return "Update available";
if (!strcmp(p->status, "up to date")) return "Up to date";
if (!strcmp(p->status, "error")) return "Problem";
return "Not installed";
}
static void clear(void) {
color(BG);
SDL_RenderClear(ren);
}
static int title(const char* s) {
int m = W / 20;
text_fit(s, m, m, S * 2, W - 2 * m, TXT);
return m + 22 * S;
}
static void legend(const char* s) {
int y = H - H / 20 - 8 * S;
fill(0, y - 12 * S, W, H - y + 12 * S, 0x151a20);
text_fit(s, W / 20, y, S, W - W / 10, MUTED);
}
/* First visible row so that sel stays on screen. */
static int scroll_first(int sel, int max_rows) {
return sel >= max_rows ? sel - max_rows + 1 : 0;
}
static void draw_list(App* apps, int n, int sel, const char* banner, Uint32 banner_rgb, int disc_new) {
clear();
int m = W / 20, y = title("Frametanium");
if (banner && *banner) {
text_fit(banner, m, y, S, W - 2 * m, banner_rgb);
y += 14 * S;
}
if (n < 0) {
text("Starting the Frametanium service...", m, y + 8 * S, S, MUTED);
legend("B Close");
SDL_RenderPresent(ren);
return;
}
int row_h = 50 * S;
int max_rows = (H - y - 40 * S) / row_h;
if (max_rows < 1) max_rows = 1;
int first = scroll_first(sel, max_rows);
for (int i = first; i <= n + 1 && i < first + max_rows; i++) {
int x = m, w = W - 2 * m;
if (i >= n) { /* the "discover" and "add" rows */
fill(x, y, w, 26 * S, i == sel ? ROW_SEL : ROW);
if (i == sel) fill(x, y, 3 * S, 26 * S, ACCENT);
char label[100];
if (i == n && disc_new > 0)
snprintf(label, sizeof label, "* Discover new apps for the Frame (%d new)", disc_new);
else
snprintf(label, sizeof label, "%s", i == n ? "* Discover new apps for the Frame" : "+ Add an app from GitHub");
text(label, x + 10 * S, y + 9 * S, S, ACCENT);
y += 32 * S;
continue;
}
App* p = &apps[i];
fill(x, y, w, row_h - 6 * S, i == sel ? ROW_SEL : ROW);
if (i == sel) fill(x, y, 3 * S, row_h - 6 * S, ACCENT);
int tx = x + 10 * S, ty = y + 8 * S;
const char* st = status_label(p);
int sw = (int)strlen(st) * 8 * S;
text_fit(p->name, tx, ty, S, w - 30 * S - sw, TXT);
text(st, x + w - 10 * S - sw, ty, S, status_color(p));
ty += 13 * S;
char line[300];
snprintf(line, sizeof line, "%s Installed: %s Latest: %s", p->repo, p->installed[0] ? p->installed : "-",
p->latest[0] ? p->latest : "-");
text_fit(line, tx, ty, S, w - 20 * S, MUTED);
ty += 12 * S;
if (busy_status(p->status)) {
int bw = w - 20 * S;
fill(tx, ty + 2 * S, bw, 4 * S, 0x2a313a);
fill(tx, ty + 2 * S, (int)(bw * p->progress), 4 * S, ACCENT);
} else if (p->message[0]) {
text_fit(p->message, tx, ty, S, w - 20 * S, !strcmp(p->job, "error") ? BAD : OK_C);
}
y += row_h;
}
legend("A Open Y Add X Discover Select Update all Start Check B Close");
SDL_RenderPresent(ren);
}
/* A simple list of choices under a heading. */
static void draw_menu(const char* heading, const char* sub, const char** items, int n, int sel, const char* err,
const char* keys) {
clear();
int m = W / 20, y = title(heading);
if (sub && *sub) {
text_fit(sub, m, y, S, W - 2 * m, MUTED);
y += 16 * S;
}
if (err && *err) {
y += text_wrap(err, m, y, S, W - 2 * m, 3, BAD) + 4 * S;
}
int row_h = 18 * S;
int max_rows = (H - y - 40 * S) / row_h;
if (max_rows < 1) max_rows = 1;
int first = scroll_first(sel, max_rows);
for (int i = first; i < n && i < first + max_rows; i++) {
if (i == sel) {
fill(m, y - 5 * S, W - 2 * m, row_h - 2 * S, ROW_SEL);
fill(m, y - 5 * S, 3 * S, row_h - 2 * S, ACCENT);
}
text_fit(items[i], m + 10 * S, y, S, W - 2 * m - 20 * S, i == sel ? TXT : MUTED);
y += row_h;
}
legend(keys);
SDL_RenderPresent(ren);
}
static void draw_wait(const char* heading, const char* what) {
clear();
int m = W / 20, y = title(heading);
text_fit(what, m, y + 8 * S, S, W - 2 * m, MUTED);
SDL_RenderPresent(ren);
}
static void draw_discover(const Disc* d, int n, int sel, int running, const char* msg, const char* err) {
clear();
int m = W / 20, y = title("Discover new apps for the Frame");
const char* sub = running ? (msg[0] ? msg : "Searching GitHub for Steam Frame projects...")
: msg[0] ? msg : "Steam Frame projects on GitHub with a release you can install.";
text_fit(sub, m, y, S, W - 2 * m, running ? ACCENT : MUTED);
y += 16 * S;
if (err && *err) {
text_fit(err, m, y, S, W - 2 * m, BAD);
y += 14 * S;
}
if (n <= 0) {
text(n < 0 ? "The Frametanium service didn't answer." : running ? "" : "Nothing new right now. Start searches again.",
m, y + 8 * S, S, MUTED);
}
int row_h = 50 * S;
int max_rows = (H - y - 40 * S) / row_h;
if (max_rows < 1) max_rows = 1;
int first = scroll_first(sel, max_rows);
for (int i = first; i < n && i < first + max_rows; i++) {
const Disc* x = &d[i];
int w = W - 2 * m, tx = m + 10 * S, ty = y + 8 * S;
fill(m, y, w, row_h - 6 * S, i == sel ? ROW_SEL : ROW);
if (i == sel) fill(m, y, 3 * S, row_h - 6 * S, ACCENT);
int nw = 0;
if (x->is_new) {
nw = 6 * 8 * S;
text("NEW", m + w - 10 * S - 3 * 8 * S, ty, S, ACCENT);
}
text_fit(x->name, tx, ty, S, w - 20 * S - nw, TXT);
ty += 13 * S;
if (x->description[0]) text_fit(x->description, tx, ty, S, w - 20 * S, MUTED);
ty += 12 * S;
char line[400];
snprintf(line, sizeof line, "%s %s%s %s %d stars%s", x->repo, x->tag, x->prerelease ? " (pre)" : "", x->date,
x->stars, x->arm64 ? "" : " not marked ARM64");
text_fit(line, tx, ty, S, w - 20 * S, x->arm64 ? MUTED : WARN);
y += row_h;
}
legend("A Look at it X Not interested Start Search again B Back");
SDL_RenderPresent(ren);
}
/* ---- on-screen keyboard */
static const char* KB_ROWS[] = { "1234567890", "qwertyuiop", "asdfghjkl-", "zxcvbnm_./" };
static const char* KB_KEYS[] = { "Delete", "Paste", "Clear", "Done" };
#define KB_SPECIAL_ROW 4
static void draw_keyboard(const char* entry, int row, int col, const char* err) {
clear();
int m = W / 20, y = title("Add an app from GitHub");
text("Type the repo, like owner/name (a full github.com link works too):", m, y, S, MUTED);
y += 16 * S;
fill(m, y, W - 2 * m, 20 * S, ROW);
char shown[300];
snprintf(shown, sizeof shown, "%s_", entry);
int maxc = (W - 2 * m - 20 * S) / (8 * S);
int len = (int)strlen(shown);
text(shown + (len > maxc ? len - maxc : 0), m + 10 * S, y + 6 * S, S, TXT);
y += 30 * S;
if (err && *err) {
text_fit(err, m, y, S, W - 2 * m, BAD);
}
y += 14 * S;
int key = 30 * S, gap = 4 * S;
int kw = 10 * key + 9 * gap;
int x0 = (W - kw) / 2;
for (int r = 0; r < 4; r++) {
for (int c = 0; c < 10; c++) {
int x = x0 + c * (key + gap), on = r == row && c == col;
fill(x, y, key, key, on ? ACCENT : ROW_SEL);
char ch[2] = { KB_ROWS[r][c], 0 };
text(ch, x + key / 2 - 4 * S, y + key / 2 - 4 * S, S, on ? 0xffffff : TXT);
}
y += key + gap;
}
int sw = (kw - 3 * gap) / 4;
for (int c = 0; c < 4; c++) {
int x = x0 + c * (sw + gap), on = row == KB_SPECIAL_ROW && c == col;
fill(x, y, sw, key, on ? ACCENT : ROW_SEL);
int tw = (int)strlen(KB_KEYS[c]) * 8 * S;
text(KB_KEYS[c], x + (sw - tw) / 2, y + key / 2 - 4 * S, S, on ? 0xffffff : TXT);
}
legend("A Type X Delete Start Done B Cancel");
SDL_RenderPresent(ren);
}
static void draw_found(const Found* fd, const char* err) {
clear();
int m = W / 20, y = title(fd->name);
text_fit(fd->repo, m, y, S, W - 2 * m, ACCENT);
y += 14 * S;
if (fd->description[0]) y += text_wrap(fd->description, m, y, S, W - 2 * m, 3, MUTED) + 6 * S;
char line[400];
snprintf(line, sizeof line, "Latest release: %s (%s)%s", fd->tag, fd->date,
fd->prereleases ? " pre-release" : "");
text_fit(line, m, y, S, W - 2 * m, TXT);
y += 20 * S;
if (fd->existing[0]) {
text("It's already on your list.", m, y, S, OK_C);
legend("A Show it B Back");
SDL_RenderPresent(ren);
return;
}
text("Download:", m, y, S, MUTED);
y += 14 * S;
fill(m, y - 5 * S, W - 2 * m, 18 * S, ROW_SEL);
snprintf(line, sizeof line, "%s %s (%.1f MB)%s %s", fd->nassets > 1 ? "<" : " ", fd->assets[fd->asset],
fd->sizes[fd->asset] / 1048576.0, fd->arm64[fd->asset] ? "" : " not ARM64", fd->nassets > 1 ? ">" : "");
text_fit(line, m + 10 * S, y, S, W - 2 * m - 20 * S, fd->arm64[fd->asset] ? TXT : WARN);
y += 22 * S;
if (fd->nassets > 1) {
snprintf(line, sizeof line, "%d downloads to choose from; Left/Right to switch.", fd->nassets);
text(line, m, y, S, MUTED);
y += 14 * S;
}
text_wrap("Frametanium unpacks it and either adds it to your Steam library or, if it comes with an install.sh, "
"runs that. Only add apps you trust.", m, y, S, W - 2 * m, 3, MUTED);
y += 40 * S;
if (err && *err) text_wrap(err, m, y, S, W - 2 * m, 3, BAD);
legend(fd->nassets > 1 ? "A Add and install Left/Right Download B Back" : "A Add and install B Back");
SDL_RenderPresent(ren);
}
/* ---------------------------------------------------------------- main */
enum { NONE, UP, DOWN, LEFT, RIGHT, OK, BACK, ADD, UPDATE_ALL, REFRESH, DELETE };
enum { LIST, MENU, VERSIONS, CONFIRM, ADDING, KEYBOARD, FOUND, DISCOVER };
static int map_button(int b) {
switch (b) {
case SDL_CONTROLLER_BUTTON_DPAD_UP: return UP;
case SDL_CONTROLLER_BUTTON_DPAD_DOWN: return DOWN;
case SDL_CONTROLLER_BUTTON_DPAD_LEFT: return LEFT;
case SDL_CONTROLLER_BUTTON_DPAD_RIGHT: return RIGHT;
case SDL_CONTROLLER_BUTTON_A: return OK;
case SDL_CONTROLLER_BUTTON_B: return BACK;
case SDL_CONTROLLER_BUTTON_Y: return ADD;
case SDL_CONTROLLER_BUTTON_X: return DELETE;
case SDL_CONTROLLER_BUTTON_BACK: return UPDATE_ALL;
case SDL_CONTROLLER_BUTTON_START: return REFRESH;
default: return NONE;
}
}
static int map_key(SDL_Keycode k, int typing) {
switch (k) {
case SDLK_UP: return UP;
case SDLK_DOWN: return DOWN;
case SDLK_LEFT: return LEFT;
case SDLK_RIGHT: return RIGHT;
case SDLK_RETURN: return typing ? REFRESH : OK; /* Enter finishes typing */
case SDLK_ESCAPE: return BACK;
case SDLK_BACKSPACE: return typing ? DELETE : BACK;
default: break;
}
if (typing) return NONE;
switch (k) {
case SDLK_a: return ADD;
case SDLK_u: return UPDATE_ALL;
case SDLK_r: return REFRESH;
case SDLK_x: return DELETE;
default: return NONE;
}
}
int main(void) {
/* PM_SCREENSHOT=<file.bmp> draws one frame into memory, saves it and exits (testing without a display);
* PM_SIZE=WxH sets its size, PM_SCREEN=menu|versions|add|keyboard|found|discover picks the screen (for the first app;
* PM_REPO is the repo for "found", PM_TYPED the text for "keyboard"). */
const char* shot = getenv("PM_SCREENSHOT");
SDL_Surface* shot_surface = NULL;
if (shot) {
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("Frametanium", 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);
SDL_StopTextInput();
read_port();
static App apps[MAX_APPS];
static Release rels[MAX_RELEASES];
static Suggestion sug[MAX_SUGGEST];
static Found fd;
static Disc disc[MAX_DISC];
int napps = -1, sel = 0, nrels = 0, rsel = 0, msel = 0, asel = 0, nsug = 0, tried_start = 0;
int ndisc = 0, dsel = 0, disc_running = 0, disc_new = 0, found_from = ADDING;
char disc_msg[300] = "";
Uint32 next_disc_poll = 0;
int screen = LIST, kb_row = 1, kb_col = 0, notice_busy = 0;
char err[300] = "", banner[300] = "", notice[300] = "", entry[256] = "", want_id[64] = "";
char hint_name[128] = "";
Uint32 next_poll = 0, axis_ready = 0;
const char* menu_items[8];
int menu_acts[8], nmenu = 0;
char menu_text[8][160];
enum { M_INSTALL, M_VERSIONS, M_PREVIOUS, M_PRE, M_REMOVE };
if (shot && getenv("PM_SCREEN")) {
const char* sc = getenv("PM_SCREEN");
napps = load_apps(apps, notice, sizeof notice, &notice_busy);
if (!strcmp(sc, "menu")) screen = MENU;
if (!strcmp(sc, "versions") && napps > 0) {
nrels = load_releases(apps[0].id, rels, err, sizeof err);
screen = VERSIONS;
}
if (!strcmp(sc, "add")) {
nsug = load_suggestions(sug);
screen = ADDING;
}
if (!strcmp(sc, "keyboard")) {
snprintf(entry, sizeof entry, "%s", getenv("PM_TYPED") ? getenv("PM_TYPED") : "");
kb_row = 2, kb_col = 3;
screen = KEYBOARD;
}
if (!strcmp(sc, "found")) {
inspect(getenv("PM_REPO") ? getenv("PM_REPO") : "", &fd, err, sizeof err);
screen = FOUND;
}
if (!strcmp(sc, "confirm")) screen = CONFIRM;
if (!strcmp(sc, "discover")) screen = DISCOVER;
ndisc = load_discover(disc, &disc_running, disc_msg, sizeof disc_msg, &disc_new);
next_poll = 0xffffffff;
}
for (;;) {
int act = NONE;
const char* typed = NULL;
SDL_Event e;
while (SDL_PollEvent(&e)) {
if (e.type == SDL_QUIT) goto done;
if (e.type == SDL_CONTROLLERDEVICEADDED) SDL_GameControllerOpen(e.cdevice.which);
if (e.type == SDL_CONTROLLERBUTTONDOWN) act = map_button(e.cbutton.button);
if (e.type == SDL_KEYDOWN) act = map_key(e.key.keysym.sym, screen == KEYBOARD);
if (e.type == SDL_TEXTINPUT && screen == KEYBOARD) typed = e.text.text;
if (e.type == SDL_CONTROLLERAXISMOTION && SDL_GetTicks() >= axis_ready && abs(e.caxis.value) > 24000) {
if (e.caxis.axis == SDL_CONTROLLER_AXIS_LEFTY) act = e.caxis.value < 0 ? UP : DOWN;
if (e.caxis.axis == SDL_CONTROLLER_AXIS_LEFTX) act = e.caxis.value < 0 ? LEFT : RIGHT;
if (act != NONE) axis_ready = SDL_GetTicks() + 220;
}
}
if (SDL_GetTicks() >= next_poll) {
napps = load_apps(apps, notice, sizeof notice, &notice_busy);
if (napps < 0 && !tried_start) {
tried_start = 1;
try_start_service();
}
int busy = notice_busy;
for (int i = 0; i < napps; i++) busy |= busy_status(apps[i].status);
next_poll = SDL_GetTicks() + (busy ? 400 : 2000);
if (want_id[0]) /* jump to an app that was just added */
for (int i = 0; i < napps; i++)
if (!strcmp(apps[i].id, want_id)) {
sel = i;
want_id[0] = '\0';
}
if (sel > napps + 1) sel = napps > 0 ? napps : 0;
snprintf(banner, sizeof banner, "%s",
napps < 0 && tried_start && SDL_GetTicks() > 8000
? "The Frametanium service isn't running. Restart the Frame and try again."
: notice);
}
if (SDL_GetTicks() >= next_disc_poll) {
ndisc = load_discover(disc, &disc_running, disc_msg, sizeof disc_msg, &disc_new);
next_disc_poll = SDL_GetTicks() + (screen == DISCOVER && disc_running ? 1500 : 30000);
if (dsel >= ndisc) dsel = ndisc > 0 ? ndisc - 1 : 0;
}
App* cur = sel < napps ? &apps[sel] : NULL;
switch (screen) {
case LIST:
if (act == UP && sel > 0) sel--;
if (act == DOWN && sel < napps + 1) sel++;
if (act == BACK) goto done;
if (napps < 0) break;
if (act == OK && cur) {
screen = MENU;
msel = 0;
err[0] = '\0';
} else if (act == DELETE || (act == OK && sel == napps)) {
screen = DISCOVER;
dsel = 0;
err[0] = '\0';
next_disc_poll = 0;
} else if (act == ADD || (act == OK && !cur)) {
draw_wait("Add an app from GitHub", "Loading suggestions...");
nsug = load_suggestions(sug);
asel = 0;
err[0] = '\0';
screen = ADDING;
}
if (act == REFRESH) post("/api/refresh", NULL, 0);
if (act == UPDATE_ALL) post("/api/update_all", NULL, 0);
if (act != NONE) next_poll = SDL_GetTicks() + 150;
break;
case MENU:
if (!cur) {
screen = LIST;
break;
}
nmenu = 0;
if (cur->latest[0]) {
if (!cur->installed[0])
snprintf(menu_text[nmenu], 160, "Install %s", cur->latest);
else if (!strcmp(cur->status, "update available"))
snprintf(menu_text[nmenu], 160, "Update to %s", cur->latest);
else
snprintf(menu_text[nmenu], 160, "Reinstall %s", cur->latest);
menu_acts[nmenu++] = M_INSTALL;
}
snprintf(menu_text[nmenu], 160, "Choose a version...");
menu_acts[nmenu++] = M_VERSIONS;
if (cur->can_rollback) {
snprintf(menu_text[nmenu], 160, "Go back to the previous version");
menu_acts[nmenu++] = M_PREVIOUS;
}
snprintf(menu_text[nmenu], 160, "Pre-releases: %s", cur->prereleases ? "included" : "skipped");
menu_acts[nmenu++] = M_PRE;
snprintf(menu_text[nmenu], 160, "Remove...");
menu_acts[nmenu++] = M_REMOVE;
for (int i = 0; i < nmenu; i++) menu_items[i] = menu_text[i];
if (msel >= nmenu) msel = nmenu - 1;
if (act == UP && msel > 0) msel--;
if (act == DOWN && msel < nmenu - 1) msel++;
if (act == BACK) screen = LIST;
if (act == OK) {
int m = menu_acts[msel];
if (m == M_INSTALL) {
action("install", cur->id, NULL);
screen = LIST;
} else if (m == M_VERSIONS) {
draw_wait(cur->name, "Loading versions...");
nrels = load_releases(cur->id, rels, err, sizeof err);
rsel = 0;
for (int i = 0; i < nrels; i++)
if (!strcmp(rels[i].tag, cur->installed)) rsel = i;
screen = VERSIONS;
} else if (m == M_PREVIOUS) {
action("rollback", cur->id, NULL);
screen = LIST;
} else if (m == M_PRE) {
action("settings", cur->id, cur->prereleases ? "&prereleases=0" : "&prereleases=1");
cur->prereleases = !cur->prereleases;
} else if (m == M_REMOVE) {
screen = CONFIRM;
}
next_poll = SDL_GetTicks() + 150;
}
break;
case VERSIONS:
if (act == UP && rsel > 0) rsel--;
if (act == DOWN && rsel < nrels - 1) rsel++;
if (act == BACK) screen = MENU;
if (act == OK && nrels > 0 && cur) {
char extra[100], enc[80];
urlenc(enc, sizeof enc, rels[rsel].tag);
snprintf(extra, sizeof extra, "&tag=%s", enc);
action("install", cur->id, extra);
screen = LIST;
next_poll = 0;
}
break;
case CONFIRM:
if (act == BACK) screen = MENU;
if (act == OK && cur) {
action("remove", cur->id, NULL);
screen = LIST;
next_poll = SDL_GetTicks() + 300;
}
break;
case ADDING: {
int has_clip = SDL_HasClipboardText();
int nitems = 1 + has_clip + nsug;
if (act == UP && asel > 0) asel--;
if (act == DOWN && asel < nitems - 1) asel++;
if (act == BACK) screen = LIST;
if (act == OK) {
const char* repo = NULL;
hint_name[0] = '\0';
if (asel == 0) {
screen = KEYBOARD;
err[0] = '\0';
} else if (has_clip && asel == 1) {
char* clip = SDL_GetClipboardText();
snprintf(entry, sizeof entry, "%s", clip ? clip : "");
SDL_free(clip);
repo = entry;
} else {
Suggestion* s = &sug[asel - 1 - has_clip];
repo = s->repo;
snprintf(hint_name, sizeof hint_name, "%s", s->name);
}
if (repo) {
draw_wait("Add an app from GitHub", "Looking it up on GitHub...");
if (inspect(repo, &fd, err, sizeof err)) {
if (hint_name[0]) snprintf(fd.name, sizeof fd.name, "%s", hint_name);
err[0] = '\0';
found_from = ADDING;
screen = FOUND;
}
}
}
if (screen == KEYBOARD) SDL_StartTextInput();
break;
}
case DISCOVER:
if (act == UP && dsel > 0) dsel--;
if (act == DOWN && dsel < ndisc - 1) dsel++;
if (act == BACK) {
if (disc_new > 0) post("/api/discover/seen", NULL, 0); /* everything listed has now been seen */
screen = LIST;
next_disc_poll = SDL_GetTicks() + 300;
}
if (act == REFRESH && !disc_running) {
post("/api/discover/refresh", NULL, 0);
next_disc_poll = SDL_GetTicks() + 400;
}
if (act == DELETE && dsel < ndisc) {
char enc[300], path[400];
urlenc(enc, sizeof enc, disc[dsel].repo);
snprintf(path, sizeof path, "/api/discover/hide?repo=%s", enc);
post(path, NULL, 0);
next_disc_poll = 0;
}
if (act == OK && dsel < ndisc) {
draw_wait(disc[dsel].name, "Looking it up on GitHub...");
if (inspect(disc[dsel].repo, &fd, err, sizeof err)) {
snprintf(fd.name, sizeof fd.name, "%s", disc[dsel].name);
err[0] = '\0';
found_from = DISCOVER;
screen = FOUND;
}
}
break;
case KEYBOARD: {
size_t len = strlen(entry);
if (typed && len + strlen(typed) < sizeof entry - 1) strcat(entry, typed);
if (act == UP) kb_row = kb_row > 0 ? kb_row - 1 : KB_SPECIAL_ROW;
if (act == DOWN) kb_row = kb_row < KB_SPECIAL_ROW ? kb_row + 1 : 0;
int cols = kb_row == KB_SPECIAL_ROW ? 4 : 10;
if (kb_col >= cols) kb_col = cols - 1;
if (act == LEFT) kb_col = (kb_col + cols - 1) % cols;
if (act == RIGHT) kb_col = (kb_col + 1) % cols;
int done_typing = act == REFRESH;
if (act == DELETE && len) entry[len - 1] = '\0';
if (act == OK) {
if (kb_row < KB_SPECIAL_ROW) {
if (len < sizeof entry - 2) {
entry[len] = KB_ROWS[kb_row][kb_col];
entry[len + 1] = '\0';
}
} else if (kb_col == 0 && len) {
entry[len - 1] = '\0';
} else if (kb_col == 1) {
char* clip = SDL_GetClipboardText();
if (clip && len + strlen(clip) < sizeof entry - 1) strcat(entry, clip);
SDL_free(clip);
} else if (kb_col == 2) {
entry[0] = '\0';
} else if (kb_col == 3) {
done_typing = 1;
}
}
if (act == BACK) {
SDL_StopTextInput();
screen = ADDING;
}
if (done_typing && entry[0]) {
draw_wait("Add an app from GitHub", "Looking it up on GitHub...");
if (inspect(entry, &fd, err, sizeof err)) {
err[0] = '\0';
SDL_StopTextInput();
found_from = ADDING;
screen = FOUND;
}
}
break;
}
case FOUND:
if (act == BACK) {
screen = found_from;
err[0] = '\0';
}
if (fd.existing[0]) {
if (act == OK) {
snprintf(want_id, sizeof want_id, "%s", fd.existing);
screen = LIST;
next_poll = 0;
}
break;
}
if (act == LEFT && fd.nassets > 1) fd.asset = (fd.asset + fd.nassets - 1) % fd.nassets;
if (act == RIGHT && fd.nassets > 1) fd.asset = (fd.asset + 1) % fd.nassets;
if (act == OK) {
char r[300], a[300], n[300], path[1100];
urlenc(r, sizeof r, fd.repo);
urlenc(a, sizeof a, fd.assets[fd.asset]);
urlenc(n, sizeof n, fd.name);
snprintf(path, sizeof path, "/api/add?fmt=tsv&install=1&repo=%s&asset=%s&name=%s", r, a, n);
draw_wait(fd.name, "Adding it...");
char* body = http("POST", path, 30);
if (!body) {
snprintf(err, sizeof err, "The Frametanium service didn't answer.");
} else if (body[0] == '!') {
snprintf(err, sizeof err, "%.*s", (int)strcspn(body + 2, "\n"), body + 2);
} else {
if (body[0] == '=') snprintf(want_id, sizeof want_id, "%.*s", (int)strcspn(body + 2, "\n"), body + 2);
screen = LIST;
entry[0] = '\0';
next_poll = 0;
next_disc_poll = 0;
}
free(body);
}
break;
}
SDL_GetRendererOutputSize(ren, &W, &H);
S = H >= 1000 ? 3 : 2;
cur = sel < napps ? &apps[sel] : NULL;
if (screen == MENU && cur) {
char sub[300];
snprintf(sub, sizeof sub, "%s Installed: %s Latest: %s", cur->repo,
cur->installed[0] ? cur->installed : "-", cur->latest[0] ? cur->latest : "-");
draw_menu(cur->name, sub, menu_items, nmenu, msel, cur->message[0] && !strcmp(cur->job, "error") ? cur->message : "",
"A Select B Back");
} else if (screen == VERSIONS && cur) {
const char* items[MAX_RELEASES];
static char lines[MAX_RELEASES][200];
for (int i = 0; i < nrels; i++) {
snprintf(lines[i], sizeof lines[i], "%s %s%s%s%s", rels[i].tag, rels[i].date,
rels[i].prerelease ? " pre-release" : "", !strcmp(rels[i].tag, cur->latest) ? " (latest)" : "",
!strcmp(rels[i].tag, cur->installed) ? " (installed)" : "");
items[i] = lines[i];
}
draw_menu(cur->name, "Choose a version to install:", items, nrels, rsel, err, "A Install this version B Back");
} else if (screen == CONFIRM && cur) {
const char* items[] = { "Yes, remove it" };
char sub[300];
snprintf(sub, sizeof sub, "Remove %s?", cur->name);
draw_menu(cur->name, sub, items, 1, 0,
cur->installed[0] ? "Its files are deleted. Saves and settings kept outside its folder stay." : "",
"A Remove B Cancel");
} else if (screen == ADDING) {
const char* items[2 + MAX_SUGGEST];
static char lines[MAX_SUGGEST][300];
int k = 0, has_clip = SDL_HasClipboardText();
items[k++] = "Type a GitHub repo...";
if (has_clip) items[k++] = "Paste a link from the clipboard";
for (int i = 0; i < nsug; i++) {
snprintf(lines[i], sizeof lines[i], "%s - %s", sug[i].name, sug[i].note);
items[k++] = lines[i];
}
draw_menu("Add an app from GitHub", nsug ? "Type a repo, or pick one made for the Frame:" : "", items, k, asel,
err, "A Select B Back");
} else if (screen == KEYBOARD) {
draw_keyboard(entry, kb_row, kb_col, err);
} else if (screen == FOUND) {
draw_found(&fd, err);
} else if (screen == DISCOVER) {
draw_discover(disc, ndisc, dsel, disc_running, disc_msg, err);
} else {
if (screen != LIST) screen = LIST;
int is_err = strstr(banner, "isn't running") != NULL;
draw_list(apps, napps, sel, banner, is_err ? WARN : OK_C, disc_new);
}
if (shot) {
SDL_SaveBMP(shot_surface, shot);
goto done;
}
SDL_Delay(16);
}
done:
SDL_Quit();
return 0;
}