diff --git a/README.md b/README.md index 7f15bd2..822c6a4 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,12 @@ and Frametanium keeps it up to date. Works from the headset or a browser. - **Add any GitHub repo** by typing `owner/name` (or pasting a link) on the headset's on-screen keyboard, from the browser page, or by picking a suggestion made for the Frame. The built-in ports (Ship of Harkinian, 2 Ship 2 Harkinian) are there from the start. +- **Discover new Frame apps:** every 12 hours (or on demand) it searches GitHub for Steam Frame projects (the + `steam-frame` topic, "steam frame" in names and descriptions, and the community's + [awesome-steam-frame](https://github.com/kosantosbik/awesome-steam-frame) list) and shows the ones whose latest + release has a download it can install, with ones you haven't seen marked **New**. Pick one to look at and add it, + or hide it. Without a GitHub token, GitHub allows 60 lookups an hour, so each search checks a batch (keeping some + for update checks) and the next search carries on; results are cached and only rechecked when a repo changes. - **Picks the right download:** ARM64 Linux builds (`aarch64`/`arm64`, "steamframe"); checksums, Windows, macOS, Android and x86 files are passed over. You can choose another one when adding. Zips, tarballs (gz/xz/bz2/zst), AppImages and single programs all work. @@ -23,7 +29,7 @@ and Frametanium keeps it up to date. Works from the headset or a browser. ## Use it - **In the headset:** open **Frametanium** in the library. D-pad to choose, **A** open an app (install/update, - versions, previous version, pre-releases, remove), **Y** add an app, **Select** update all, **Start** check for + versions, previous version, pre-releases, remove), **Y** add an app, **X** discover new apps, **Select** update all, **Start** check for updates, **B** back/close. - **From a phone or PC:** `http://:8643` (add apps, edit name/install style/program, remove). @@ -32,6 +38,8 @@ Updates are checked every 6 hours and on demand. ## Files - `catalog.json`: the built-in ports. - `suggestions.json`: apps offered on the Add screen. +- `~/.config/frametanium/discover.json`: discovered projects you've seen or hidden; `~/.cache/frametanium/discover.json` + caches what each search found. - `~/.config/frametanium/catalog.json` on the Frame: the apps you added and changes to built-in ones (`{"id": "soh", "hidden": true}` hides one; adding its repo again brings it back). - `~/.config/frametanium/installed.json`: installed versions. diff --git a/app/app.c b/app/app.c index fd1ce5e..950b615 100644 --- a/app/app.c +++ b/app/app.c @@ -3,11 +3,11 @@ * 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); and the found repo, - * where you pick its download and add it. + * 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, Select update all, Start check for - * updates. Keyboard: arrows, Enter, Esc, and typing on the on-screen keyboard screen. */ + * 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 #include @@ -28,6 +28,7 @@ #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]; @@ -44,6 +45,11 @@ 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; @@ -297,6 +303,41 @@ static int load_suggestions(Suggestion* sug) { 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]; @@ -433,7 +474,7 @@ 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) { +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) { @@ -450,13 +491,19 @@ static void draw_list(App* apps, int n, int sel, const char* banner, Uint32 bann 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++) { + for (int i = first; i <= n + 1 && i < first + max_rows; i++) { int x = m, w = W - 2 * m; - if (i == n) { /* the "add" row */ + 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); - text("+ Add an app from GitHub", x + 10 * S, y + 9 * S, S, ACCENT); - break; + 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); @@ -481,7 +528,7 @@ static void draw_list(App* apps, int n, int sel, const char* banner, Uint32 bann } y += row_h; } - legend("A Open Y Add app Select Update all Start Check B Close"); + legend("A Open Y Add X Discover Select Update all Start Check B Close"); SDL_RenderPresent(ren); } @@ -520,6 +567,49 @@ static void draw_wait(const char* heading, const char* what) { 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_./" }; @@ -605,7 +695,7 @@ static void draw_found(const Found* fd, const char* err) { /* ---------------------------------------------------------------- main */ enum { NONE, UP, DOWN, LEFT, RIGHT, OK, BACK, ADD, UPDATE_ALL, REFRESH, DELETE }; -enum { LIST, MENU, VERSIONS, CONFIRM, ADDING, KEYBOARD, FOUND }; +enum { LIST, MENU, VERSIONS, CONFIRM, ADDING, KEYBOARD, FOUND, DISCOVER }; static int map_button(int b) { switch (b) { @@ -639,13 +729,14 @@ static int map_key(SDL_Keycode k, int typing) { 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= draws one frame into memory, saves it and exits (testing without a display); - * PM_SIZE=WxH sets its size, PM_SCREEN=menu|versions|add|keyboard|found picks the screen (for the first app; + * PM_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; @@ -675,7 +766,11 @@ int main(void) { 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] = ""; @@ -707,6 +802,8 @@ int main(void) { 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; } @@ -742,24 +839,34 @@ int main(void) { sel = i; want_id[0] = '\0'; } - if (sel > napps) sel = napps > 0 ? napps : 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) 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); @@ -877,6 +984,7 @@ int main(void) { 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; } } @@ -885,6 +993,36 @@ int main(void) { 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); @@ -923,6 +1061,7 @@ int main(void) { if (inspect(entry, &fd, err, sizeof err)) { err[0] = '\0'; SDL_StopTextInput(); + found_from = ADDING; screen = FOUND; } } @@ -930,7 +1069,10 @@ int main(void) { } case FOUND: - if (act == BACK) screen = ADDING; + 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); @@ -958,6 +1100,7 @@ int main(void) { screen = LIST; entry[0] = '\0'; next_poll = 0; + next_disc_poll = 0; } free(body); } @@ -1006,10 +1149,12 @@ int main(void) { 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); + draw_list(apps, napps, sel, banner, is_err ? WARN : OK_C, disc_new); } if (shot) { SDL_SaveBMP(shot_surface, shot); diff --git a/manager.py b/manager.py index 2ab397b..935b39b 100755 --- a/manager.py +++ b/manager.py @@ -130,9 +130,15 @@ def gh_request(url, accept="application/vnd.github+json"): return urllib.request.Request(url, headers=headers) +_rate = {"core": None, "search": None} # requests GitHub still allows this hour (or minute, for search) + + def gh_json(url): try: with urllib.request.urlopen(gh_request(url), timeout=20) as r: + left = r.headers.get("X-RateLimit-Remaining") + if left is not None: + _rate[r.headers.get("X-RateLimit-Resource") or "core"] = int(left) return json.load(r) except urllib.error.HTTPError as e: if e.code == 404: @@ -329,6 +335,179 @@ def add_app(repo_text, asset=None, name=None, mode="auto", prereleases=None): return pid +# ---------------------------------------------------------------- discover + +# Finding new Steam Frame projects: GitHub search plus the community's awesome-steam-frame list give the candidates, +# and each candidate's releases are checked for a download the Frame can install. Without a token GitHub allows 60 +# requests an hour, so the verdicts are cached (and rechecked when the repo changes) and each run checks a batch, +# leaving room for update checks; repeated runs work through the rest. + +DISCOVER_FILE = CONFIG_DIR / "discover.json" # {"seen": [...], "hidden": [...]}: what the user has looked at +DISCOVER_CACHE = CACHE_DIR / "discover.json" # {"repos": {repo: candidate + verdict}, "last_run": time} +DISCOVER_QUERIES = ['topic:steam-frame', 'topic:steamframe', '"steam frame" in:name,description', + 'steamframe in:name,description', '"steam-frame" in:name'] +AWESOME_LISTS = ["kosantosbik/awesome-steam-frame"] +DISCOVER_EVERY = 12 * 3600 +_discover = {"running": False, "message": "", "lock": threading.Lock()} + + +def awesome_candidates(): + """GitHub repos linked from the awesome lists, with the list's name and description for them.""" + out = {} + for lst in AWESOME_LISTS: + try: + req = urllib.request.Request(f"https://raw.githubusercontent.com/{lst}/HEAD/README.md", + headers={"User-Agent": "frametanium"}) + with urllib.request.urlopen(req, timeout=20) as r: + text = r.read(512 * 1024).decode("utf-8", "replace") + except OSError: + continue + for m in re.finditer(r"^\s*[-*] \[([^\]]+)\]\(https://github\.com/([\w.-]+/[\w.-]+?)/?\)\s*-?\s*(.*)$", + text, re.M): + out[m.group(2)] = {"repo": m.group(2), "name": m.group(1).strip(), "description": m.group(3).strip(), + "stars": None, "pushed": None, "listed": True} + return out + + +def search_candidates(): + out = {} + for q in DISCOVER_QUERIES: + url = "https://api.github.com/search/repositories?" + urllib.parse.urlencode( + {"q": q, "sort": "updated", "order": "desc", "per_page": 100}) + try: + items = gh_json(url).get("items", []) + except RuntimeError: + continue + for it in items: + if it.get("archived") or it.get("fork"): + continue + out[it["full_name"]] = { + "repo": it["full_name"], "name": nice_name(it["name"]), + "description": re.sub(r"\s+", " ", it.get("description") or "").strip(), + "stars": it.get("stargazers_count", 0), "pushed": it.get("pushed_at"), + "topic": q.startswith("topic:"), + } + return out + + +def check_candidate(c): + """Fills in the newest release with a download for the Frame, or "none".""" + c["checked"] = int(time.time()) + c["checked_pushed"] = c.get("pushed") + c.pop("release", None) + for rel in gh_json(f"https://api.github.com/repos/{c['repo']}/releases?per_page=5"): + if rel.get("draft"): + continue + asset = choose_asset({}, rel.get("assets", [])) + if asset: + c["release"] = {"tag": rel["tag_name"], "date": (rel.get("published_at") or "")[:10], + "asset": asset["name"], "arm64": bool(OUR_CPU.search(asset["name"])), + "prerelease": bool(rel.get("prerelease"))} + return + + +def needs_check(c, now): + if "checked" not in c: + return True + if c.get("pushed") and c.get("pushed") != c.get("checked_pushed"): + return True + return now - c["checked"] > (2 if c.get("release") else 7) * 86400 + + +def discover(force=False): + """One discovery run; returns False if one is already going or the last one was recent.""" + if not _discover["lock"].acquire(blocking=False): + return False + try: + cache = load_json(DISCOVER_CACHE, {"repos": {}, "last_run": 0}) + if not force and time.time() - cache.get("last_run", 0) < DISCOVER_EVERY: + return False + _discover.update(running=True, message="Searching GitHub for Steam Frame projects...") + found = awesome_candidates() + for repo, c in search_candidates().items(): + listed = found.get(repo, {}) + found[repo] = {**listed, **c, "listed": bool(listed)} + if listed: # the list's hand-written name reads better than one made from the repo name + found[repo]["name"] = listed["name"] + found[repo]["description"] = c["description"] or listed["description"] + repos = cache["repos"] + for repo, c in found.items(): + old = repos.get(repo, {}) + keep = {k: old[k] for k in ("checked", "checked_pushed", "release") if k in old} + if found[repo].get("stars") is None and old.get("stars") is not None: # list-only entry seen in search before + keep.update(stars=old["stars"], pushed=old.get("pushed")) + repos[repo] = {**c, **keep} + mine = {p["repo"].lower() for p in catalog().values()} | {"cogential/frametanium"} + now = time.time() + todo = [c for c in repos.values() if c["repo"].lower() not in mine and needs_check(c, now)] + # Projects the community listed or tagged first, then the most recently changed. + todo.sort(key=lambda c: (not c.get("listed"), not c.get("topic"), c.get("checked", 0) > 0, + -(time.mktime(time.strptime(c["pushed"][:19], "%Y-%m-%dT%H:%M:%S")) + if c.get("pushed") else 0))) + budget = 150 if config().get("github_token") else 35 + checked = 0 + for c in todo[:budget]: + if _rate["core"] is not None and _rate["core"] < (100 if config().get("github_token") else 15): + break # leave the rest of the hour for update checks + _discover["message"] = f"Checking {c['repo']} ({checked + 1} of {min(len(todo), budget)})..." + try: + check_candidate(c) + except RuntimeError as e: + if "not found" not in str(e): + break # rate limit or offline: try again next run + c["checked"], c["checked_pushed"] = int(time.time()), c.get("pushed") # deleted or renamed + checked += 1 + left = len(todo) - checked + cache["last_run"] = int(time.time()) + save_json(DISCOVER_CACHE, cache) + if not left: + _discover["message"] = "" + elif checked: + _discover["message"] = f"Checked {checked} projects; {left} more on the next search (GitHub's hourly limit)." + else: + _discover["message"] = f"GitHub's hourly limit is used up; {left} projects left to check on the next search." + return True + finally: + _discover["running"] = False + _discover["lock"].release() + + +def discovered(): + """Found projects with a download for the Frame that aren't on the list yet: unseen ones first, then newest.""" + cache = load_json(DISCOVER_CACHE, {"repos": {}}) + prefs = load_json(DISCOVER_FILE, {"seen": [], "hidden": []}) + seen, hidden = {r.lower() for r in prefs["seen"]}, {r.lower() for r in prefs["hidden"]} + mine = {p["repo"].lower() for p in catalog().values()} | {"cogential/frametanium"} + mine |= {x["repo"].lower() for x in suggestions()} # already offered as suggestions + rows = [] + for c in cache["repos"].values(): + rel = c.get("release") + if not rel or c["repo"].lower() in mine | hidden: + continue + rows.append({ + "repo": c["repo"], "name": c["name"], "description": c.get("description", "")[:300], + "stars": c.get("stars") or 0, "tag": rel["tag"], "date": rel["date"], "asset": rel["asset"], + "arm64": rel["arm64"], "prerelease": rel["prerelease"], "new": c["repo"].lower() not in seen, + }) + rows.sort(key=lambda r: r["date"], reverse=True) + rows.sort(key=lambda r: not r["new"]) + return rows + + +def discover_mark(repos, key): + prefs = load_json(DISCOVER_FILE, {"seen": [], "hidden": []}) + have = {r.lower() for r in prefs[key]} + prefs[key] += [r for r in repos if r and r.lower() not in have] + save_json(DISCOVER_FILE, prefs) + + +def start_discover(force=True): + if _discover["running"]: + return False + threading.Thread(target=discover, kwargs={"force": force}, daemon=True).start() + return True + + # ---------------------------------------------------------------- Steam @@ -1045,7 +1224,7 @@ class Handler(BaseHTTPRequestHandler): "text/plain") else: self.reply({"apps": rows, "token_set": bool(config().get("github_token")), "busy": jobs.busy(), - "notice": notice}) + "notice": notice, "discover_new": sum(r["new"] for r in discovered())}) elif url.path == "/api/releases": port = catalog().get(arg("id")) if not port: @@ -1071,6 +1250,22 @@ class Handler(BaseHTTPRequestHandler): self.reply("=\t" + head + tsv(info["assets"], ["name", "size", "arm64"]), "text/plain") else: self.reply(info) + elif url.path == "/api/discover": + rows = discovered() + cache = load_json(DISCOVER_CACHE, {}) + if not cache.get("last_run") and not _discover["running"]: + start_discover() + running = _discover["running"] or not cache.get("last_run") + new = sum(r["new"] for r in rows) + if fmt == "tsv": + message = re.sub(r"[\t\n]+", " ", _discover["message"]) + head = f"#\t{int(running)}\t{message}\t{new}\n" + self.reply(head + (tsv(rows, ["repo", "name", "description", "stars", "tag", "date", "asset", + "arm64", "prerelease", "new"]) if rows else ""), + "text/plain") + else: + self.reply({"projects": rows, "running": running, "message": _discover["message"], "new": new, + "last_run": cache.get("last_run", 0)}) elif url.path == "/api/suggestions": have = {p["repo"].lower() for p in catalog().values()} rows = [{**s, "added": s["repo"].lower() in have} for s in suggestions()] @@ -1121,6 +1316,14 @@ class Handler(BaseHTTPRequestHandler): start(install, new) self.reply(f"=\t{new}\n" if fmt == "tsv" else {"ok": True, "id": new}, "text/plain" if fmt == "tsv" else "application/json") + elif url.path == "/api/discover/refresh": + self.reply({"ok": start_discover()}) + elif url.path in ("/api/discover/seen", "/api/discover/hide"): + repos = [r for r in arg("repos").split(",") if r] or ([arg("repo")] if arg("repo") else []) + if url.path.endswith("seen") and not repos: + repos = [r["repo"] for r in discovered()] + discover_mark(repos, "seen" if url.path.endswith("seen") else "hidden") + self.reply({"ok": True}) elif url.path == "/api/settings": port = catalog().get(pid) if not port: @@ -1158,6 +1361,7 @@ class Handler(BaseHTTPRequestHandler): def checker(): while True: refresh(force=True) + discover() time.sleep(max(1, float(config().get("check_hours", 6))) * 3600) diff --git a/page.html b/page.html index 7325399..4150a44 100644 --- a/page.html +++ b/page.html @@ -45,6 +45,11 @@ .found { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 12px; } .notice { color: var(--ok); margin: 0 0 12px; } label { display: flex; gap: 8px; align-items: center; } + .disc { border-top: 1px solid var(--line); padding: 12px 0; } + .disc:first-child { border-top: 0; padding-top: 4px; } + .disc .desc { margin: 4px 0; font-size: .92rem; overflow-wrap: anywhere; } + .badge.new { color: var(--accent); border-color: var(--accent); } + .warn { color: var(--warn); } @@ -63,6 +68,15 @@
+
+

New for the Frame on GitHub

+
+

Projects made for the Steam Frame with a release you can install. Searched every + 12 hours.

+
+ +
+
@@ -232,7 +246,7 @@ async function lookup(repo, name) { const add = async install => { const r = await post(`/api/add?repo=${q(info.repo)}&asset=${q(assetSel.value)}&name=${q(nameIn.value)}&mode=${q(modeSel.value)}&install=${install}`); if (r.error) { $("adderr").textContent = r.error; $("adderr").hidden = false; return; } - f.hidden = true; $("repo").value = ""; last = ""; poll(); loadSuggestions(); + f.hidden = true; $("repo").value = ""; last = ""; poll(); loadSuggestions(); loadDiscover(); }; f.append( el("div", { className: "row" }, "Name", nameIn), el("p"), @@ -256,6 +270,49 @@ async function loadSuggestions() { if (box.children.length) box.prepend(el("div", { className: "hint", style: "width:100%", textContent: "Suggested for the Frame:" })); } +// ---- discover + +const shownNew = new Set(); // repos that were new when this page opened keep their badge until reload +let discAll = false, discTimer = null; +async function loadDiscover() { + const d = await get("/api/discover"); + for (const p of d.projects || []) if (p.new) shownNew.add(p.repo); + const box = $("disclist"); + box.innerHTML = ""; + const rows = d.projects || []; + const show = discAll ? rows : rows.slice(0, 8); + for (const p of show) { + const lookBtn = el("button", { className: "primary", textContent: "Look at it" }); + lookBtn.onclick = () => { $("repo").value = p.repo; lookup(p.repo, p.name); $("repo").scrollIntoView({ behavior: "smooth" }); }; + const hide = el("button", { textContent: "Not interested" }); + hide.onclick = async () => { await post(`/api/discover/hide?repo=${q(p.repo)}`); loadDiscover(); }; + const meta = `${p.tag}${p.prerelease ? " (pre-release)" : ""} · ${p.date} · ★ ${p.stars} · ${p.asset}`; + box.append(el("div", { className: "disc" }, + el("div", { className: "top" }, + el("div", {}, el("span", { className: "name", textContent: p.name + " " }), + shownNew.has(p.repo) ? el("span", { className: "badge new", textContent: "New" }) : "", + el("br"), el("a", { className: "repo", href: `https://github.com/${p.repo}`, target: "_blank", rel: "noopener", textContent: p.repo }))), + el("p", { className: "desc", textContent: p.description || "" }), + el("p", { className: "hint", textContent: meta }, + p.arm64 ? "" : el("span", { className: "warn", textContent: " · doesn't say ARM64, check before installing" })), + el("div", { className: "row" }, lookBtn, hide))); + } + if (!rows.length) box.append(el("p", { className: "hint", textContent: d.running ? "Searching…" : "Nothing new right now." })); + $("discmore").hidden = rows.length <= 8; + $("discmore").textContent = discAll ? "Show fewer" : `Show all ${rows.length}`; + const n = [...shownNew].filter(r => rows.some(p => p.repo === r)).length; + $("newcount").hidden = !n; + $("newcount").textContent = `${n} new`; + $("discstate").textContent = d.running ? (d.message || "Searching GitHub…") + : (d.message || "Projects made for the Steam Frame with a release you can install. Searched every 12 hours."); + $("research").disabled = d.running; + clearTimeout(discTimer); + if (d.running) discTimer = setTimeout(loadDiscover, 2000); + else if (rows.some(p => p.new)) setTimeout(() => post("/api/discover/seen?repos=" + q(rows.map(p => p.repo).join(","))), 3000); +} +$("discmore").onclick = () => { discAll = !discAll; loadDiscover(); }; +$("research").onclick = async () => { await post("/api/discover/refresh"); setTimeout(loadDiscover, 500); }; + let timer = null, last = ""; async function poll() { const s = await get("/api/state"); @@ -291,6 +348,7 @@ $("savetoken").onclick = async () => { }; poll(); loadSuggestions(); +loadDiscover();