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:
CogentialandClaude Opus 5.5 committed 2026-10-07 12:25:59 -04:00
1 parent 65c6ad3eaa
commit 94865cb34f
9 files changed
+1559 -263

No files matched your search

+29 -21
View File
@@ -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
+683 -128
View File
File diff suppressed because it is too large. Load diff
+14 -4
View File
@@ -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"
}
]
+1 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+152 -23
View File
@@ -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>
+22
View File
@@ -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"
}
]