mirror of
https://github.com/Cogential/frametanium.git
synced 2026-10-21 09:00:31 +02:00
Add any GitHub repo, Obtainium-style
Apps can now be added by repo (headset on-screen keyboard, pasted link, suggestions or the web page). The service picks the ARM64 Linux download, unpacks zips/tarballs/AppImages/single programs, and either adds the app to the Steam library with a generated run.sh and GitHub art, or runs the project's own install.sh. Adds update all, per-app pre-release setting, remove (with uninstaller), and an edit panel on the web page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHDaz86aayqMULHsjByEmT
This commit is contained in:
1 parent
65c6ad3eaa
commit
94865cb34f
9 files changed
+1559
-263
No files matched your search
@@ -1,32 +1,40 @@
|
||||
# Port Manager for the Steam Frame
|
||||
|
||||
Installs and updates native Steam Frame ports straight from their GitHub releases, from the headset or a browser.
|
||||
Installs and updates Steam Frame apps and ports straight from their GitHub releases, like Obtainium: add any repo,
|
||||
and Port Manager keeps it up to date. Works from the headset or a browser.
|
||||
|
||||
- **Install, update, or pick any version** of each port in the catalog. Pre-releases count.
|
||||
- **Keeps your stuff:** saves live outside the install folder, and ROMs placed next to `run.sh` are carried over.
|
||||
- **One-step rollback** to the version you had before (the previous install is kept).
|
||||
- Fixes executable bits, unpacks AppImages (no FUSE needed), writes a launcher when a port needs one,
|
||||
registers the game with Steam (`steam-client-create-shortcut`, Steam Linux Runtime 4 arm64) and adds library art.
|
||||
- Won't touch a game that's running.
|
||||
- **Add any GitHub repo** by typing `owner/name` (or pasting a link) on the headset's on-screen keyboard, from the
|
||||
browser page, or by picking a suggestion made for the Frame. The built-in ports (Ship of Harkinian, 2 Ship 2
|
||||
Harkinian) are there from the start.
|
||||
- **Picks the right download:** ARM64 Linux builds (`aarch64`/`arm64`, "steamframe"); checksums, Windows, macOS,
|
||||
Android and x86 files are passed over. You can choose another one when adding. Zips, tarballs (gz/xz/bz2/zst),
|
||||
AppImages and single programs all work.
|
||||
- **Two ways to install,** picked from the download unless you choose:
|
||||
- *Library app:* unpacked into `~/devkit-game/<name>`, given a `run.sh` (its own, or one that starts the ARM64
|
||||
program it finds), added to the Steam library with art (the repo's GitHub card and the owner's avatar).
|
||||
- *Its installer:* when the download has `install.sh` (or `contrib/install.sh`, `setup.sh`), it's unpacked into
|
||||
`~/.local/share/port-manager/apps/<id>` and that script runs, for tools such as SteamVR overlays. Removing the app
|
||||
runs its uninstaller when it has one.
|
||||
- **Install, update, update all, or pick any version.** Pre-releases are skipped unless you turn them on for an app
|
||||
(they're on for apps that only have pre-releases).
|
||||
- **Keeps your stuff:** saves live outside the install folder, ROMs placed next to `run.sh` are carried over, and
|
||||
the previous install is kept for a **one-step rollback**.
|
||||
- Won't touch an app that's running. Only add apps you trust: their programs and installers run as your user.
|
||||
|
||||
## Use it
|
||||
- **In the headset:** open **Port Manager** in the library. D-pad to choose, **A** install/update, **Y** choose a
|
||||
version, **X** previous version, **Start** check for updates, **B** close.
|
||||
- **From a phone or PC:** `http://<frame-ip>:8643`.
|
||||
- **In the headset:** open **Port Manager** in the library. D-pad to choose, **A** open an app (install/update,
|
||||
versions, previous version, pre-releases, remove), **Y** add an app, **Select** update all, **Start** check for
|
||||
updates, **B** back/close.
|
||||
- **From a phone or PC:** `http://<frame-ip>:8643` (add apps, edit name/install style/program, remove).
|
||||
|
||||
Updates are checked every 6 hours and on demand.
|
||||
|
||||
## Catalog
|
||||
`catalog.json` lists the built-in ports:
|
||||
|
||||
| Port | Repo | Download |
|
||||
|---|---|---|
|
||||
| Ocarina of Time (Ship of Harkinian) | Cogential/Shipwright-frame | `soh-steam-frame-arm64.zip` |
|
||||
| Majora's Mask (2 Ship 2 Harkinian) | Cogential/2ship2harkinian-frame | `*arm64.appimage` (unpacked, launcher written) |
|
||||
|
||||
Add your own in `~/.config/port-manager/catalog.json` on the Frame (same fields; the same `id` overrides a built-in
|
||||
entry). A port's release needs a zip whose top folder holds `run.sh`, or an AppImage plus a `run_sh` launcher in the
|
||||
catalog.
|
||||
## Files
|
||||
- `catalog.json`: the built-in ports.
|
||||
- `suggestions.json`: apps offered on the Add screen.
|
||||
- `~/.config/port-manager/catalog.json` on the Frame: the apps you added and changes to built-in ones
|
||||
(`{"id": "soh", "hidden": true}` hides one; adding its repo again brings it back).
|
||||
- `~/.config/port-manager/installed.json`: installed versions.
|
||||
|
||||
## Private repos
|
||||
GitHub only shows releases of private repos to signed-in users. Either make the port repos public (they contain no
|
||||
|
||||
+14
-4
@@ -7,9 +7,15 @@
|
||||
"type": "zip",
|
||||
"gameid": "Ocarina_of_Time_SoH",
|
||||
"launch": "run.sh",
|
||||
"keep": ["*.z64", "*.n64", "*.v64"],
|
||||
"keep": [
|
||||
"*.z64",
|
||||
"*.n64",
|
||||
"*.v64"
|
||||
],
|
||||
"data": "~/.local/share/soh",
|
||||
"rom_hint": "Send your Ocarina of Time ROM with Frame Inbox, or put a .z64 in ~/.local/share/soh."
|
||||
"rom_hint": "Send your Ocarina of Time ROM with Frame Inbox, or put a .z64 in ~/.local/share/soh.",
|
||||
"prereleases": true,
|
||||
"mode": "game"
|
||||
},
|
||||
{
|
||||
"id": "2ship",
|
||||
@@ -19,7 +25,9 @@
|
||||
"type": "appimage",
|
||||
"gameid": "Majoras_Mask_2Ship",
|
||||
"launch": "run.sh",
|
||||
"keep": ["*.z64"],
|
||||
"keep": [
|
||||
"*.z64"
|
||||
],
|
||||
"data": "~/.local/share/2ship",
|
||||
"rom_hint": "Send your Majora's Mask ROM with Frame Inbox, or put a .z64 in ~/.local/share/2ship.",
|
||||
"run_sh": [
|
||||
@@ -35,6 +43,8 @@
|
||||
"fi",
|
||||
"chmod +x \"$D/usr/bin/2s2h.elf\" 2>/dev/null",
|
||||
"cd \"$D/usr/bin\" && exec ./2s2h.elf \"$@\""
|
||||
]
|
||||
],
|
||||
"prereleases": true,
|
||||
"mode": "game"
|
||||
}
|
||||
]
|
||||
@@ -18,7 +18,7 @@ $SSH "$USER_AT" true || { echo "Can't reach $HOST over SSH. Is the Frame on and
|
||||
|
||||
echo "== Service"
|
||||
$SSH "$USER_AT" 'rm -rf ~/.cache/frame-port-manager-src && mkdir -p ~/.cache/frame-port-manager-src'
|
||||
rsync -a -e "$SSH" manager.py page.html catalog.json frame-port-manager.service install.sh art "$USER_AT:.cache/frame-port-manager-src/"
|
||||
rsync -a -e "$SSH" manager.py page.html catalog.json suggestions.json frame-port-manager.service install.sh art "$USER_AT:.cache/frame-port-manager-src/"
|
||||
$SSH "$USER_AT" 'sh ~/.cache/frame-port-manager-src/install.sh'
|
||||
|
||||
echo "== Headset app"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=Port Manager (installs and updates Steam Frame ports from GitHub releases)
|
||||
Description=Port Manager (installs and updates Steam Frame apps and ports from GitHub releases)
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ set -e
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
dest="$HOME/.local/share/frame-port-manager"
|
||||
mkdir -p "$dest" "$HOME/.config/systemd/user"
|
||||
cp "$here/manager.py" "$here/page.html" "$here/catalog.json" "$dest/"
|
||||
cp "$here/manager.py" "$here/page.html" "$here/catalog.json" "$here/suggestions.json" "$dest/"
|
||||
rm -rf "$dest/art" && cp -r "$here/art" "$dest/art"
|
||||
cp "$here/frame-port-manager.service" "$HOME/.config/systemd/user/"
|
||||
systemctl --user daemon-reload
|
||||
|
||||
+656
-84
@@ -1,9 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Port Manager: installs and updates native Steam Frame ports from their GitHub releases.
|
||||
"""Port Manager: installs and updates Steam Frame apps and ports from their GitHub releases, like Obtainium.
|
||||
|
||||
A small web service (standard library only) that runs on the Frame. It reads each port's releases, downloads the
|
||||
chosen one, unpacks it into ~/devkit-game/<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.
|
||||
A small web service (standard library only) that runs on the Frame. It tracks a list of GitHub repos: the built-in
|
||||
ports in catalog.json plus any repo you add. For each it reads the releases, picks the download meant for the Frame
|
||||
(ARM64 Linux), unpacks it and either
|
||||
- adds it to the Steam library (mode "game": ~/devkit-game/<gameid>, a run.sh launcher, library art), or
|
||||
- runs the project's own installer (mode "installer": install.sh, contrib/install.sh or setup.sh), for tools like
|
||||
SteamVR overlays that set themselves up.
|
||||
ROMs and saves are kept, and the previous install is kept for a one-step rollback.
|
||||
|
||||
Control it from the headset app (app/) or from a browser at http://<frame>:8643.
|
||||
"""
|
||||
@@ -17,6 +21,7 @@ import stat
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
@@ -33,6 +38,8 @@ CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||
INSTALLED_FILE = CONFIG_DIR / "installed.json"
|
||||
USER_CATALOG = CONFIG_DIR / "catalog.json"
|
||||
CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", HOME / ".cache")) / "port-manager"
|
||||
DATA_DIR = Path(os.environ.get("XDG_DATA_HOME", HOME / ".local/share")) / "port-manager"
|
||||
APPS_DIR = DATA_DIR / "apps" # installs of "installer" apps (their installers may point back into them)
|
||||
GAMES_DIR = Path(os.environ.get("PORT_MANAGER_GAMES", HOME / "devkit-game"))
|
||||
CREATE_SHORTCUT = HOME / "devkit-utils" / "steam-client-create-shortcut"
|
||||
STEAM_USERDATA = HOME / ".local/share/Steam/userdata"
|
||||
@@ -46,6 +53,8 @@ DEFAULT_CONFIG = {
|
||||
}
|
||||
CHUNK = 1024 * 1024
|
||||
RELEASES_TTL = 600
|
||||
INSTALLERS = ["install.sh", "contrib/install.sh", "scripts/install.sh", "setup.sh"]
|
||||
REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
|
||||
|
||||
def load_json(path, default):
|
||||
@@ -64,17 +73,50 @@ def save_json(path, data):
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
|
||||
|
||||
def config():
|
||||
return {**DEFAULT_CONFIG, **load_json(CONFIG_FILE, {})}
|
||||
|
||||
|
||||
def catalog():
|
||||
"""The built-in ports, plus the user's own entries (same id replaces a built-in one)."""
|
||||
ports = {p["id"]: p for p in load_json(APP_DIR / "catalog.json", [])}
|
||||
"""The built-in ports plus the apps the user added. A user entry with the id of a built-in one changes it, and
|
||||
{"id": ..., "hidden": true} takes a built-in one off the list."""
|
||||
apps = {}
|
||||
for p in load_json(APP_DIR / "catalog.json", []):
|
||||
apps[p["id"]] = {**p, "builtin": True}
|
||||
for p in load_json(USER_CATALOG, []):
|
||||
if "id" in p:
|
||||
ports[p["id"]] = {**ports.get(p["id"], {}), **p}
|
||||
return ports
|
||||
apps[p["id"]] = {**apps.get(p["id"], {}), **p}
|
||||
return {k: v for k, v in apps.items() if not v.get("hidden") and v.get("repo")}
|
||||
|
||||
|
||||
def update_user_entry(pid, **fields):
|
||||
"""Changes (or with remove=True deletes) the user's entry for an app, creating one for a built-in app."""
|
||||
entries = load_json(USER_CATALOG, [])
|
||||
entry = next((e for e in entries if e.get("id") == pid), None)
|
||||
if entry is None:
|
||||
entry = {"id": pid}
|
||||
entries.append(entry)
|
||||
entry.update(fields)
|
||||
save_json(USER_CATALOG, entries)
|
||||
|
||||
|
||||
def suggestions():
|
||||
"""Apps worth adding: the built-in ports (so a removed one can come back) and the ones in suggestions.json."""
|
||||
ports = [{"repo": p["repo"], "name": p["name"], "note": "Built-in port"}
|
||||
for p in load_json(APP_DIR / "catalog.json", [])]
|
||||
return ports + load_json(APP_DIR / "suggestions.json", [])
|
||||
|
||||
|
||||
def unhide_builtin(repo):
|
||||
"""Puts a removed built-in port back on the list; returns its id, or None if the repo isn't one."""
|
||||
port = next((p for p in load_json(APP_DIR / "catalog.json", []) if p["repo"].lower() == repo.lower()), None)
|
||||
if port is None:
|
||||
return None
|
||||
entries = load_json(USER_CATALOG, [])
|
||||
save_json(USER_CATALOG, [e for e in entries if not (e.get("id") == port["id"] and e.get("hidden"))])
|
||||
return port["id"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- GitHub
|
||||
@@ -94,8 +136,9 @@ def gh_json(url):
|
||||
return json.load(r)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
raise RuntimeError("Repo not found. If it's private, add a GitHub token in Settings.") from e
|
||||
if e.code in (401, 403):
|
||||
raise RuntimeError("Repo not found. Check the address; if it's private, add a GitHub token in "
|
||||
"Settings.") from e
|
||||
if e.code in (401, 403, 429):
|
||||
raise RuntimeError("GitHub refused the request (token or rate limit). Try again later.") from e
|
||||
raise RuntimeError(f"GitHub error {e.code}") from e
|
||||
except OSError as e:
|
||||
@@ -104,12 +147,82 @@ def gh_json(url):
|
||||
raise RuntimeError("The GitHub token isn't valid. Check it in Settings.") from e
|
||||
|
||||
|
||||
def parse_repo(text):
|
||||
"""owner/repo from "owner/repo", "github.com/owner/repo" or any github.com URL inside a repo."""
|
||||
t = (text or "").strip()
|
||||
t = re.sub(r"^(https?://)?(www\.)?github\.com/", "", t, flags=re.I)
|
||||
t = re.split(r"[?#]", t)[0]
|
||||
parts = [p for p in t.split("/") if p]
|
||||
if len(parts) < 2:
|
||||
raise RuntimeError("Enter a GitHub repo like owner/name or github.com/owner/name.")
|
||||
repo = parts[0] + "/" + re.sub(r"\.git$", "", parts[1])
|
||||
if not REPO_RE.match(repo):
|
||||
raise RuntimeError("That doesn't look like a GitHub repo address.")
|
||||
return repo
|
||||
|
||||
|
||||
# ---- choosing the download
|
||||
|
||||
ARCHIVES = [(".tar.gz", "tar"), (".tgz", "tar"), (".tar.xz", "tar"), (".txz", "tar"), (".tar.bz2", "tar"),
|
||||
(".tar.zst", "tar"), (".tar", "tar"), (".zip", "zip"), (".appimage", "appimage")]
|
||||
NOT_DOWNLOADS = re.compile(r"\.(sha\d*|sha\d+sum|md5|sig|asc|minisig|pem|txt|md|json|ya?ml|html|pdf|png|jpe?g|"
|
||||
r"apk|aab|ipa|exe|msi|msix|dmg|pkg|deb|rpm|flatpak|snap|7z|rar|whl|jar|so|dll)$", re.I)
|
||||
NOT_PROGRAMS = re.compile(r"(sums?$|checksum|readme|license|changelog|manifest|notes)", re.I)
|
||||
OTHER_SYSTEMS = re.compile(r"(windows|win32|win64|\bwin\b|macos|darwin|osx|android|\bios\b|apple|mingw|msvc)", re.I)
|
||||
OTHER_CPUS = re.compile(r"(x86[_-]?64|amd64|\bx64\b|i[36]86|\bx86\b|armv7|armhf|\barm32\b|riscv)", re.I)
|
||||
OUR_CPU = re.compile(r"(aarch64|arm64)", re.I)
|
||||
|
||||
|
||||
def asset_type(name):
|
||||
low = name.lower()
|
||||
for ext, kind in ARCHIVES:
|
||||
if low.endswith(ext):
|
||||
return kind
|
||||
# A file with no archive extension is a program on its own (e.g. tool-aarch64-linux-gnu).
|
||||
return None if NOT_DOWNLOADS.search(low) or re.search(r"\.[a-z][a-z0-9]{0,4}$", low) else "binary"
|
||||
|
||||
|
||||
def score_asset(name):
|
||||
"""How likely a release file is the Frame's download: None if it can't be one (checksums, other systems...)."""
|
||||
if NOT_DOWNLOADS.search(name) or NOT_PROGRAMS.search(name) or asset_type(name) is None \
|
||||
or OTHER_SYSTEMS.search(name):
|
||||
return None
|
||||
score = 0
|
||||
if OUR_CPU.search(name):
|
||||
score += 50
|
||||
elif OTHER_CPUS.search(name):
|
||||
score -= 60 # still offered (some run through FEX), but only when nothing else fits
|
||||
if re.search(r"steam[-_. ]?frame|frame", name, re.I):
|
||||
score += 20
|
||||
if re.search(r"linux", name, re.I):
|
||||
score += 10
|
||||
score += {"appimage": 6, "tar": 4, "zip": 3, "binary": 0}[asset_type(name)]
|
||||
return score
|
||||
|
||||
|
||||
def asset_glob(name):
|
||||
"""A pattern that finds the same download in later releases: version numbers become *."""
|
||||
pat = re.sub(r"v?\d+(\.\d+)+(-?(rc|beta|alpha)\.?\d*)?", "*", name)
|
||||
return re.sub(r"\*+", "*", pat)
|
||||
|
||||
|
||||
def choose_asset(port, assets):
|
||||
pattern = port.get("asset")
|
||||
if pattern:
|
||||
hit = next((a for a in assets if fnmatch.fnmatch(a["name"], pattern)), None)
|
||||
if hit or not port.get("auto_asset"):
|
||||
return hit
|
||||
scored = [(score_asset(a["name"]), a) for a in assets]
|
||||
scored = [(s, a) for s, a in scored if s is not None and s > -30]
|
||||
return max(scored, key=lambda x: x[0])[1] if scored else None
|
||||
|
||||
|
||||
_releases_cache = {}
|
||||
|
||||
|
||||
def releases(port, force=False):
|
||||
"""Releases newest first, with the asset that matches the port's pattern."""
|
||||
key = port["repo"]
|
||||
"""Releases newest first, each with the download chosen for the Frame."""
|
||||
key = (port["repo"], port.get("asset"))
|
||||
hit = _releases_cache.get(key)
|
||||
if hit and not force and time.time() - hit[0] < RELEASES_TTL:
|
||||
return hit[1]
|
||||
@@ -117,7 +230,7 @@ def releases(port, force=False):
|
||||
for rel in gh_json(f"https://api.github.com/repos/{port['repo']}/releases?per_page=30"):
|
||||
if rel.get("draft"):
|
||||
continue
|
||||
asset = next((a for a in rel.get("assets", []) if fnmatch.fnmatch(a["name"], port["asset"])), None)
|
||||
asset = choose_asset(port, rel.get("assets", []))
|
||||
if asset is None:
|
||||
continue
|
||||
out.append({
|
||||
@@ -134,6 +247,88 @@ def releases(port, force=False):
|
||||
return out
|
||||
|
||||
|
||||
def latest_release(port, rels):
|
||||
"""The release an update goes to: the newest one, skipping pre-releases unless the app takes them."""
|
||||
if port.get("prereleases"):
|
||||
return rels[0] if rels else None
|
||||
return next((r for r in rels if not r["prerelease"]), None)
|
||||
|
||||
|
||||
def nice_name(repo_name):
|
||||
words = re.split(r"[-_ ]+", repo_name)
|
||||
if repo_name == repo_name.lower():
|
||||
words = [w[:1].upper() + w[1:] for w in words]
|
||||
return " ".join(w for w in words if w)
|
||||
|
||||
|
||||
def inspect_repo(text):
|
||||
"""What adding a repo would install: its name, the newest usable release and every download it could use."""
|
||||
repo = parse_repo(text)
|
||||
info = gh_json(f"https://api.github.com/repos/{repo}")
|
||||
repo = info.get("full_name") or repo
|
||||
rels = [r for r in gh_json(f"https://api.github.com/repos/{repo}/releases?per_page=15") if not r.get("draft")]
|
||||
if not rels:
|
||||
raise RuntimeError(f"{repo} has no releases to install.")
|
||||
usable = [r for r in rels if any(score_asset(a["name"]) is not None for a in r.get("assets", []))]
|
||||
if not usable:
|
||||
raise RuntimeError(f"{repo}'s releases have no Linux downloads (only source code or other systems).")
|
||||
stable = next((r for r in usable if not r.get("prerelease")), None)
|
||||
rel = stable or usable[0]
|
||||
assets = sorted(({"name": a["name"], "size": a.get("size", 0), "score": score_asset(a["name"])}
|
||||
for a in rel["assets"] if score_asset(a["name"]) is not None),
|
||||
key=lambda a: -a["score"])
|
||||
for a in assets:
|
||||
a["arm64"] = bool(OUR_CPU.search(a["name"])) or not OTHER_CPUS.search(a["name"])
|
||||
existing = next((pid for pid, p in catalog().items() if p["repo"].lower() == repo.lower()), None)
|
||||
return {
|
||||
"repo": repo,
|
||||
"name": nice_name(info.get("name") or repo.split("/")[1]),
|
||||
"description": re.sub(r"\s+", " ", (info.get("description") or "")).strip(),
|
||||
"tag": rel["tag_name"],
|
||||
"date": (rel.get("published_at") or "")[:10],
|
||||
"prereleases": stable is None,
|
||||
"assets": assets,
|
||||
"existing": existing,
|
||||
}
|
||||
|
||||
|
||||
def add_app(repo_text, asset=None, name=None, mode="auto", prereleases=None):
|
||||
"""Adds a repo to the list and returns its id (an existing one's id if it's already there)."""
|
||||
builtin = unhide_builtin(parse_repo(repo_text))
|
||||
if builtin:
|
||||
return builtin
|
||||
info = inspect_repo(repo_text)
|
||||
if info["existing"]:
|
||||
return info["existing"]
|
||||
names = [a["name"] for a in info["assets"]]
|
||||
if asset and asset not in names:
|
||||
raise RuntimeError(f"{asset} isn't in the latest release.")
|
||||
asset = asset or names[0]
|
||||
base = re.sub(r"[^a-z0-9]+", "-", info["repo"].split("/")[1].lower()).strip("-") or "app"
|
||||
pid, n = base, 2
|
||||
taken = set(catalog()) | {e.get("id") for e in load_json(USER_CATALOG, [])}
|
||||
while pid in taken:
|
||||
pid, n = f"{base}-{n}", n + 1
|
||||
title = (name or info["name"]).strip()[:80] or info["name"]
|
||||
gameid = re.sub(r"[^A-Za-z0-9]+", "_", title).strip("_") or "App"
|
||||
if gameid[0].isdigit():
|
||||
gameid = "App_" + gameid
|
||||
used = {p.get("gameid") for p in catalog().values()}
|
||||
g, n = gameid, 2
|
||||
while g in used:
|
||||
g, n = f"{gameid}_{n}", n + 1
|
||||
entries = load_json(USER_CATALOG, [])
|
||||
entries.append({
|
||||
"id": pid, "name": title, "repo": info["repo"], "asset": asset_glob(asset), "auto_asset": True,
|
||||
"mode": mode if mode in ("auto", "game", "installer") else "auto",
|
||||
"prereleases": info["prereleases"] if prereleases is None else bool(prereleases),
|
||||
"gameid": g, "description": info["description"][:300], "added": int(time.time()),
|
||||
})
|
||||
save_json(USER_CATALOG, entries)
|
||||
_releases_cache.clear()
|
||||
return pid
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Steam
|
||||
|
||||
|
||||
@@ -188,6 +383,7 @@ def parse_vdf(data):
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
def register_with_steam(port, folder):
|
||||
if not CREATE_SHORTCUT.exists():
|
||||
return "Installed. Add it to Steam with FrameDrop or Add a Non-Steam Game (pick run.sh)."
|
||||
@@ -205,9 +401,36 @@ def register_with_steam(port, folder):
|
||||
return None
|
||||
|
||||
|
||||
def fetch(url, dest, limit=8 * 1024 * 1024):
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "frame-port-manager"})
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
data = r.read(limit)
|
||||
if not data.startswith(b"\x89PNG") and not data.startswith(b"\xff\xd8"):
|
||||
raise OSError("not an image")
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(data)
|
||||
|
||||
|
||||
def art_folder(port):
|
||||
"""The app's library art: the built-in ports ship theirs, added apps get their GitHub preview card and the
|
||||
owner's avatar (fetched once)."""
|
||||
if port.get("builtin"):
|
||||
return APP_DIR / "art" / port["id"]
|
||||
art = DATA_DIR / "art" / port["id"]
|
||||
if not (art / "wide.png").exists():
|
||||
owner, name = port["repo"].split("/")
|
||||
try:
|
||||
fetch(f"https://opengraph.githubassets.com/1/{owner}/{name}", art / "wide.png")
|
||||
shutil.copyfile(art / "wide.png", art / "hero.png")
|
||||
fetch(f"https://github.com/{owner}.png?size=256", art / "icon.png")
|
||||
except OSError:
|
||||
pass
|
||||
return art
|
||||
|
||||
|
||||
def add_art(port):
|
||||
"""Copies the port's library art into Steam's grid folder, without replacing art the user set."""
|
||||
art = APP_DIR / "art" / port["id"]
|
||||
"""Copies the app's library art into Steam's grid folder, without replacing art the user set."""
|
||||
art = art_folder(port)
|
||||
if not art.is_dir():
|
||||
return
|
||||
appid = None
|
||||
@@ -270,13 +493,7 @@ def extract_zip(archive, dest):
|
||||
mode = (info.external_attr >> 16) & 0o777
|
||||
if mode and not info.is_dir():
|
||||
os.chmod(target, mode)
|
||||
# A single top-level folder (e.g. soh-steam-frame/) becomes the install folder itself.
|
||||
entries = [e for e in dest.iterdir()]
|
||||
if len(entries) == 1 and entries[0].is_dir():
|
||||
inner = entries[0]
|
||||
for e in list(inner.iterdir()):
|
||||
shutil.move(str(e), dest / e.name)
|
||||
inner.rmdir()
|
||||
flatten(dest)
|
||||
|
||||
|
||||
def extract_appimage(archive, dest):
|
||||
@@ -296,35 +513,215 @@ def extract_appimage(archive, dest):
|
||||
archive.unlink()
|
||||
|
||||
|
||||
|
||||
def flatten(dest):
|
||||
"""A single top-level folder (e.g. soh-steam-frame/) becomes the install folder itself."""
|
||||
entries = list(dest.iterdir())
|
||||
if len(entries) == 1 and entries[0].is_dir() and not entries[0].is_symlink():
|
||||
inner = dest / ".pm-inner"
|
||||
os.replace(entries[0], inner)
|
||||
for e in list(inner.iterdir()):
|
||||
shutil.move(str(e), dest / e.name)
|
||||
inner.rmdir()
|
||||
|
||||
|
||||
def extract_tar(archive, dest):
|
||||
if archive.name.lower().endswith(".tar.zst"):
|
||||
r = subprocess.run(["tar", "--zstd", "-xf", str(archive), "-C", str(dest), "--no-same-owner"],
|
||||
capture_output=True, timeout=1800)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError("Couldn't unpack the .tar.zst download.")
|
||||
else:
|
||||
with tarfile.open(archive) as t:
|
||||
root = dest.resolve()
|
||||
for m in t.getmembers():
|
||||
target = (dest / m.name).resolve()
|
||||
if root not in target.parents and target != root:
|
||||
raise RuntimeError("The download contains unsafe paths.")
|
||||
if m.issym() or m.islnk():
|
||||
link = (target.parent / m.linkname).resolve() if m.issym() else (dest / m.linkname).resolve()
|
||||
if root not in link.parents:
|
||||
raise RuntimeError("The download contains unsafe links.")
|
||||
if m.isdev():
|
||||
raise RuntimeError("The download contains device files.")
|
||||
if hasattr(tarfile, "data_filter"):
|
||||
t.extractall(dest, filter="data")
|
||||
else:
|
||||
t.extractall(dest)
|
||||
flatten(dest)
|
||||
|
||||
|
||||
def elf_info(path):
|
||||
"""(machine, is_program) for an ELF file, else None. Programs have an interpreter or are static executables;
|
||||
shared libraries have neither."""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
head = f.read(64)
|
||||
if len(head) < 64 or head[:4] != b"\x7fELF" or head[4] != 2:
|
||||
return None
|
||||
e_type, machine = struct.unpack_from("<HH", head, 16)
|
||||
phoff = struct.unpack_from("<Q", head, 32)[0]
|
||||
phentsize, phnum = struct.unpack_from("<HH", head, 54)
|
||||
f.seek(phoff)
|
||||
ph = f.read(phentsize * phnum)
|
||||
except (OSError, struct.error):
|
||||
return None
|
||||
interp = any(struct.unpack_from("<I", ph, i * phentsize)[0] == 3 for i in range(phnum)
|
||||
if (i + 1) * phentsize <= len(ph))
|
||||
return machine, interp or e_type == 2
|
||||
|
||||
|
||||
AARCH64 = 0xB7
|
||||
SKIP_DIRS = {"lib", "lib64", "libs", "vendor", "contrib", "tools", "third_party", "licenses", "share", "include",
|
||||
"docs", "doc", "resources", "data", ".git"}
|
||||
NOT_LAUNCHERS = re.compile(r"(install|uninstall|setup|update|updater|build|test|config|ctl|guard|importer|helper)",
|
||||
re.I)
|
||||
GENERIC_WORDS = {"steam", "frame", "steamframe", "arm64", "aarch64", "linux", "port", "native", "app", "the"}
|
||||
|
||||
|
||||
def words(s):
|
||||
return {w for w in re.split(r"[^a-z0-9]+", s.lower()) if w and w not in GENERIC_WORDS}
|
||||
|
||||
|
||||
def find_program(root, port):
|
||||
"""The file that starts the app: the best-named ARM64 program (or start script) near the top of the folder."""
|
||||
want = words(port["repo"].split("/")[1]) | words(port.get("name", ""))
|
||||
best = None
|
||||
for dirpath, dirs, files in os.walk(root):
|
||||
rel_dir = Path(dirpath).relative_to(root)
|
||||
depth = len(rel_dir.parts)
|
||||
dirs[:] = [d for d in dirs if d.lower() not in SKIP_DIRS and depth < 3]
|
||||
for name in files:
|
||||
p = Path(dirpath) / name
|
||||
if p.is_symlink() or ".so" in name:
|
||||
continue
|
||||
info = elf_info(p)
|
||||
score = 0
|
||||
if info:
|
||||
machine, program = info
|
||||
if not program:
|
||||
continue
|
||||
score += 40 if machine == AARCH64 else -40
|
||||
elif name.endswith(".sh") and depth == 0:
|
||||
if not re.match(r"(run|start|launch|play)", name, re.I):
|
||||
continue
|
||||
score += 25
|
||||
else:
|
||||
continue
|
||||
if NOT_LAUNCHERS.search(name) and not name.endswith(".sh"):
|
||||
score -= 30
|
||||
stem = re.sub(r"\.(sh|x86_64|arm64|aarch64|bin|elf)$", "", name, flags=re.I)
|
||||
got = words(stem)
|
||||
score += 15 * len(got & want) + (20 if got and got == want else 0) - 4 * depth
|
||||
if best is None or score > best[0]:
|
||||
best = (score, p)
|
||||
return best[1] if best else None
|
||||
|
||||
|
||||
def write_launcher(staging, port):
|
||||
"""Makes sure the folder has the run.sh Steam starts: the port's own, one from the catalog, or one that starts
|
||||
the program found in the download."""
|
||||
launch = staging / port.get("launch", "run.sh")
|
||||
if port.get("run_sh"):
|
||||
launch.write_text("\n".join(port["run_sh"]) + "\n")
|
||||
return None
|
||||
if launch.exists():
|
||||
return None
|
||||
if port.get("program"):
|
||||
target = staging / port["program"]
|
||||
if not target.exists():
|
||||
raise RuntimeError(f"The download has no {port['program']} (the program set for this app).")
|
||||
elif (staging / "AppRun").exists():
|
||||
target = staging / "AppRun"
|
||||
else:
|
||||
target = find_program(staging, port)
|
||||
if target is None:
|
||||
raise RuntimeError("Couldn't find a program to start in this download. Set one in the web page "
|
||||
"(Edit), or install it with its installer instead.")
|
||||
rel = target.relative_to(staging)
|
||||
folder = "/" + str(rel.parent) if str(rel.parent) != "." else ""
|
||||
launch.write_text(
|
||||
"#!/bin/sh\n"
|
||||
f"# Starts {port['name']} (written by Port Manager).\n"
|
||||
'D="$(cd "$(dirname "$0")" && pwd)"\n'
|
||||
f'chmod +x "$D/{rel}" 2>/dev/null\n'
|
||||
f'cd "$D{folder}" && exec "./{rel.name}" "$@"\n')
|
||||
return str(rel)
|
||||
|
||||
|
||||
def find_installer(root, port):
|
||||
for name in ([port["installer"]] if port.get("installer") else []) + INSTALLERS:
|
||||
if (root / name).is_file():
|
||||
return root / name
|
||||
return None
|
||||
|
||||
|
||||
def run_installer(folder, port, uninstall=False):
|
||||
"""Runs the app's own installer (or its uninstaller) and returns its last lines. Raises if it fails."""
|
||||
script = find_installer(folder, port)
|
||||
if script is None:
|
||||
raise RuntimeError("The download has no install.sh.")
|
||||
args = [str(script)]
|
||||
if uninstall:
|
||||
if (script.parent / "uninstall.sh").is_file():
|
||||
args = [str(script.parent / "uninstall.sh")]
|
||||
elif "--uninstall" in script.read_text(errors="replace"):
|
||||
args.append("--uninstall")
|
||||
else:
|
||||
return ""
|
||||
log = CACHE_DIR / f"{port['id']}-{'uninstall' if uninstall else 'install'}.log"
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(script, os.stat(script).st_mode | stat.S_IXUSR)
|
||||
with open(log, "wb") as out:
|
||||
try:
|
||||
r = subprocess.run((["bash"] if args[0].endswith(".sh") else []) + args, cwd=script.parent,
|
||||
stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT, timeout=900)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise RuntimeError(f"Its installer didn't finish in 15 minutes (log: {log}).") from e
|
||||
lines = [l.strip() for l in log.read_text(errors="replace").splitlines() if l.strip()]
|
||||
tail = " ".join(lines[-2:])[:200]
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"Its installer failed: {tail or 'no output'} (log: {log})")
|
||||
return tail
|
||||
|
||||
|
||||
class Jobs:
|
||||
"""One install at a time; state is shown by both front ends."""
|
||||
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
self.state = {} # port id -> {"status", "progress", "message"}
|
||||
self.state = {} # app id -> {"status", "progress", "message"}
|
||||
|
||||
def set(self, pid, status, progress=0.0, message=""):
|
||||
self.state[pid] = {"status": status, "progress": progress, "message": message}
|
||||
|
||||
def busy(self):
|
||||
return any(s["status"] in ("downloading", "installing") for s in self.state.values())
|
||||
return any(s["status"] in ("downloading", "installing", "waiting") for s in self.state.values())
|
||||
|
||||
|
||||
jobs = Jobs()
|
||||
|
||||
|
||||
def app_mode(port):
|
||||
return port.get("mode") or "game"
|
||||
|
||||
|
||||
def target_dir(port, mode=None):
|
||||
mode = mode or app_mode(port)
|
||||
return GAMES_DIR / port["gameid"] if mode == "game" else APPS_DIR / port["id"]
|
||||
|
||||
|
||||
def install(pid, tag=None):
|
||||
port = catalog()[pid]
|
||||
staging = archive = None
|
||||
try:
|
||||
rels = releases(port, force=True)
|
||||
if not rels:
|
||||
raise RuntimeError("This repo has no release with a matching download yet.")
|
||||
rel = rels[0] if tag is None else next((r for r in rels if r["tag"] == tag), None)
|
||||
raise RuntimeError("This repo has no release with a download for the Frame yet.")
|
||||
rel = latest_release(port, rels) if tag is None else next((r for r in rels if r["tag"] == tag), None)
|
||||
if rel is None:
|
||||
raise RuntimeError(f"Release {tag} not found.")
|
||||
final = GAMES_DIR / port["gameid"]
|
||||
if final.exists() and game_running(final):
|
||||
raise RuntimeError("Close the game first.")
|
||||
raise RuntimeError(f"Release {tag} not found." if tag else "Only pre-releases so far. Turn on "
|
||||
"pre-releases for this app, or pick a version.")
|
||||
|
||||
# Download
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
@@ -345,24 +742,36 @@ def install(pid, tag=None):
|
||||
if total:
|
||||
jobs.set(pid, "downloading", done / total, f"Downloading {rel['tag']}")
|
||||
|
||||
# Unpack into a staging folder next to the final one (same filesystem, so the swap is a rename)
|
||||
# Unpack into a staging folder (moved into place with a rename once it's ready)
|
||||
jobs.set(pid, "installing", 1.0, "Unpacking")
|
||||
GAMES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
staging = GAMES_DIR / f".{port['gameid']}.staging"
|
||||
stage_root = DATA_DIR / "staging"
|
||||
stage_root.mkdir(parents=True, exist_ok=True)
|
||||
staging = stage_root / pid
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
staging.mkdir()
|
||||
if port.get("type", "zip") == "appimage":
|
||||
kind = port.get("type") or asset_type(rel["asset_name"])
|
||||
if kind == "appimage":
|
||||
moved = staging / archive.name
|
||||
shutil.move(str(archive), moved)
|
||||
extract_appimage(moved, staging)
|
||||
elif kind == "tar":
|
||||
extract_tar(archive, staging)
|
||||
elif kind == "binary":
|
||||
shutil.move(str(archive), staging / rel["asset_name"])
|
||||
else:
|
||||
extract_zip(archive, staging)
|
||||
archive.unlink(missing_ok=True)
|
||||
if port.get("run_sh"):
|
||||
launcher = staging / port.get("launch", "run.sh")
|
||||
launcher.write_text("\n".join(port["run_sh"]) + "\n")
|
||||
if not (staging / port.get("launch", "run.sh")).exists():
|
||||
raise RuntimeError(f"The download has no {port.get('launch', 'run.sh')}.")
|
||||
archive.unlink(missing_ok=True)
|
||||
make_executables(staging)
|
||||
|
||||
mode = app_mode(port)
|
||||
if mode == "auto":
|
||||
mode = "installer" if find_installer(staging, port) and not port.get("run_sh") else "game"
|
||||
update_user_entry(pid, mode=mode)
|
||||
port["mode"] = mode
|
||||
final = target_dir(port, mode)
|
||||
if final.exists() and game_running(final):
|
||||
raise RuntimeError("Close the app first.")
|
||||
program = write_launcher(staging, port) if mode == "game" else None
|
||||
|
||||
# Carry over files the user put in the old install (ROMs next to run.sh)
|
||||
if final.exists():
|
||||
@@ -372,58 +781,128 @@ def install(pid, tag=None):
|
||||
shutil.copy2(f, staging / f.name)
|
||||
make_executables(staging)
|
||||
|
||||
# Swap: the old install becomes the "previous" one
|
||||
previous = GAMES_DIR / f".{port['gameid']}.previous"
|
||||
# Swap: the old install becomes the "previous" one. Staging is on the same filesystem as the games folder
|
||||
# on the Frame (both in the home directory); a move covers the case where it isn't.
|
||||
final.parent.mkdir(parents=True, exist_ok=True)
|
||||
previous = final.parent / f".{final.name}.previous"
|
||||
if final.exists():
|
||||
if game_running(final):
|
||||
raise RuntimeError("Close the game first.")
|
||||
raise RuntimeError("Close the app first.")
|
||||
shutil.rmtree(previous, ignore_errors=True)
|
||||
os.replace(final, previous)
|
||||
os.replace(staging, final)
|
||||
shutil.move(str(staging), final)
|
||||
staging = None
|
||||
|
||||
jobs.set(pid, "installing", 1.0, "Adding to Steam")
|
||||
note = register_with_steam(port, final)
|
||||
add_art(port)
|
||||
if mode == "game":
|
||||
jobs.set(pid, "installing", 1.0, "Adding to Steam")
|
||||
note = register_with_steam(port, final)
|
||||
add_art(port)
|
||||
message = note or f"Installed {rel['tag']}. It's in your library."
|
||||
if program and not note:
|
||||
message += f" Starts {program}."
|
||||
else:
|
||||
jobs.set(pid, "installing", 1.0, "Running its installer")
|
||||
try:
|
||||
tail = run_installer(final, port)
|
||||
except RuntimeError:
|
||||
# Put back what was there before, so the list shows what's really installed.
|
||||
shutil.rmtree(final, ignore_errors=True)
|
||||
if previous.exists():
|
||||
os.replace(previous, final)
|
||||
raise
|
||||
message = f"Installed {rel['tag']}." + (f" {tail}" if tail else "")
|
||||
|
||||
installed = load_json(INSTALLED_FILE, {})
|
||||
prev = installed.get(pid, {}).get("tag")
|
||||
installed[pid] = {"tag": rel["tag"], "previous": prev, "time": int(time.time())}
|
||||
installed[pid] = {"tag": rel["tag"], "previous": prev, "time": int(time.time()), "mode": mode}
|
||||
save_json(INSTALLED_FILE, installed)
|
||||
|
||||
message = note or f"Installed {rel['tag']}."
|
||||
data = Path(os.path.expanduser(port.get("data", "")))
|
||||
if port.get("rom_hint") and port.get("data") and not any(data.glob("*.o2r")) and not any(data.glob("*.z64")):
|
||||
message += " " + port["rom_hint"]
|
||||
jobs.set(pid, "done", 1.0, message)
|
||||
except Exception as e: # report every failure on screen rather than dying
|
||||
shutil.rmtree(GAMES_DIR / f".{port['gameid']}.staging", ignore_errors=True)
|
||||
jobs.set(pid, "error", 0.0, str(e))
|
||||
finally:
|
||||
if staging is not None:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
if archive is not None:
|
||||
archive.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def rollback(pid):
|
||||
port = catalog()[pid]
|
||||
final = GAMES_DIR / port["gameid"]
|
||||
previous = GAMES_DIR / f".{port['gameid']}.previous"
|
||||
try:
|
||||
installed = load_json(INSTALLED_FILE, {})
|
||||
cur = installed.get(pid, {})
|
||||
mode = cur.get("mode") or app_mode(port)
|
||||
final = target_dir(port, mode)
|
||||
previous = final.parent / f".{final.name}.previous"
|
||||
if not previous.exists():
|
||||
raise RuntimeError("There's no previous version to go back to.")
|
||||
if final.exists() and game_running(final):
|
||||
raise RuntimeError("Close the game first.")
|
||||
swap = GAMES_DIR / f".{port['gameid']}.swap"
|
||||
raise RuntimeError("Close the app first.")
|
||||
swap = final.parent / f".{final.name}.swap"
|
||||
if final.exists():
|
||||
os.replace(final, swap)
|
||||
os.replace(previous, final)
|
||||
if swap.exists():
|
||||
os.replace(swap, previous)
|
||||
installed = load_json(INSTALLED_FILE, {})
|
||||
cur = installed.get(pid, {})
|
||||
installed[pid] = {"tag": cur.get("previous"), "previous": cur.get("tag"), "time": int(time.time())}
|
||||
if mode == "installer":
|
||||
jobs.set(pid, "installing", 1.0, "Running its installer")
|
||||
run_installer(final, port)
|
||||
installed[pid] = {"tag": cur.get("previous"), "previous": cur.get("tag"), "time": int(time.time()),
|
||||
"mode": mode}
|
||||
save_json(INSTALLED_FILE, installed)
|
||||
jobs.set(pid, "done", 1.0, f"Back to {cur.get('previous') or 'the previous version'}.")
|
||||
except Exception as e:
|
||||
jobs.set(pid, "error", 0.0, str(e))
|
||||
|
||||
|
||||
def remove(pid, delete_files=True):
|
||||
"""Takes an app off the list. Its files go too (an installer app's uninstaller runs first); saves and settings
|
||||
outside its folder stay."""
|
||||
port = catalog()[pid]
|
||||
try:
|
||||
installed = load_json(INSTALLED_FILE, {})
|
||||
mode = installed.get(pid, {}).get("mode") or app_mode(port)
|
||||
note = ""
|
||||
if delete_files and mode != "auto":
|
||||
final = target_dir(port, mode)
|
||||
if final.exists() and game_running(final):
|
||||
raise RuntimeError("Close the app first.")
|
||||
if mode == "installer" and final.exists():
|
||||
jobs.set(pid, "installing", 1.0, "Running its uninstaller")
|
||||
run_installer(final, port, uninstall=True)
|
||||
if final.exists() and mode == "game":
|
||||
note = " Remove its tile from the library too: Manage > Remove non-Steam game."
|
||||
shutil.rmtree(final, ignore_errors=True)
|
||||
shutil.rmtree(final.parent / f".{final.name}.previous", ignore_errors=True)
|
||||
installed.pop(pid, None)
|
||||
save_json(INSTALLED_FILE, installed)
|
||||
entries = [e for e in load_json(USER_CATALOG, []) if e.get("id") != pid]
|
||||
if port.get("builtin"):
|
||||
entries.append({"id": pid, "hidden": True})
|
||||
save_json(USER_CATALOG, entries)
|
||||
jobs.state.pop(pid, None)
|
||||
_latest.pop(pid, None)
|
||||
jobs.set("_list", "done", 1.0, f"Removed {port['name']}.{note}")
|
||||
except Exception as e:
|
||||
jobs.set(pid, "error", 0.0, str(e))
|
||||
|
||||
|
||||
def update_all(_pid=None):
|
||||
"""Updates every installed app that has a newer release, one after another."""
|
||||
jobs.set("_list", "installing", 0.0, "Updating everything")
|
||||
count = 0
|
||||
for row in app_state():
|
||||
if row["status"] == "update available":
|
||||
install(row["id"])
|
||||
count += 1
|
||||
jobs.set("_list", "done", 1.0, f"Updated {count} app{'s' if count != 1 else ''}." if count
|
||||
else "Everything is up to date.")
|
||||
|
||||
|
||||
def start(fn, pid, *args):
|
||||
with jobs.lock:
|
||||
if jobs.busy():
|
||||
@@ -436,33 +915,39 @@ def start(fn, pid, *args):
|
||||
# ---------------------------------------------------------------- state
|
||||
|
||||
|
||||
_latest = {} # port id -> (tag, error)
|
||||
_latest = {} # app id -> (tag, error)
|
||||
|
||||
|
||||
def refresh(force=False):
|
||||
for pid, port in catalog().items():
|
||||
try:
|
||||
rels = releases(port, force=force)
|
||||
_latest[pid] = (rels[0]["tag"] if rels else None, None if rels else "No releases yet")
|
||||
rel = latest_release(port, rels)
|
||||
_latest[pid] = (rel["tag"] if rel else None,
|
||||
None if rel else ("Only pre-releases so far" if rels else "No releases for the Frame yet"))
|
||||
except RuntimeError as e:
|
||||
_latest[pid] = (None, str(e))
|
||||
|
||||
|
||||
def port_state():
|
||||
def app_state():
|
||||
installed = load_json(INSTALLED_FILE, {})
|
||||
out = []
|
||||
for pid, port in catalog().items():
|
||||
folder = GAMES_DIR / port["gameid"]
|
||||
tag = installed.get(pid, {}).get("tag") if folder.exists() else None
|
||||
if folder.exists() and not tag:
|
||||
mode = installed.get(pid, {}).get("mode") or app_mode(port)
|
||||
folder = target_dir(port, mode) if mode != "auto" else None
|
||||
present = bool(folder and folder.exists())
|
||||
tag = installed.get(pid, {}).get("tag") if present else None
|
||||
if present and not tag:
|
||||
tag = "unknown"
|
||||
if pid not in _latest:
|
||||
refresh_one(pid, port)
|
||||
latest, err = _latest.get(pid, (None, None))
|
||||
job = jobs.state.get(pid, {})
|
||||
if job.get("status") in ("downloading", "installing"):
|
||||
if job.get("status") in ("downloading", "installing", "waiting"):
|
||||
status = job["status"]
|
||||
elif err and not latest:
|
||||
elif err and not latest and not present:
|
||||
status = "error"
|
||||
elif not folder.exists():
|
||||
elif not present:
|
||||
status = "not installed"
|
||||
elif latest and tag != latest:
|
||||
status = "update available"
|
||||
@@ -472,11 +957,28 @@ def port_state():
|
||||
"id": pid, "name": port["name"], "installed": tag, "latest": latest, "status": status,
|
||||
"progress": job.get("progress", 0.0), "message": job.get("message") or err or "",
|
||||
"job": job.get("status", ""),
|
||||
"can_rollback": (GAMES_DIR / f".{port['gameid']}.previous").exists(),
|
||||
"can_rollback": bool(folder and (folder.parent / f".{folder.name}.previous").exists()),
|
||||
"repo": port["repo"], "mode": mode, "prereleases": bool(port.get("prereleases")),
|
||||
"builtin": bool(port.get("builtin")), "description": port.get("description", ""),
|
||||
"program": port.get("program", ""),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def refresh_one(pid, port):
|
||||
"""Fills in the latest version of an app that hasn't been checked yet (just added), in the background."""
|
||||
_latest[pid] = (None, None)
|
||||
|
||||
def work():
|
||||
try:
|
||||
rels = releases(port)
|
||||
rel = latest_release(port, rels)
|
||||
_latest[pid] = (rel["tag"] if rel else None, None if rel else "Only pre-releases so far")
|
||||
except RuntimeError as e:
|
||||
_latest[pid] = (None, str(e))
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
|
||||
def tsv(rows, keys):
|
||||
"""Tab-separated lines for the headset app (no JSON parser needed there)."""
|
||||
def clean(v):
|
||||
@@ -486,7 +988,7 @@ def tsv(rows, keys):
|
||||
return "1" if v else "0"
|
||||
if isinstance(v, float):
|
||||
return f"{v:.3f}"
|
||||
return re.sub(r"[\t\r\n]+", " ", str(v))
|
||||
return re.sub(r"[\t\r\n]+", " ", str(v)) or "-"
|
||||
return "\n".join("\t".join(clean(r.get(k)) for k in keys) for r in rows) + "\n"
|
||||
|
||||
|
||||
@@ -494,7 +996,7 @@ def tsv(rows, keys):
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "PortManager/1.0"
|
||||
server_version = "PortManager/2.0"
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
sys.stderr.write("%s %s\n" % (self.address_string(), fmt % args))
|
||||
@@ -509,6 +1011,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def fail(self, message, fmt, code=400):
|
||||
if fmt == "tsv":
|
||||
self.reply(f"!\t{message}\n", "text/plain")
|
||||
else:
|
||||
self.reply({"error": message}, code=code)
|
||||
|
||||
def allowed(self, q):
|
||||
password = config().get("password") or ""
|
||||
# The headset app talks to us over loopback and never needs the password.
|
||||
@@ -526,29 +1034,50 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.reply({"error": "password"}, code=401)
|
||||
return
|
||||
fmt = q.get("fmt", ["json"])[0]
|
||||
arg = lambda k: q.get(k, [""])[0]
|
||||
if url.path == "/api/state":
|
||||
rows = port_state()
|
||||
rows = app_state()
|
||||
notice = jobs.state.get("_list", {})
|
||||
if fmt == "tsv":
|
||||
self.reply(tsv(rows, ["id", "name", "installed", "latest", "status", "progress", "message",
|
||||
"can_rollback"]), "text/plain")
|
||||
head = f"#\t{notice.get('status', '')}\t{notice.get('message', '')}\n" if notice else ""
|
||||
self.reply(head + tsv(rows, ["id", "name", "installed", "latest", "status", "progress", "message",
|
||||
"can_rollback", "repo", "mode", "prereleases", "builtin", "job"]),
|
||||
"text/plain")
|
||||
else:
|
||||
self.reply({"ports": rows, "token_set": bool(config().get("github_token")),
|
||||
"busy": jobs.busy()})
|
||||
self.reply({"apps": rows, "token_set": bool(config().get("github_token")), "busy": jobs.busy(),
|
||||
"notice": notice})
|
||||
elif url.path == "/api/releases":
|
||||
pid = q.get("id", [""])[0]
|
||||
port = catalog().get(pid)
|
||||
port = catalog().get(arg("id"))
|
||||
if not port:
|
||||
self.reply({"error": "unknown port"}, code=404)
|
||||
self.fail("Unknown app.", fmt, 404)
|
||||
return
|
||||
try:
|
||||
rels = releases(port)
|
||||
except RuntimeError as e:
|
||||
self.reply({"error": str(e)} if fmt != "tsv" else f"!\t{e}\n", "application/json" if fmt != "tsv" else "text/plain")
|
||||
self.fail(str(e), fmt)
|
||||
return
|
||||
if fmt == "tsv":
|
||||
self.reply(tsv(rels, ["tag", "name", "date", "prerelease"]), "text/plain")
|
||||
else:
|
||||
self.reply({"releases": rels})
|
||||
elif url.path == "/api/inspect":
|
||||
try:
|
||||
info = inspect_repo(arg("repo"))
|
||||
except RuntimeError as e:
|
||||
self.fail(str(e), fmt)
|
||||
return
|
||||
if fmt == "tsv":
|
||||
head = tsv([info], ["repo", "name", "description", "tag", "date", "prereleases", "existing"])
|
||||
self.reply("=\t" + head + tsv(info["assets"], ["name", "size", "arm64"]), "text/plain")
|
||||
else:
|
||||
self.reply(info)
|
||||
elif url.path == "/api/suggestions":
|
||||
have = {p["repo"].lower() for p in catalog().values()}
|
||||
rows = [{**s, "added": s["repo"].lower() in have} for s in suggestions()]
|
||||
if fmt == "tsv":
|
||||
self.reply(tsv(rows, ["repo", "name", "note", "added"]), "text/plain")
|
||||
else:
|
||||
self.reply({"suggestions": rows})
|
||||
else:
|
||||
self.reply({"error": "not found"}, code=404)
|
||||
|
||||
@@ -558,17 +1087,60 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not self.allowed(q):
|
||||
self.reply({"error": "password"}, code=401)
|
||||
return
|
||||
pid = q.get("id", [""])[0]
|
||||
fmt = q.get("fmt", ["json"])[0]
|
||||
arg = lambda k: q.get(k, [""])[0]
|
||||
pid = arg("id")
|
||||
if url.path == "/api/refresh":
|
||||
_releases_cache.clear()
|
||||
threading.Thread(target=refresh, kwargs={"force": True}, daemon=True).start()
|
||||
self.reply({"ok": True})
|
||||
elif url.path in ("/api/install", "/api/rollback"):
|
||||
elif url.path == "/api/update_all":
|
||||
ok = start(update_all, "_list")
|
||||
self.reply({"ok": ok, "error": None if ok else "Something else is installing."})
|
||||
elif url.path in ("/api/install", "/api/rollback", "/api/remove"):
|
||||
if pid not in catalog():
|
||||
self.reply({"error": "unknown port"}, code=404)
|
||||
self.fail("Unknown app.", fmt, 404)
|
||||
return
|
||||
tag = q.get("tag", [None])[0] or None
|
||||
ok = start(install, pid, tag) if url.path == "/api/install" else start(rollback, pid)
|
||||
self.reply({"ok": ok, "error": None if ok else "Another install is running."})
|
||||
if url.path == "/api/install":
|
||||
ok = start(install, pid, arg("tag") or None)
|
||||
elif url.path == "/api/rollback":
|
||||
ok = start(rollback, pid)
|
||||
else:
|
||||
ok = start(remove, pid, arg("keep_files") != "1")
|
||||
self.reply({"ok": ok, "error": None if ok else "Something else is installing."})
|
||||
elif url.path == "/api/add":
|
||||
if jobs.busy():
|
||||
self.fail("Wait for the current install to finish.", fmt)
|
||||
return
|
||||
try:
|
||||
new = add_app(arg("repo"), arg("asset") or None, arg("name") or None, arg("mode") or "auto")
|
||||
except RuntimeError as e:
|
||||
self.fail(str(e), fmt)
|
||||
return
|
||||
if arg("install") == "1":
|
||||
start(install, new)
|
||||
self.reply(f"=\t{new}\n" if fmt == "tsv" else {"ok": True, "id": new},
|
||||
"text/plain" if fmt == "tsv" else "application/json")
|
||||
elif url.path == "/api/settings":
|
||||
port = catalog().get(pid)
|
||||
if not port:
|
||||
self.fail("Unknown app.", fmt, 404)
|
||||
return
|
||||
fields = {}
|
||||
if "prereleases" in q:
|
||||
fields["prereleases"] = arg("prereleases") == "1"
|
||||
if arg("name").strip():
|
||||
fields["name"] = arg("name").strip()[:80]
|
||||
if arg("mode") in ("auto", "game", "installer"):
|
||||
fields["mode"] = arg("mode")
|
||||
if "program" in q:
|
||||
fields["program"] = arg("program").strip().lstrip("/")
|
||||
if "asset" in q and arg("asset").strip() and not port.get("builtin"):
|
||||
fields["asset"] = arg("asset").strip()
|
||||
update_user_entry(pid, **fields)
|
||||
_releases_cache.clear()
|
||||
_latest.pop(pid, None)
|
||||
self.reply({"ok": True})
|
||||
elif url.path == "/api/token":
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
token = self.rfile.read(min(length, 4096)).decode().strip()
|
||||
|
||||
@@ -12,41 +12,65 @@
|
||||
font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||
main { max-width: 760px; margin: 0 auto; padding: 20px 16px 48px; }
|
||||
h1 { font-size: 1.5rem; margin: 0 0 4px; }
|
||||
.sub { color: var(--muted); margin: 0 0 20px; }
|
||||
h2 { font-size: 1.05rem; margin: 0 0 10px; }
|
||||
a { color: var(--accent); }
|
||||
.sub { color: var(--muted); margin: 0 0 16px; }
|
||||
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 16px; margin-bottom: 14px; }
|
||||
.top { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; flex-wrap: wrap; }
|
||||
.name { font-weight: 650; font-size: 1.05rem; }
|
||||
.repo { color: var(--muted); font-size: .85rem; text-decoration: none; }
|
||||
.badge { font-size: .85rem; padding: 2px 10px; border-radius: 99px; border: 1px solid var(--line); color: var(--muted); white-space: nowrap; }
|
||||
.badge.update { color: var(--warn); border-color: var(--warn); }
|
||||
.badge.ok { color: var(--ok); border-color: var(--ok); }
|
||||
.badge.error { color: var(--bad); border-color: var(--bad); }
|
||||
.badge.busy { color: var(--accent); border-color: var(--accent); }
|
||||
.ver { color: var(--muted); font-size: .92rem; margin: 6px 0 10px; }
|
||||
.msg { font-size: .92rem; margin: 8px 0 0; }
|
||||
.msg { font-size: .92rem; margin: 8px 0 0; overflow-wrap: anywhere; }
|
||||
.msg.error { color: var(--bad); } .msg.done { color: var(--ok); }
|
||||
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.grow { flex: 1; min-width: 200px; }
|
||||
button, select, input { font: inherit; border-radius: 8px; }
|
||||
button { padding: 8px 14px; border: 1px solid var(--line); background: #222a33; color: var(--text); cursor: pointer; }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
button.danger { color: var(--bad); }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
select, input { background: var(--bg); color: var(--text); border: 1px solid var(--line); padding: 8px 10px; max-width: 100%; }
|
||||
progress { width: 100%; height: 8px; margin-top: 10px; accent-color: var(--accent); }
|
||||
details summary { cursor: pointer; color: var(--muted); }
|
||||
details[open] summary { margin-bottom: 10px; }
|
||||
.hint { color: var(--muted); font-size: .9rem; }
|
||||
input[type=password] { flex: 1; min-width: 200px; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
.chip { text-align: left; padding: 8px 12px; max-width: 100%; }
|
||||
.chip small { display: block; color: var(--muted); font-size: .8rem; }
|
||||
.found { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 12px; }
|
||||
.notice { color: var(--ok); margin: 0 0 12px; }
|
||||
label { display: flex; gap: 8px; align-items: center; }
|
||||
</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="top"><h1>Port Manager</h1>
|
||||
<div class="row"><button id="updateall">Update all</button><button id="refresh">Check for updates</button></div></div>
|
||||
<p class="sub">Install and update Steam Frame apps and ports from their GitHub releases. Saves and ROMs are kept.</p>
|
||||
<p class="notice" id="notice" hidden></p>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Add an app from GitHub</h2>
|
||||
<div class="row"><input id="repo" class="grow" placeholder="owner/name or a github.com link" autocomplete="off">
|
||||
<button id="lookup">Look up</button></div>
|
||||
<p class="msg error" id="adderr" hidden></p>
|
||||
<div id="found" class="found" hidden></div>
|
||||
<div class="chips" id="suggest"></div>
|
||||
</div>
|
||||
|
||||
<div id="apps"></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">
|
||||
<div class="row"><input id="token" type="password" class="grow" placeholder="github_pat_…" autocomplete="off">
|
||||
<button id="savetoken">Save token</button></div>
|
||||
<p class="hint" id="tokenstate"></p>
|
||||
</details>
|
||||
@@ -59,6 +83,7 @@ const $ = id => document.getElementById(id);
|
||||
const q = encodeURIComponent;
|
||||
const hdr = () => key ? { "X-Key": key } : {};
|
||||
const releases = {};
|
||||
const open = {}; // app ids whose Edit section is open, kept across redraws
|
||||
|
||||
async function get(path) {
|
||||
const r = await fetch(path, { headers: hdr() });
|
||||
@@ -73,6 +98,11 @@ async function post(path, body) {
|
||||
const r = await fetch(path, { method: "POST", headers: hdr(), body });
|
||||
return r.json();
|
||||
}
|
||||
function el(tag, props = {}, ...kids) {
|
||||
const e = Object.assign(document.createElement(tag), props);
|
||||
e.append(...kids);
|
||||
return e;
|
||||
}
|
||||
|
||||
function badge(p) {
|
||||
if (p.status === "downloading" || p.status === "installing") return ["busy", p.status === "downloading" ? "Downloading" : "Installing"];
|
||||
@@ -81,6 +111,7 @@ function badge(p) {
|
||||
if (p.status === "error") return ["error", "Problem"];
|
||||
return ["", "Not installed"];
|
||||
}
|
||||
const modeText = { game: "In the Steam library", installer: "Set up by its installer", auto: "Decided on first install" };
|
||||
|
||||
async function loadReleases(p, sel) {
|
||||
if (!releases[p.id]) {
|
||||
@@ -89,19 +120,18 @@ async function loadReleases(p, sel) {
|
||||
}
|
||||
sel.innerHTML = "";
|
||||
for (const r of releases[p.id]) {
|
||||
const o = document.createElement("option");
|
||||
o.value = r.tag;
|
||||
o.textContent = `${r.tag}${r.tag === p.latest ? " (latest)" : ""}${r.tag === p.installed ? " (installed)" : ""} · ${r.date}`;
|
||||
sel.append(o);
|
||||
sel.append(el("option", { value: r.tag, textContent:
|
||||
`${r.tag}${r.prerelease ? " (pre-release)" : ""}${r.tag === p.latest ? " (latest)" : ""}${r.tag === p.installed ? " (installed)" : ""} · ${r.date}` }));
|
||||
}
|
||||
if (!sel.options.length) sel.append(el("option", { textContent: "No versions" }));
|
||||
}
|
||||
|
||||
function card(p, busy) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "panel";
|
||||
const div = el("div", { className: "panel" });
|
||||
const [cls, label] = badge(p);
|
||||
div.innerHTML = `
|
||||
<div class="top"><span class="name"></span><span class="badge ${cls}">${label}</span></div>
|
||||
<div class="top"><div><span class="name"></span><br><a class="repo" target="_blank" rel="noopener"></a></div>
|
||||
<span class="badge ${cls}">${label}</span></div>
|
||||
<div class="ver"></div>
|
||||
<div class="row">
|
||||
<button class="primary act"></button>
|
||||
@@ -109,12 +139,28 @@ function card(p, busy) {
|
||||
<button class="inst">Install this version</button>
|
||||
<button class="back">Previous version</button>
|
||||
</div>
|
||||
<p class="msg"></p>`;
|
||||
<p class="msg"></p>
|
||||
<details class="edit"><summary>Edit</summary>
|
||||
<div class="row"><input class="ename grow" placeholder="Name"></div>
|
||||
<p></p>
|
||||
<label><input type="checkbox" class="epre"> Include pre-releases</label>
|
||||
<p></p>
|
||||
<div class="row">How it's installed:
|
||||
<select class="emode"><option value="auto">Decide on first install</option>
|
||||
<option value="game">Add to the Steam library</option><option value="installer">Run its install.sh</option></select></div>
|
||||
<p></p>
|
||||
<div class="row"><input class="eprog grow" placeholder="Program to start, e.g. bin/mygame (blank: find it)"></div>
|
||||
<p class="hint">Used for library apps without their own run.sh. Takes effect on the next install.</p>
|
||||
<div class="row"><button class="save">Save</button><button class="danger rm">Remove</button></div>
|
||||
</details>`;
|
||||
div.querySelector(".name").textContent = p.name;
|
||||
const link = div.querySelector(".repo");
|
||||
link.textContent = p.repo; link.href = `https://github.com/${p.repo}`;
|
||||
div.querySelector(".ver").textContent =
|
||||
`Installed: ${p.installed === null ? "—" : p.installed} · Latest: ${p.latest === null ? "—" : p.latest}`;
|
||||
`Installed: ${p.installed === null ? "—" : p.installed} · Latest: ${p.latest === null ? "—" : p.latest} · ${modeText[p.mode] || p.mode}`;
|
||||
const act = div.querySelector(".act");
|
||||
act.textContent = p.status === "not installed" ? "Install" : p.status === "update available" ? "Update" : "Reinstall";
|
||||
act.textContent = p.status === "not installed" || p.status === "error" && !p.installed ? "Install"
|
||||
: p.status === "update available" ? "Update" : "Reinstall";
|
||||
const working = p.status === "downloading" || p.status === "installing";
|
||||
for (const b of div.querySelectorAll("button")) b.disabled = busy;
|
||||
div.querySelector(".back").disabled = busy || !p.can_rollback;
|
||||
@@ -126,35 +172,117 @@ function card(p, busy) {
|
||||
div.querySelector(".back").onclick = () => post(`/api/rollback?id=${q(p.id)}`).then(poll);
|
||||
const msg = div.querySelector(".msg");
|
||||
msg.textContent = p.message;
|
||||
msg.className = "msg " + (p.status === "error" || (p.message && p.job === "error") ? "error" : p.job === "done" ? "done" : "");
|
||||
msg.className = "msg " + (p.job === "error" || p.status === "error" ? "error" : p.job === "done" ? "done" : "");
|
||||
if (working) {
|
||||
const bar = document.createElement("progress");
|
||||
bar.max = 1; bar.value = p.progress;
|
||||
div.append(bar);
|
||||
const bar = el("progress", { max: 1, value: p.progress });
|
||||
div.insertBefore(bar, msg);
|
||||
}
|
||||
const edit = div.querySelector(".edit");
|
||||
edit.open = !!open[p.id];
|
||||
edit.ontoggle = () => { open[p.id] = edit.open; };
|
||||
div.querySelector(".ename").value = p.name;
|
||||
div.querySelector(".epre").checked = p.prereleases;
|
||||
div.querySelector(".emode").value = p.mode;
|
||||
div.querySelector(".eprog").value = p.program || "";
|
||||
div.querySelector(".save").onclick = async () => {
|
||||
const pre = div.querySelector(".epre").checked ? 1 : 0;
|
||||
await post(`/api/settings?id=${q(p.id)}&name=${q(div.querySelector(".ename").value)}&prereleases=${pre}` +
|
||||
`&mode=${q(div.querySelector(".emode").value)}&program=${q(div.querySelector(".eprog").value)}`);
|
||||
delete releases[p.id]; last = ""; poll();
|
||||
};
|
||||
div.querySelector(".rm").onclick = async () => {
|
||||
const what = p.installed ? " Its files are deleted too (saves and settings kept elsewhere stay)." : "";
|
||||
if (!confirm(`Remove ${p.name}?${what}`)) return;
|
||||
await post(`/api/remove?id=${q(p.id)}`);
|
||||
delete open[p.id]; setTimeout(poll, 600);
|
||||
};
|
||||
return div;
|
||||
}
|
||||
|
||||
// ---- adding apps
|
||||
|
||||
async function lookup(repo, name) {
|
||||
$("adderr").hidden = true;
|
||||
$("found").hidden = false;
|
||||
$("found").textContent = "Looking it up…";
|
||||
const info = await get(`/api/inspect?repo=${q(repo)}`);
|
||||
if (info.error) {
|
||||
$("found").hidden = true;
|
||||
$("adderr").textContent = info.error; $("adderr").hidden = false;
|
||||
return;
|
||||
}
|
||||
const f = $("found");
|
||||
f.innerHTML = "";
|
||||
f.append(
|
||||
el("div", { className: "name", textContent: info.name }),
|
||||
el("a", { className: "repo", href: `https://github.com/${info.repo}`, target: "_blank", textContent: info.repo }),
|
||||
el("p", { className: "hint", textContent: (info.description || "") }),
|
||||
el("p", { className: "hint", textContent: `Latest: ${info.tag} · ${info.date}${info.prereleases ? " (pre-release; pre-releases will be included)" : ""}` }));
|
||||
if (info.existing) {
|
||||
f.append(el("p", { className: "msg done", textContent: "It's already on your list." }));
|
||||
return;
|
||||
}
|
||||
const nameIn = el("input", { className: "grow", value: name || info.name });
|
||||
const assetSel = el("select");
|
||||
for (const a of info.assets)
|
||||
assetSel.append(el("option", { value: a.name, textContent: `${a.name} (${(a.size / 1048576).toFixed(1)} MB)${a.arm64 ? "" : " – not ARM64"}` }));
|
||||
const modeSel = el("select");
|
||||
for (const [v, t] of [["auto", "Decide from the download"], ["game", "Add to the Steam library"], ["installer", "Run its install.sh"]])
|
||||
modeSel.append(el("option", { value: v, textContent: t }));
|
||||
const add = async install => {
|
||||
const r = await post(`/api/add?repo=${q(info.repo)}&asset=${q(assetSel.value)}&name=${q(nameIn.value)}&mode=${q(modeSel.value)}&install=${install}`);
|
||||
if (r.error) { $("adderr").textContent = r.error; $("adderr").hidden = false; return; }
|
||||
f.hidden = true; $("repo").value = ""; last = ""; poll(); loadSuggestions();
|
||||
};
|
||||
f.append(
|
||||
el("div", { className: "row" }, "Name", nameIn), el("p"),
|
||||
el("div", { className: "row" }, "Download", assetSel), el("p"),
|
||||
el("div", { className: "row" }, "Install", modeSel), el("p"),
|
||||
el("div", { className: "row" },
|
||||
el("button", { className: "primary", textContent: "Add and install", onclick: () => add(1) }),
|
||||
el("button", { textContent: "Add", onclick: () => add(0) })));
|
||||
if (!info.assets.some(a => a.arm64))
|
||||
f.append(el("p", { className: "msg error", textContent: "None of its downloads say ARM64, so they may not run on the Frame." }));
|
||||
}
|
||||
|
||||
async function loadSuggestions() {
|
||||
const s = await get("/api/suggestions");
|
||||
const box = $("suggest");
|
||||
box.innerHTML = "";
|
||||
for (const x of (s.suggestions || []).filter(x => !x.added)) {
|
||||
box.append(el("button", { className: "chip", onclick: () => { $("repo").value = x.repo; lookup(x.repo, x.name); } },
|
||||
x.name, el("small", { textContent: x.note })));
|
||||
}
|
||||
if (box.children.length) box.prepend(el("div", { className: "hint", style: "width:100%", textContent: "Suggested for the Frame:" }));
|
||||
}
|
||||
|
||||
let timer = null, last = "";
|
||||
async function poll() {
|
||||
const s = await get("/api/state");
|
||||
const snapshot = JSON.stringify(s);
|
||||
if (snapshot !== last) { // redraw only on changes, so an open version list isn't reset
|
||||
last = snapshot;
|
||||
const box = $("ports");
|
||||
const box = $("apps");
|
||||
box.innerHTML = "";
|
||||
for (const p of s.ports) box.append(card(p, s.busy));
|
||||
for (const p of s.apps) box.append(card(p, s.busy));
|
||||
const n = s.notice || {};
|
||||
$("notice").hidden = !n.message;
|
||||
$("notice").textContent = n.message || "";
|
||||
$("updateall").disabled = s.busy;
|
||||
}
|
||||
$("tokenstate").textContent = s.token_set ? "A token is saved." : "No token saved.";
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(poll, s.busy ? 700 : 5000);
|
||||
}
|
||||
|
||||
$("lookup").onclick = () => lookup($("repo").value);
|
||||
$("repo").onkeydown = e => { if (e.key === "Enter") lookup($("repo").value); };
|
||||
$("refresh").onclick = async () => {
|
||||
for (const k in releases) delete releases[k];
|
||||
await post("/api/refresh");
|
||||
setTimeout(poll, 2500);
|
||||
};
|
||||
$("updateall").onclick = () => post("/api/update_all").then(() => setTimeout(poll, 300));
|
||||
$("savetoken").onclick = async () => {
|
||||
await post("/api/token", $("token").value.trim());
|
||||
$("token").value = "";
|
||||
@@ -162,6 +290,7 @@ $("savetoken").onclick = async () => {
|
||||
setTimeout(poll, 2500);
|
||||
};
|
||||
poll();
|
||||
loadSuggestions();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"repo": "sasaken1102r/frame-perf-overlay",
|
||||
"name": "Frame Perf Overlay",
|
||||
"note": "fps, CPU/GPU, temperatures and battery in the SteamVR dashboard"
|
||||
},
|
||||
{
|
||||
"repo": "sasaken1102r/frame-mic-tuner",
|
||||
"name": "Frame Mic Tuner",
|
||||
"note": "Switch the mic's echo cancellation and noise suppression from the dashboard"
|
||||
},
|
||||
{
|
||||
"repo": "beko-kerbecotton/framecolor",
|
||||
"name": "Frame Color",
|
||||
"note": "Adjust the display's colours from the dashboard (restarts SteamVR)"
|
||||
},
|
||||
{
|
||||
"repo": "stephen-moskal/openmw-vr-steam-frame",
|
||||
"name": "OpenMW-VR",
|
||||
"note": "Morrowind in VR on the headset; needs Morrowind installed from Steam"
|
||||
}
|
||||
]
|
||||
Reference in new issue
Block a user