diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..98e291c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,63 @@ +name: Release + +# Builds the headset app on an ARM64 runner (Ubuntu 24.04: glibc 2.39, as SteamOS on the Frame) and publishes +# frametanium-steam-frame-arm64.tar.gz, which Frametanium installs to update itself (install.sh inside). +# Push a tag v* to release. +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: write + +env: + SDL2_VERSION: 2.30.12 + +jobs: + release: + runs-on: ubuntu-24.04-arm + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Install the toolchain and SDL2's dependencies + run: | + sudo apt-get update -q + sudo apt-get install -y -q clang lld ninja-build cmake \ + libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxfixes-dev libxi-dev libxss-dev \ + libxkbcommon-dev libwayland-dev wayland-protocols libdecor-0-dev libdrm-dev libgbm-dev libegl-dev libgles-dev \ + libpulse-dev libasound2-dev libpipewire-0.3-dev libudev-dev libdbus-1-dev + + # A source build loads X11, Wayland, audio... at run time instead of linking them. + - name: Build SDL2 + run: | + curl -fsSL "https://github.com/libsdl-org/SDL/releases/download/release-$SDL2_VERSION/SDL2-$SDL2_VERSION.tar.gz" | tar -xz -C "$RUNNER_TEMP" + cmake -S "$RUNNER_TEMP/SDL2-$SDL2_VERSION" -B "$RUNNER_TEMP/sdl2-build" -G Ninja -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_C_COMPILER=clang -DCMAKE_INSTALL_PREFIX="$RUNNER_TEMP/sdl2" -DSDL_SHARED=ON -DSDL_STATIC=OFF -DSDL_TEST=OFF + ninja -C "$RUNNER_TEMP/sdl2-build" install + + - name: Build the headset app + run: | + mkdir -p dist/app/lib + clang -O2 -Wall -march=armv8.2-a -fuse-ld=lld -I"$RUNNER_TEMP/sdl2/include/SDL2" -D_REENTRANT app/app.c \ + -L"$RUNNER_TEMP/sdl2/lib" -lSDL2-2.0 -Wl,-rpath,'$ORIGIN/lib' -o dist/app/frametanium + strip dist/app/frametanium + cp -L "$RUNNER_TEMP/sdl2/lib/libSDL2-2.0.so.0" dist/app/lib/ + sed -n '/^cat > "$OUT\/run.sh" <<.RUN.$/,/^RUN$/p' app/build-arm64.sh | sed '1d;$d' > dist/app/run.sh + chmod +x dist/app/run.sh + grep -q 'exec ./frametanium' dist/app/run.sh + + - name: Package + run: | + tools/bundle.sh dist/app dist/bundle + tar -C dist/bundle -czf frametanium-steam-frame-arm64.tar.gz . + tar -tzf frametanium-steam-frame-arm64.tar.gz | grep -v '^./art/' + + - name: Publish + if: startsWith(github.ref, 'refs/tags/v') + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" frametanium-steam-frame-arm64.tar.gz --target "$GITHUB_SHA" \ + --title "Frametanium $GITHUB_REF_NAME" --notes "Update from Frametanium itself (its own row in the list), or unpack and run install.sh on the Frame." diff --git a/README.md b/README.md index 822c6a4..f4f3afa 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ and Frametanium keeps it up to date. Works from the headset or a browser. - **Add any GitHub repo** by typing `owner/name` (or pasting a link) on the headset's on-screen keyboard, from the browser page, or by picking a suggestion made for the Frame. The built-in ports (Ship of Harkinian, 2 Ship 2 - Harkinian) are there from the start. + Harkinian, Starship, Lighthouse, OpenCE) are there from the start. - **Discover new Frame apps:** every 12 hours (or on demand) it searches GitHub for Steam Frame projects (the `steam-frame` topic, "steam frame" in names and descriptions, and the community's [awesome-steam-frame](https://github.com/kosantosbik/awesome-steam-frame) list) and shows the ones whose latest @@ -17,7 +17,8 @@ and Frametanium keeps it up to date. Works from the headset or a browser. AppImages and single programs all work. - **Two ways to install,** picked from the download unless you choose: - *Library app:* unpacked into `~/devkit-game/`, 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). + program it finds), added to the Steam library with art (the repo's GitHub card, and the project's icon or the owner's avatar), + fetched when it's installed. - *Its installer:* when the download has `install.sh` (or `contrib/install.sh`, `setup.sh`), it's unpacked into `~/.local/share/frametanium/apps/` and that script runs, for tools such as SteamVR overlays. Removing the app runs its uninstaller when it has one. @@ -35,6 +36,10 @@ and Frametanium keeps it up to date. Works from the headset or a browser. Updates are checked every 6 hours and on demand. +**Frametanium updates itself:** it's in its own list. Updating it downloads its latest +[release](https://github.com/Cogential/frametanium/releases), reinstalls the service and the headset app, and +restarts the service a few seconds later (reopen the headset app to get the new one). + ## Files - `catalog.json`: the built-in ports. - `suggestions.json`: apps offered on the Add screen. @@ -51,14 +56,18 @@ ROMs), or save a token in the web page's Settings: a fine-grained token limited to the page. ## Install -From a computer that can SSH into the Frame: +On the Frame: download `frametanium-steam-frame-arm64.tar.gz` from the latest release, unpack it and run +`sh install.sh`. It installs the service and adds the Frametanium app to the library. + +Or from a computer that can SSH into the Frame: ```sh app/build-arm64.sh # cross-compiles the headset app into dist/app ./deploy.sh [host] # host defaults to frame.local ``` -Service only, on the Frame: copy this folder over and run `sh install.sh`. +Releases: push a tag `v*`; `.github/workflows/release.yml` builds the headset app on an ARM64 runner and publishes +the bundle (`tools/bundle.sh`). Uninstall: `systemctl --user disable --now frametanium.service` and remove Frametanium from the library. ## Notes diff --git a/catalog.json b/catalog.json index ae38fd9..6fcd9ad 100644 --- a/catalog.json +++ b/catalog.json @@ -15,7 +15,8 @@ "data": "~/.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" + "mode": "game", + "icon_url": "https://raw.githubusercontent.com/Cogential/Shipwright-frame/steam-frame/soh/macosx/sohIcon.png" }, { "id": "2ship", @@ -45,7 +46,8 @@ "cd \"$D/usr/bin\" && exec ./2s2h.elf \"$@\"" ], "prereleases": true, - "mode": "game" + "mode": "game", + "icon_url": "https://raw.githubusercontent.com/Cogential/2ship2harkinian-frame/claude/focused-brahmagupta-sylmhh/mm/linux/2s2hIcon.png" }, { "id": "starship", @@ -63,7 +65,8 @@ "data": "~/.local/share/starship", "rom_hint": "Send your Star Fox 64 ROM (US) with Frame Inbox, or put a .z64 in ~/.local/share/starship.", "prereleases": true, - "mode": "game" + "mode": "game", + "icon_url": "https://raw.githubusercontent.com/Cogential/Starship-frame/steam-frame/logo.png" }, { "id": "halo", @@ -80,6 +83,34 @@ "data": "~/.local/share/halo-ce", "rom_hint": "Needs your Halo: Combat Evolved Xbox disc image: put the .iso in ~/.local/share/halo-ce (or send it with Frame Inbox) and the first start copies its maps out.", "prereleases": true, + "mode": "game", + "icon_url": "https://raw.githubusercontent.com/Cogential/OpenCE-frame/steam-frame/port/android/art/android-icon.png" + }, + { + "id": "banjo", + "name": "Banjo-Kazooie (Lighthouse)", + "repo": "Cogential/Lighthouse", + "asset": "lighthouse-steam-frame-arm64.zip", + "type": "zip", + "gameid": "Banjo_Kazooie", + "launch": "run.sh", + "keep": [ + "*.z64" + ], + "data": "~/.local/share/Lighthouse", + "rom_hint": "Send your Banjo-Kazooie ROM (US) with Frame Inbox, or put a .z64 in ~/.local/share/Lighthouse.", + "icon_url": "https://raw.githubusercontent.com/Cogential/Lighthouse/android/logo.png", + "prereleases": true, "mode": "game" + }, + { + "id": "frametanium", + "name": "Frametanium", + "repo": "Cogential/frametanium", + "asset": "frametanium-steam-frame-arm64.tar.gz", + "type": "tar", + "prereleases": false, + "mode": "installer", + "description": "This app. Updating it reinstalls the service and the headset app, then restarts the service." } ] diff --git a/deploy.sh b/deploy.sh index ed22f69..a003a65 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,6 +1,7 @@ #!/bin/sh # Installs or updates Frametanium on a Steam Frame from this computer over SSH: the service, and the -# "Frametanium" app in the Steam library with its art. +# "Frametanium" app in the Steam library with its art. (Once it's installed, it can also update itself from +# its GitHub releases.) # # Usage: ./deploy.sh [host] host defaults to frame.local # Env: SSH_KEY (default ~/.ssh/frame_devkit), SSH_USER (default steamos) @@ -11,37 +12,12 @@ HOST="${1:-frame.local}" KEY="${SSH_KEY:-$HOME/.ssh/frame_devkit}" USER_AT="${SSH_USER:-steamos}@$HOST" SSH="ssh -i $KEY -o ConnectTimeout=10" -GAMEID=Frametanium [ -x dist/app/frametanium ] || { echo "Build the app first: app/build-arm64.sh"; exit 1; } $SSH "$USER_AT" true || { echo "Can't reach $HOST over SSH. Is the Frame on and on this network?"; exit 1; } -echo "== Service" +tools/bundle.sh dist/app dist/bundle $SSH "$USER_AT" 'rm -rf ~/.cache/frametanium-src && mkdir -p ~/.cache/frametanium-src' -rsync -a -e "$SSH" manager.py page.html catalog.json suggestions.json frametanium.service install.sh art "$USER_AT:.cache/frametanium-src/" +rsync -a -e "$SSH" dist/bundle/ "$USER_AT:.cache/frametanium-src/" $SSH "$USER_AT" 'sh ~/.cache/frametanium-src/install.sh' - -echo "== Headset app" -rsync -a --delete -e "$SSH" dist/app/ "$USER_AT:devkit-game/$GAMEID/" -# The app used to be called Port Manager: remove its old folder and library tile. -$SSH "$USER_AT" '[ ! -e ~/devkit-game/Port_Manager ] || python3 ~/devkit-utils/steamos-delete --delete-title Port_Manager' -$SSH "$USER_AT" "python3 ~/devkit-utils/steam-client-create-shortcut --parms '{\"gameid\": \"$GAMEID\", \"directory\": \"/home/steamos/devkit-game/$GAMEID\", \"argv\": [\"./run.sh\"], \"env\": {}, \"settings\": {\"compat_tool\": \"SteamLinuxRuntime_4-arm64\", \"steam_play\": \"0\"}}'" >/dev/null - -echo "== Library art" -APPID="" -for i in 1 2 3 4 5 6 7 8 9 10; do - APPID=$($SSH "$USER_AT" "python3 - $GAMEID" < tools/find_appid.py) && [ -n "$APPID" ] && break - sleep 2 -done -if [ -n "$APPID" ]; then - for USERDIR in $($SSH "$USER_AT" 'ls -d ~/.local/share/Steam/userdata/[1-9]*/config 2>/dev/null'); do - $SSH "$USER_AT" "mkdir -p $USERDIR/grid" - for pair in portrait.png:p.png wide.png:.png hero.png:_hero.png logo.png:_logo.png icon.png:_icon.png; do - rsync -a -e "$SSH" "art/app/${pair%%:*}" "$USER_AT:$USERDIR/grid/${APPID}${pair##*:}" - done - done - echo "Art added for app id $APPID (shows after Steam restarts)." -else - echo "Couldn't find the shortcut's app id yet; run this again later to add the art." -fi echo "Done. Open \"Frametanium\" from the library, or http://$HOST:8643 in a browser." diff --git a/install.sh b/install.sh index 8d3a6a4..072f9c9 100755 --- a/install.sh +++ b/install.sh @@ -24,6 +24,46 @@ rm -rf "$dest/art" && cp -r "$here/art" "$dest/art" cp "$here/frametanium.service" "$HOME/.config/systemd/user/" systemctl --user daemon-reload systemctl --user enable frametanium.service >/dev/null 2>&1 + +# The headset app, when the bundle has it (releases and deploy.sh do): files are replaced rather than overwritten, +# so a running copy keeps going until it's closed. +if [ -x "$here/app/frametanium" ]; then + game="$HOME/devkit-game/Frametanium" + mkdir -p "$game" + rm -rf "$game/frametanium" "$game/run.sh" "$game/lib" + cp -r "$here/app/." "$game/" + chmod +x "$game/frametanium" "$game/run.sh" + # The app used to be called Port Manager: remove its old folder and library tile. + if [ -e "$HOME/devkit-game/Port_Manager" ]; then + python3 "$HOME/devkit-utils/steamos-delete" --delete-title Port_Manager >/dev/null 2>&1 || true + fi + if [ -f "$HOME/devkit-utils/steam-client-create-shortcut" ]; then + python3 "$HOME/devkit-utils/steam-client-create-shortcut" --parms "{\"gameid\": \"Frametanium\", \"directory\": \"$game\", \"argv\": [\"./run.sh\"], \"env\": {}, \"settings\": {\"compat_tool\": \"SteamLinuxRuntime_4-arm64\", \"steam_play\": \"0\"}}" >/dev/null 2>&1 || true + # Library art, without replacing art already there. + appid="" + for i in 1 2 3 4 5 6 7 8 9 10; do + appid=$(python3 "$here/tools/find_appid.py" Frametanium 2>/dev/null) && [ -n "$appid" ] && break + sleep 2 + done + if [ -n "$appid" ]; then + for grid in "$HOME"/.local/share/Steam/userdata/[1-9]*/config; do + [ -d "$grid" ] || continue + mkdir -p "$grid/grid" + for pair in portrait.png:p.png wide.png:.png hero.png:_hero.png logo.png:_logo.png icon.png:_icon.png; do + [ -e "$grid/grid/$appid${pair##*:}" ] || cp "$here/art/app/${pair%%:*}" "$grid/grid/$appid${pair##*:}" + done + done + fi + fi +fi + +# When Frametanium updates itself it runs this script from the service, and restarting the service now would stop +# this script and the service before they finish: restart it a few seconds later from a unit of its own instead. +if [ "$(systemctl --user show -p MainPID --value frametanium.service 2>/dev/null)" = "$PPID" ]; then + systemd-run --user --on-active=3 --collect systemctl --user restart frametanium.service >/dev/null + echo "Frametanium restarts in a few seconds." + exit 0 +fi systemctl --user restart frametanium.service sleep 1 ip=$(python3 -c 'import socket;s=socket.socket(2,2);s.connect(("192.0.2.1",9));print(s.getsockname()[0])' 2>/dev/null || hostname) diff --git a/manager.py b/manager.py index 935b39b..8550693 100755 --- a/manager.py +++ b/manager.py @@ -85,9 +85,14 @@ def catalog(): 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: - apps[p["id"]] = {**apps.get(p["id"], {}), **p} + users = [p for p in load_json(USER_CATALOG, []) if "id" in p] + # A repo the user added before it became a built-in one stays the user's entry, so it isn't listed twice. + added = {p["repo"].lower(): p["id"] for p in users if p.get("repo")} + for pid, p in list(apps.items()): + if added.get(p["repo"].lower(), pid) != pid: + del apps[pid] + for p in users: + 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")} @@ -593,7 +598,7 @@ def fetch(url, dest, limit=8 * 1024 * 1024): 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"): + if port.get("builtin") and (APP_DIR / "art" / port["id"]).is_dir(): return APP_DIR / "art" / port["id"] art = DATA_DIR / "art" / port["id"] if not (art / "wide.png").exists(): @@ -601,7 +606,7 @@ def art_folder(port): 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") + fetch(port.get("icon_url") or f"https://github.com/{owner}.png?size=256", art / "icon.png") except OSError: pass return art @@ -1043,6 +1048,8 @@ def remove(pid, delete_files=True): outside its folder stay.""" port = catalog()[pid] try: + if pid == "frametanium": + raise RuntimeError("Frametanium can't remove itself.") installed = load_json(INSTALLED_FILE, {}) mode = installed.get(pid, {}).get("mode") or app_mode(port) note = "" diff --git a/tools/bundle.sh b/tools/bundle.sh new file mode 100755 index 0000000..ba8af9f --- /dev/null +++ b/tools/bundle.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# Puts together what install.sh installs: tools/bundle.sh +# (the app folder is what app/build-arm64.sh makes: frametanium, run.sh, lib/). +set -e +cd "$(dirname "$0")/.." +APPDIR="$1" OUT="$2" +[ -x "$APPDIR/frametanium" ] || { echo "No built headset app in $APPDIR" >&2; exit 1; } +rm -rf "$OUT" && mkdir -p "$OUT/tools" +cp manager.py page.html catalog.json suggestions.json frametanium.service install.sh README.md "$OUT/" +cp tools/find_appid.py "$OUT/tools/" +cp -r art "$OUT/art" +cp -r "$APPDIR" "$OUT/app"