From c06b3285f2aa7700f75a2c05839e9f34613be8b7 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:15:43 +1000 Subject: [PATCH 1/2] Add Android Steam library artwork and supervised Lepton sessions Generate five artwork slots, refresh VR shortcuts and managed collections, and retain a signal-aware launcher around setsid so stopping the wrapper cleans its container. Cover installation, artwork and launch cleanup offline; record the Steam client startup blocker for device verification. Co-Authored-By: GPT-6 Astra --- .claude/NOTES-vr-library.md | 105 ++++++ docs/vr-apks.md | 47 +++ frame/android/lepton-app.sh | 34 +- frame/android/steam_shortcuts.py | 95 ++++- tests/fakeframe/rootfs/usr/local/bin/podman | 4 + .../usr/local/lib/fakeframe/cef_shim.js | 26 ++ tests/fixtures/library/README.md | 17 + tests/fixtures/library/icon.jpg | Bin 0 -> 834 bytes tests/fixtures/library/icon.png | Bin 0 -> 77 bytes tests/fixtures/library/steam-responses.json | 12 + tests/test_frame_android_library.py | 350 ++++++++++++++++++ ui/frame_android.py | 52 ++- ui/frame_apk.py | 5 +- ui/frame_artwork.py | 241 ++++++++++++ 14 files changed, 969 insertions(+), 19 deletions(-) create mode 100644 .claude/NOTES-vr-library.md create mode 100644 tests/fixtures/library/README.md create mode 100644 tests/fixtures/library/icon.jpg create mode 100644 tests/fixtures/library/icon.png create mode 100644 tests/fixtures/library/steam-responses.json create mode 100644 tests/test_frame_android_library.py create mode 100644 ui/frame_artwork.py diff --git a/.claude/NOTES-vr-library.md b/.claude/NOTES-vr-library.md new file mode 100644 index 0000000..9ebc857 --- /dev/null +++ b/.claude/NOTES-vr-library.md @@ -0,0 +1,105 @@ +# vr-library implementation notes + +- Scope: `/tmp/vrapk/p1.md`; work only in steam-frame-vrlib, no delegation, + no push/PR/issues. Parent handles independent review. +- Read source interface, APK/VR docs, catalogue docs and installer/catalogue, + Frame skill and device architecture docs. +- Registry validation passed; no Steam Frame capability in rendered entries. + Prior-work skill absent at both skill roots. Existing worktree explicitly + authorized by task; no portfolio registration or out-of-worktree writes. +- Device preflight: SSH alias timed out; frame.local does not resolve. No + sessions started or stopped; no settings changed. Device evidence pending. +- Current launcher execs setsid --wait, replacing the wrapper and providing + no TERM/INT/HUP cleanup for its podman container. Need testable supervisor. +- Artwork: installer currently copies only APK icon; shortcut updates do not + refresh existing name/icon. Plan stdlib PNG fallbacks and Steam grid files. + +## Implementation and evidence + +- Added `ui/frame_artwork.py`: bounded non-interlaced PNG decode/composition, + five fallback slots, transparent logo, supplied PNG/JPEG bytes or HTTP(S) + URLs. Standard library only; ASCII lettering, original label in native UI. +- Small additive `ui/frame_apk.py` fallback finds `assets/icon.png` in Open + Saber Plus (its manifest/adaptive icon yielded no PNG before). SuperTux's + normal APK icon works. Generated all slots from both supplied APKs and + visually inspected the individual grids plus contact sheet. +- `install(..., artwork=None)` forwards artwork through patching, stages it, + refreshes existing shortcuts instead of duplicating them, marks immersive + shortcuts VR, and stores presentation warnings in meta.json. Source images + retain their dimensions; generated slots use the requested Steam sizes. +- Frame helper uses native SetCustomArtworkForApp/ClearCustomArtworkForApp. + Collections use collectionStore: create static Android/Android VR, preserve + other members and dynamic/read-only names, remove obsolete membership. +- Remove clears artwork/managed memberships before app-folder deletion. A + Steam cleanup failure leaves local metadata for a retry. Failed new install + attempts to remove a newly created shortcut. +- Root-cause feedback loop: original launcher failed both cleanup tests + (no `podman stop` on TERM or normal exit). It exec'd setsid without a signal + handler. New supervising shell stays in Steam's tracked tree, handles + TERM/INT/HUP, stops its container, terminates its child process group, and + keeps the Lepton exit code. Lock + pre-existing container check prevent a + second launch from stopping someone else's session. Data paths unchanged. +- Stop helper calls TerminateApp with the exact 64-bit game-ID string (not + the 32-bit app ID, and not a lossy JavaScript number). Frame Control also + directly stops the container if Steam is unavailable. +- API signatures/collection semantics read from public source, not guessed: + [Steam client types](https://github.com/SteamDeckHomebrew/decky-frontend-lib/blob/main/src/globals/steam-client/App.ts) + and [Steam UI](https://github.com/SteamDatabase/SteamTracking/blob/master/ClientExtracted/steamui/chunk~2dcc5aaf7.js). + The fakeframe CEF/podman fixtures were extended accordingly; their new + library methods are explicitly source-derived, not recorded device calls. + +## Verification (2026-09-28) + +- `python3 -m unittest discover -s tests -p test_frame_android_library.py`: + initial two launcher cases FAILED on the old implementation; the focused + suite passed after the changes (20 cases at that run, then one JPEG case + added and covered by the final whole-suite run). +- `python3 -m unittest discover -s tests`: **187 tests, OK**, exit 0, + 9.948 seconds. Actual local runtime is **Python 3.9.6** (`python3 --version`), + not just a syntax-compatibility check. Log: `/tmp/vrlib-evidence/tests.log`. +- `bash -n frame/android/lepton-app.sh`, `node --check + tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js`, Python AST parse + with `feature_version=(3,9)`, and `git diff --check`: exit 0. +- Generated self-contained Linux harness using the actual launcher and the + fixture Lepton/podman, retaining the Frame's real setsid/flock: + `ssh -o BatchMode=yes -o ConnectTimeout=8 frame 'python3 - 2>&1' < + /tmp/vrlib-evidence/linux-launcher-check.py` — **3 tests, OK**, exit 0; + normal exit + TERM + INT/HUP subcases, preserved saved-data sentinel. + It uses a TemporaryDirectory; no installed game or global setting touched. + Log: `/tmp/vrlib-evidence/linux-launcher-tests.log`. +- `ssh -o BatchMode=yes -o ConnectTimeout=8 frame python3 - < + ui/frame_vrshot.py`: exit 0; returned `/tmp/frame-vrcap/shot-28947-vr.png`. + Copied to `/tmp/vrlib-evidence/headset-preflight.png`, then removed only the + temporary remote screenshot. The capture shows Steam's startup error. +- Source APKs were read from `/tmp/vrapk/opensaberplus.apk` and + `/tmp/vrapk/supertux.apk`. Ten generated assets plus + `/tmp/vrlib-evidence/artwork-preview.png` are local artwork previews, + **not Steam UI screenshots**. + +## Device blocker and open questions + +- SSH initially timed out; later reachable on BUILD_ID 20260925.6191901, + SteamVR 2.18.1. `podman ps` was empty and vrserver showed no current game + scene. Steam CDP 8080 refused connections; no steamwebhelper was running. + The updater repeatedly extracted/installed, and the headset displayed + "There was an issue launching Steam". Bounded retries remained unavailable; + console_log had no new game launch entries. Captures/logs live under + `/tmp/vrlib-evidence/` (`device-preflight.log`, `device-final.log`). +- No real APK was reinstalled/launched/stopped. Therefore actual Steam Play + and Stop/reaper tracking, app data across a real stop/relaunch, native + library artwork display, collection persistence, VR scene attribution and + automatic dashboard dismissal are **not verified**. No Steam library + screenshot was possible. Next: restore Steam, reinstall the two APKs, + test one brief Steam launch/Stop at a time, capture library/headset output, + and check podman + console_log after each transition. +- VR shortcuts are marked through SetShortcutIsVR. Source inspection shows + Steam Resume hides the dashboard only when scene-app ID matches the + shortcut; whether Lepton gets that attribution remains open. No automatic + dashboard hiding or permanent standby workaround added. Power settings + were never changed, so no restoration was needed. +- Full fakeframe container E2E suite was not run; its updated JS fixture was + exercised by the local Node/V8 test. Native Windows install path untested. +- No independent reviewer process launched: the explicit brief forbids + delegation and assigns review/integration to the parent. No review claimed. +- No registry observation write outside this worktree: brief explicitly + restricts work to this checkout (plus its requested evidence directory). diff --git a/docs/vr-apks.md b/docs/vr-apks.md index 8d31cc3..7504450 100644 --- a/docs/vr-apks.md +++ b/docs/vr-apks.md @@ -84,6 +84,53 @@ not being worn, so it did not reach `FOCUSED`). The loader was never the problem: Wolvic's Quest `libopenxr_loader.so` is a Khronos-style loader and found SteamVR through `/vendor`. +## In the Steam library + +**Implemented; Steam UI behavior still inferred (2026-09-28).** Installs and +updates refresh the same shortcut's name, VR flag and five artwork slots: +600×900 grid, 920×430 wide capsule, 3840×1240 hero, transparent 1280×480 logo +and 256×256 icon. Stdlib PNG fallbacks use the APK icon and label (including +Godot's `assets/icon.png`); unsupported/missing icons get a letter tile. +Generated lettering uses ASCII; Steam's native title retains the full label. + +Sources can pass `install(apk_path, artwork={'hero': image_bytes_or_http_url})`. +Slots are `grid`, `wide`, `hero`, `logo`, `icon`; supplied PNG/JPEG images keep +their dimensions. Limits: 12 MiB and 8 million pixels per image. The PNG +reader accepts non-interlaced 1/2/4-bit palette/grayscale and 8-bit +RGB/RGBA/grayscale. PNG icons also seed missing slots. Images are staged +inside the app directory, then applied through Steam's custom-artwork API. + +Immersive installs join **Android** and **Android VR** and get +`SetShortcutIsVR`; `--flat` installs join **Android** only. Existing dynamic +or read-only collections are preserved. Unsupported APIs/collection conflicts +appear in `library_warnings`. Removal clears all five custom slots and managed +memberships before deleting the shortcut and folder; `--keep-data` retains +app data. Failed Steam cleanup leaves metadata for a retry. + +The launcher stays alive around Lepton's separate session and handles +TERM/INT/HUP by stopping its own container and child process group. Normal +exit also cleans up. A lock and container check refuse duplicate launches. +The Stop helper uses `SteamClient.Apps.TerminateApp` with the exact 64-bit +game ID string, matching Steam's Stop action; Frame Control also falls back +to a direct container stop. Persistent data remains under the same instance ID. + +**Verified:** offline artwork, mocked SSH and V8 API tests; signal and +normal-exit tests on the Frame with real Linux `setsid`/`flock` and fake +Lepton/podman. **Not verified:** actual Steam Play/Stop, library rendering, +collections and direct-to-scene launch. On build `20260925.6191901`, the +headset showed “There was an issue launching Steam”, CDP port 8080 refused +connections and the updater was stuck. Capture: +`/tmp/vrlib-evidence/headset-preflight.png` on the development Mac. No installed +apps or global VR settings were changed during verification. + +The VR flag does not override wear detection. Steam's Resume action hides +the dashboard when its scene-app ID matches the shortcut (inferred from +[Steam's UI source](https://github.com/SteamDatabase/SteamTracking/blob/master/ClientExtracted/steamui/chunk~2dcc5aaf7.js)). +Whether Lepton gets that association still needs a headset test. No forced +hide or power override is installed. If using temporary standby settings +for unattended testing, restore `power.pauseCompositorOnStandby 1` and +`power.turnOffScreensTimeout 5`; see [the device notes](how-the-frame-works.md). + ## Out of scope - **Meta entitlement.** Apps that call the Oculus Platform SDK diff --git a/frame/android/lepton-app.sh b/frame/android/lepton-app.sh index 63b0572..e903e57 100644 --- a/frame/android/lepton-app.sh +++ b/frame/android/lepton-app.sh @@ -19,6 +19,14 @@ done # A number that isn't a real Steam app; it names this app's Lepton context. export SteamAppId="$(cat "$DIR/instance.id")" +[[ "$SteamAppId" =~ ^[0-9]+$ ]] || { echo "invalid instance.id" >&2; exit 1; } +exec 9>"$DIR/launch.lock" +flock -n 9 || { echo "Android app is already running" >&2; exit 1; } +CONTAINER="lepton-steamlaunch-$SteamAppId" +if [[ "$(podman inspect --format '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" == true ]]; then + echo "Android container is already running" >&2 + exit 1 +fi export STEAM_COMPAT_INSTALL_PATH="$DIR" # Must be under ~/.local/share/Steam: only that tree is mounted in the container. export STEAM_COMPAT_DATA_PATH="$HOME/.local/share/Steam/steamapps/compatdata/$SteamAppId" @@ -29,4 +37,28 @@ mkdir -p "$STEAM_COMPAT_DATA_PATH" "$STEAM_FOSSILIZE_DUMP_PATH" # Lepton's setpgid --foreground re-exec needs a terminal that Steam shortcuts # and SSH don't have; give it its own session instead. export IS_PARENT=true -exec setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" +# Keep this shell in Steam's process tree; setsid alone has no container cleanup. +child="" +cleanup() { + trap '' TERM INT HUP + if [[ -n "$child" ]]; then + kill -TERM -- "-$child" 2>/dev/null || true + kill -TERM "$child" 2>/dev/null || true + fi + podman stop -t 5 "$CONTAINER" >/dev/null 2>&1 || true + if [[ -n "$child" ]]; then + kill -KILL -- "-$child" 2>/dev/null || true + kill -KILL "$child" 2>/dev/null || true + wait "$child" 2>/dev/null || true + fi +} +trap cleanup EXIT +trap 'exit 143' TERM +trap 'exit 130' INT +trap 'exit 129' HUP +setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" & +child=$! +rc=0 +wait "$child" || rc=$? +child="" +exit "$rc" diff --git a/frame/android/steam_shortcuts.py b/frame/android/steam_shortcuts.py index 6aea8de..15bf5f9 100644 --- a/frame/android/steam_shortcuts.py +++ b/frame/android/steam_shortcuts.py @@ -5,6 +5,8 @@ Python stdlib only; the Mac runs it with `ssh frame python3 - < this`. steam_shortcuts.py add NAME EXE START_DIR [ICON] -> prints the shortcut app id steam_shortcuts.py list -> JSON [{appid, name, exe}] + steam_shortcuts.py configure APPID NAME EXE START_DIR ICON VR ARTWORK_JSON + steam_shortcuts.py stop APPID steam_shortcuts.py remove APPID """ import base64, json, os, socket, struct, sys, urllib.request @@ -83,6 +85,91 @@ def evaluate(js): return res.get('result', {}).get('value') +# Steam's ELibraryAssetType (Capsule, Hero, Logo, Header, Icon). +ASSETS = {'grid': 0, 'hero': 1, 'logo': 2, 'wide': 3, 'icon': 4} + + +def collections_js(appid, vr=None): + wanted = [] if vr is None else ['Android', *(['Android VR'] if vr else [])] + return f'''async function syncCollections() {{ + const wanted = {json.dumps(wanted)}; + if (typeof collectionStore === "undefined" || + typeof collectionStore.GetUserCollectionsByName !== "function" || + typeof collectionStore.NewUnsavedCollection !== "function" || + typeof collectionStore.SaveCollection !== "function") + return ["Steam collections API unavailable"]; + const app = {{appid: {appid}}}; + const warnings = []; + for (const name of ["Android", "Android VR"]) {{ + const matches = collectionStore.GetUserCollectionsByName(name); + let collection = matches.find(c => !c.bIsDynamic && c.bAllowsDragAndDrop); + if (wanted.includes(name)) {{ + if (!collection && matches.length) {{ + warnings.push(name + " is an existing dynamic or read-only collection"); + continue; + }} + if (!collection) {{ + collection = collectionStore.NewUnsavedCollection(name, undefined, [app]); + }} else {{ + collection.AsDragDropCollection().AddApps([app]); + }} + await collectionStore.SaveCollection(collection); + }} else if (collection) {{ + collection.AsDragDropCollection().RemoveApps([app]); + await collectionStore.SaveCollection(collection); + }} + }} + return warnings; + }}''' + + +def configure(appid, name, exe, start_dir, icon, vr, artwork): + images = [] + for slot, path in artwork.items(): + if slot not in ASSETS: + raise ValueError('unknown artwork slot') + ext = os.path.splitext(path)[1][1:] + if ext not in ('png', 'jpg'): + raise ValueError('artwork must be PNG or JPEG') + with open(path, 'rb') as f: + data = f.read(12 * 1024 * 1024 + 1) + if len(data) > 12 * 1024 * 1024: + raise ValueError('artwork is too large') + images.append([ASSETS[slot], ext, base64.b64encode(data).decode()]) + return evaluate(f'''(async () => {{ + const id = {int(appid)}, warnings = []; + SteamClient.Apps.SetShortcutName(id, {json.dumps(name)}); + SteamClient.Apps.SetShortcutExe(id, {json.dumps(exe)}); + SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)}); + SteamClient.Apps.SetShortcutIcon(id, {json.dumps(icon)}); + if (typeof SteamClient.Apps.SetShortcutIsVR === "function") + SteamClient.Apps.SetShortcutIsVR(id, {json.dumps(vr)}); + else warnings.push("Steam VR shortcut flag API unavailable"); + if (typeof SteamClient.Apps.SetCustomArtworkForApp === "function") {{ + for (const [type, ext, data] of {json.dumps(images)}) + await SteamClient.Apps.SetCustomArtworkForApp(id, data, ext, type); + }} else warnings.push("Steam artwork API unavailable"); + {collections_js(int(appid), vr)} + try {{ warnings.push(...await syncCollections()); }} + catch (e) {{ warnings.push("Steam collections: " + String(e)); }} + return {{warnings}}; + }})()''') + + +def remove(appid): + return evaluate(f'''(async () => {{ + const id = {int(appid)}; + {collections_js(int(appid))} + const warnings = await syncCollections(); + if (typeof SteamClient.Apps.ClearCustomArtworkForApp === "function") {{ + for (const type of [0, 1, 2, 3, 4]) + await SteamClient.Apps.ClearCustomArtworkForApp(id, type); + }} else throw new Error("Steam artwork removal API unavailable"); + SteamClient.Apps.RemoveShortcut(id); + return {{warnings}}; + }})()''') + + def main(): cmd, args = sys.argv[1], sys.argv[2:] if cmd == 'add': @@ -100,9 +187,13 @@ def main(): js = '''(() => appStore.allApps.filter(a => a.app_type === 1073741824) .map(a => ({appid: a.appid, name: a.display_name})))()''' print(json.dumps(evaluate(js))) + elif cmd == 'configure': + print(json.dumps(configure(int(args[0]), *args[1:5], args[5] == '1', json.loads(args[6])))) + elif cmd == 'stop': + evaluate(f'SteamClient.Apps.TerminateApp({json.dumps(str((int(args[0]) << 32) | 0x02000000))}, false)') + print('stopping') elif cmd == 'remove': - evaluate(f'SteamClient.Apps.RemoveShortcut({int(args[0])})') - print('removed') + print(json.dumps(remove(int(args[0])))) else: sys.exit(__doc__) diff --git a/tests/fakeframe/rootfs/usr/local/bin/podman b/tests/fakeframe/rootfs/usr/local/bin/podman index a996bf3..9c10c24 100755 --- a/tests/fakeframe/rootfs/usr/local/bin/podman +++ b/tests/fakeframe/rootfs/usr/local/bin/podman @@ -25,6 +25,10 @@ containers = {n: c for n, c in fs.read()['lepton'].items() if alive(c)} if cmd == 'ps': for name, c in sorted(containers.items()): print(f"{name} {c['port']}") +elif cmd == 'inspect': + if args[-1] not in containers: + sys.exit(1) + print('true') elif cmd == 'stop': name = args[-1] c = containers.get(name) diff --git a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js index e50f3cf..1a3f2da 100644 --- a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js +++ b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js @@ -54,7 +54,22 @@ function build(steam) { } }; + // Library API shapes from SteamTracking / decky-frontend-lib (2026-09-28). + // Not yet verified on this Frame build: Steam was unavailable during testing. + steam.collections ||= []; + const collection = value => ({ + ...value, displayName: value.name, bIsDynamic: !!value.dynamic, bAllowsDragAndDrop: true, + AsDragDropCollection() { return this; }, + AddApps(apps) { value.apps = [...new Set([...value.apps, ...apps.map(a => a.appid)])]; }, + RemoveApps(apps) { value.apps = value.apps.filter(id => !apps.some(a => a.appid === id)); }, + value, + }); return { + collectionStore: { + GetUserCollectionsByName(name) { return steam.collections.filter(c => c.name === name).map(collection); }, + NewUnsavedCollection(name, filter, apps) { return collection({name, apps: apps.map(a => a.appid)}); }, + async SaveCollection(c) { if (!steam.collections.includes(c.value)) steam.collections.push(c.value); }, + }, appStore: { get allApps() { return allApps(); }, GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; }, @@ -75,6 +90,17 @@ function build(steam) { SetShortcutStartDir(id, dir) { const s = findShortcut(id); if (s) s.start_dir = String(dir); }, SetShortcutIcon(id, icon) { const s = findShortcut(id); if (s) s.icon = String(icon); }, SetShortcutExe(id, exe) { const s = findShortcut(id); if (s) s.exe = String(exe); }, + SetShortcutIsVR(id, vr) { const s = findShortcut(id); if (s) s.vr = vr; }, + async SetCustomArtworkForApp(id, data, ext, type) { + const s = findShortcut(id); + if (s) { s.artwork ||= {}; s.artwork[type] = {data, ext}; } + }, + async ClearCustomArtworkForApp(id, type) { + const s = findShortcut(id); + if (s?.artwork) delete s.artwork[type]; + }, + // Container fallback in frame_android.stop performs the simulated stop. + TerminateApp(gameid) { steam.last_terminate = gameid; }, RemoveShortcut(id) { steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id)); delete steam.compat_tools[String(id)]; diff --git a/tests/fixtures/library/README.md b/tests/fixtures/library/README.md new file mode 100644 index 0000000..04d4f03 --- /dev/null +++ b/tests/fixtures/library/README.md @@ -0,0 +1,17 @@ +# Library fixtures + +`icon.png` is a synthetic 2×2 RGBA fixture with opaque, half-transparent and +transparent pixels. `steam-responses.json` includes the Open Saber Plus +shortcut ID/name and home path read from the Frame's existing metadata on +2026-09-28; the `configure` response is synthetic, matching our helper's +contract. Tests never contact the network. + +The existing fakeframe CEF shim models artwork and collection methods from +SteamTracking's `ClientExtracted/steamui/chunk~2dcc5aaf7.js` and +SteamDeckHomebrew/decky-frontend-lib's `src/globals/steam-client/App.ts`, read +2026-09-28. These methods were not captured from this headset: Steam's client +was unavailable. The Node-based test runs the actual generated JavaScript +against that fixture; it is skipped when Node is absent. + +`icon.jpg` is the same synthetic icon converted with macOS `sips` to exercise +JPEG SOF parsing. `sips` is not used by the product or tests. diff --git a/tests/fixtures/library/icon.jpg b/tests/fixtures/library/icon.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ea4d67c7fa6e8a22255570e1c3b136e4ebe3c159 GIT binary patch literal 834 zcmex=LK$;OGwtxvP%d~(Q!3HV(|CYfbAS1sdzc?emK*3ngfWgAa)0YKg8W4cl zs$izT71^Gf{S2E}UN&&fc=N-l?*9P>K@J8c1}0`kB?cxzMrJ|A|3?_)fp)Sof&o|? zkYHqDW?^Mx=iubx1}fMpz`(@F%*@2X%*qO~hOrhX&%h$cDx_%W$R-?^$gWfnAuRebI{N?Mn?>~P20{IIVo)B*V zNr=zT{3QtV7ZVE$GYdP&UyMxUAdd^Ouqqm|2{{I`Cl(4T88vc8f2KE_o9%~}YXK;@p{B@hbnSlumn3)1c))-U1o+|LKhO|HpcyPIEbKrez|F}mKr;A$7nm#=|KDQZVP*s-CT2kfdxpOg ze`J1m{qW0vJ{xV7c@mfI-uK+7GFyXF<-kmpH|y^I6WxAz|1Z%k>;Et||7Yk}@lWDE Z!%5Q*^Zz#K*ngR;y<`3PbBF)m1OPQ10F?j$ literal 0 HcmV?d00001 diff --git a/tests/fixtures/library/icon.png b/tests/fixtures/library/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..2e2af85bfee9eda4374916597c21b36b410f5214 GIT binary patch literal 77 zcmeAS@N?(olHy`uVBq!ia0vp^Od!m`1|*BN@u~nR5lII', data, 16), art.SLOTS[slot]) + # Transparent logo, opaque capsules; corrupt/missing icon gets a monogram. + _, _, logo = art.decode(images['logo'][1]) + self.assertIn(0, logo[3::4]) + self.assertIn(255, logo[3::4]) + self.assertEqual(set(art.fallback('日本語', b'bad icon')), set(art.SLOTS)) + + def test_supplied_slots_and_url(self): + import io + data = (FIXTURES / 'icon.png').read_bytes() + response = io.BytesIO(data) + response.geturl = lambda: 'https://example.org/icon.png' + with patch.object(art.urllib.request, 'urlopen', return_value=response) as fetch: + images = art.prepare('Game', artwork={'hero': data, 'icon': 'https://example.org/icon.png'}) + self.assertEqual(images['hero'], ('png', data)) + self.assertEqual(images['icon'], ('png', data)) + self.assertEqual(fetch.call_count, 1) + self.assertEqual(fetch.call_args.kwargs['timeout'], 20) + self.assertEqual(fetch.call_args.args[0].get_header('User-agent'), 'FrameControl/1.0') + + def test_supplied_jpeg(self): + data = (FIXTURES / 'icon.jpg').read_bytes() + self.assertEqual(art.image_type(data), 'jpg') + self.assertEqual(art.prepare('Game', artwork={'wide': data})['wide'], ('jpg', data)) + with self.assertRaises(ValueError): + art.image_type(data[:30]) + + def test_bad_artwork_and_expansion_limits(self): + import zlib + for value in ({'bad': b'bad'}, {'hero': 'file:///etc/passwd'}, {'hero': b'bad'}, ['hero']): + with self.subTest(value=value), self.assertRaises(ValueError): + art.prepare('Game', artwork=value) + data = bytearray((FIXTURES / 'icon.png').read_bytes()) + data[45] ^= 1 + with self.assertRaisesRegex(ValueError, 'checksum'): + art.decode(bytes(data)) + bomb = art.PNG + art.chunk(b'IHDR', struct.pack('>IIBBBBB', 1, 1, 8, 6, 0, 0, 0)) + \ + art.chunk(b'IDAT', zlib.compress(b'\0' * 1_000_000)) + art.chunk(b'IEND', b'') + with self.assertRaisesRegex(ValueError, 'pixels'): + art.decode(bomb) + huge = art.PNG + art.chunk(b'IHDR', struct.pack('>IIBBBBB', 10000, 10000, 8, 6, 0, 0, 0)) + art.chunk(b'IEND', b'') + with self.assertRaisesRegex(ValueError, 'dimensions'): + art.decode(huge) + + def test_palette_and_filters(self): + import zlib + header = art.chunk(b'IHDR', struct.pack('>IIBBBBB', 2, 1, 1, 3, 0, 0, 0)) + data = art.PNG + header + art.chunk(b'PLTE', b'\xff\0\0\0\xff\0') + art.chunk(b'tRNS', b'\xff\x80') + \ + art.chunk(b'IDAT', zlib.compress(b'\0\x40')) + art.chunk(b'IEND', b'') + self.assertEqual(art.decode(data)[2], bytes([255, 0, 0, 255, 0, 255, 0, 128])) + for method in range(5): + # Two identical RGBA rows: exercise each predictor with known filtered bytes. + first = bytes([10, 20, 30, 255] * 2) + filtered = bytearray() + for x, value in enumerate(first): + a, b, c = first[x-4] if x >= 4 else 0, first[x], first[x-4] if x >= 4 else 0 + predictor = (0, a, b, (a+b)//2, b)[method] + filtered.append((value - predictor) & 255) + data = art.PNG + art.chunk(b'IHDR', struct.pack('>IIBBBBB', 2, 2, 8, 6, 0, 0, 0)) + \ + art.chunk(b'IDAT', zlib.compress(b'\0' + first + bytes([method]) + filtered)) + art.chunk(b'IEND', b'') + self.assertEqual(art.decode(data)[2], first * 2) + + def test_godot_project_icon(self): + import io + import zipfile + data = (FIXTURES / 'icon.png').read_bytes() + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, 'w') as archive: + archive.writestr('assets/icon.png', data) + with zipfile.ZipFile(buffer) as archive: + self.assertEqual(android.frame_apk._icon_png(archive, set(archive.namelist()), []), data) + + +class InstallTests(unittest.TestCase): + def setUp(self): + self.responses = json.loads((FIXTURES / 'steam-responses.json').read_text()) + self.info = {'package': 'org.test.vr', 'label': 'VR', 'version': '1', 'icon_png': None, + 'vr': True, 'launchable': True, 'repairable': False, 'abis': [], 'min_sdk': 24} + self.images = {slot: ('png', b'PNG ' + slot.encode()) for slot in art.SLOTS} + self.existing = {'package': 'org.test.vr', 'instance': 2800000001, + 'shortcut': 3346865537, 'label': 'Old name'} + + def install(self, existing, tool=None): + def shortcut(*args, **kwargs): + if args[0] == 'add': + return '3346865537' + if args[0] == 'list': + return json.dumps(self.responses['shortcuts']) + return json.dumps(self.responses['configure']) + with patch.object(android.frame_artwork, 'prepare', return_value=self.images), \ + patch.object(android, 'read_meta', return_value=existing), \ + patch.object(android, '_copy') as copy, \ + patch.object(android, 'ssh', return_value=self.responses['home']) as ssh, \ + patch.object(android, 'shortcut_tool', side_effect=tool or shortcut) as api, \ + patch.object(android, '_write_meta') as meta: + result = android._install('game.apk', self.info, self.info['package'], False, 'New name', 'test') + return result, ssh, api, meta + + def test_existing_shortcut_refreshes_name_vr_and_every_slot(self): + result, ssh, api, meta = self.install(self.existing) + calls = [c.args for c in api.call_args_list] + self.assertNotIn('add', [c[0] for c in calls]) + configure = next(c for c in calls if c[0] == 'configure') + self.assertEqual(configure[1:3], ('3346865537', 'New name')) + self.assertEqual(configure[6], '1') + self.assertEqual(set(json.loads(configure[7])), set(art.SLOTS)) + self.assertTrue(configure[5].endswith('/org.test.vr/icon.png')) + self.assertEqual(result['shortcut'], self.existing['shortcut']) + self.assertEqual(result['label'], 'New name') + self.assertEqual(result['library_warnings'], []) + self.assertEqual(len([c for c in ssh.call_args_list if isinstance(c.kwargs.get('input'), bytes)]), 5) + meta.assert_called_once() + + def test_first_install_adds_shortcut(self): + _, _, api, _ = self.install(None) + self.assertEqual([c.args[0] for c in api.call_args_list], ['add', 'configure']) + + def test_artwork_forwarded_through_patch(self): + artwork = {'hero': b'provided'} + with patch.object(android, 'apk_info', return_value={**self.info, 'repairable': True}), \ + patch.object(android, 'xr_compat_files', return_value={}), \ + patch.object(android, 'patch', return_value={'patched': ['launcher']}), \ + patch.object(android, '_install', return_value={}) as install: + android.install('x.apk', artwork=artwork) + self.assertIs(install.call_args.args[-1], artwork) + + def test_failed_new_install_removes_shortcut(self): + def tool(*args): + if args[0] == 'add': + return '3346865537' + if args[0] == 'configure': + raise android.FrameError('write failed') + return '{}' + with patch.object(android.frame_artwork, 'prepare', return_value=self.images), \ + patch.object(android, 'read_meta', return_value=None), \ + patch.object(android, '_copy'), patch.object(android, 'ssh', return_value='/home/steamos') as ssh, \ + patch.object(android, 'shortcut_tool', side_effect=tool) as api: + with self.assertRaisesRegex(android.FrameError, 'write failed'): + android._install('x.apk', self.info, 'org.test.vr', False, None, None) + self.assertIn(('remove', '3346865537'), [c.args for c in api.call_args_list]) + self.assertTrue(any(c.args[0] == 'rm -rf Applications/Android/org.test.vr' for c in ssh.call_args_list)) + + def test_remove_keeps_data_when_requested_and_surfaces_api_failure(self): + with patch.object(android, '_meta_or_fail', return_value=dict(self.existing)), \ + patch.object(android, 'stop'), \ + patch.object(android, 'shortcut_tool', return_value='{"warnings": []}') as api, \ + patch.object(android, 'ssh') as ssh: + android.remove('org.test.vr', keep_data=True) + api.assert_called_once_with('remove', '3346865537') + ssh.assert_called_once_with('rm -rf Applications/Android/org.test.vr') + api.side_effect = android.FrameError('CDP unavailable') + ssh.reset_mock() + with self.assertRaises(android.FrameError): + android.remove('org.test.vr') + ssh.assert_not_called() + + def test_stop_requests_steam_and_has_container_fallback(self): + with patch.object(android, '_meta_or_fail', return_value=self.existing), \ + patch.object(android, 'shortcut_tool', side_effect=android.FrameError('offline')) as api, \ + patch.object(android, 'ssh') as ssh: + android.stop('org.test.vr') + api.assert_called_once_with('stop', '3346865537') + self.assertIn('podman stop -t 5 lepton-steamlaunch-2800000001', ssh.call_args.args[0]) + + +class SteamAPITests(unittest.TestCase): + def test_artwork_api_enums_and_safe_serialization(self): + with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate: + shortcuts.configure(42, 'A "name"\n', '/path', '/start', '/icon', True, + {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS}) + js = evaluate.call_args.args[0] + self.assertIn('SetShortcutIsVR(id, true)', js) + self.assertIn('SetShortcutName(id, "A \\"name\\"\\n")', js) + self.assertIn('SetCustomArtworkForApp(id, data, ext, type)', js) + self.assertEqual(shortcuts.ASSETS, {'grid': 0, 'hero': 1, 'logo': 2, 'wide': 3, 'icon': 4}) + self.assertIn('NewUnsavedCollection(name, undefined, [app])', js) + + def test_remove_clears_every_slot_before_shortcut(self): + with patch.object(shortcuts, 'evaluate', return_value={}) as evaluate: + shortcuts.remove(42) + js = evaluate.call_args.args[0] + self.assertIn('[0, 1, 2, 3, 4]', js) + self.assertLess(js.index('ClearCustomArtworkForApp(id, type)'), js.index('RemoveShortcut(id)')) + self.assertIn('const wanted = []', js) + + def test_stop_uses_exact_64_bit_game_id_string(self): + with patch.object(sys, 'argv', ['steam_shortcuts.py', 'stop', '3346865537']), \ + patch.object(shortcuts, 'evaluate') as evaluate, patch('builtins.print'): + shortcuts.main() + self.assertEqual(evaluate.call_args.args[0], 'SteamClient.Apps.TerminateApp("14374678025558032384", false)') + + +class SteamContextTests(unittest.TestCase): + @unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node') + def test_collection_lifecycle_and_native_artwork_calls(self): + steam = {'apps': [], 'shortcuts': [{'appid': 42, 'name': 'Before'}], 'compat_tools': {}, + 'collections': [{'name': 'Android', 'apps': [999]}]} + def evaluate(expression): + nonlocal steam + proc = subprocess.run(['node', str(ROOT / 'tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js')], + input=json.dumps({'id': 1, 'expression': expression, 'awaitPromise': True, + 'steam': steam}) + '\n', + text=True, capture_output=True, timeout=10, check=True) + reply = json.loads(proc.stdout) + self.assertNotIn('exceptionDetails', reply['result']) + steam = reply['steam'] + return reply['result']['result'].get('value') + with patch.object(shortcuts, 'evaluate', side_effect=evaluate): + result = shortcuts.configure(42, 'Game', '/exe', '/dir', '/icon', True, + {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS}) + self.assertEqual(result['warnings'], []) + self.assertTrue(steam['shortcuts'][0]['vr']) + self.assertEqual(set(steam['shortcuts'][0]['artwork']), {'0', '1', '2', '3', '4'}) + self.assertEqual(steam['collections'], [{'name': 'Android', 'apps': [999, 42]}, + {'name': 'Android VR', 'apps': [42]}]) + shortcuts.configure(42, 'Renamed', '/exe', '/dir', '/icon', False, {}) + self.assertEqual(steam['shortcuts'][0]['name'], 'Renamed') + self.assertEqual(steam['collections'][1]['apps'], []) + shortcuts.remove(42) + self.assertEqual(steam['shortcuts'], []) + self.assertEqual(steam['collections'][0]['apps'], [999]) + + +if __name__ == '__main__': + unittest.main() diff --git a/ui/frame_android.py b/ui/frame_android.py index 499e99e..79f8183 100644 --- a/ui/frame_android.py +++ b/ui/frame_android.py @@ -13,6 +13,7 @@ Python stdlib only. CLI: python3 ui/frame_android.py import json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib import frame_apk +import frame_artwork import frame_host import tempfile import zipfile @@ -135,7 +136,7 @@ def _write_meta(d, meta): ssh(f'cat > {d}/meta.json.tmp && mv {d}/meta.json.tmp {d}/meta.json', input=json.dumps(meta, indent=1)) -def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None): +def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None, artwork=None): info = apk_info(apk_path) if icon_png: info['icon_png'] = icon_png @@ -154,43 +155,61 @@ def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr patched = os.path.join(tmp, 'app.apk') info['patched'] = patch(apk_path, patched, add)['patched'] info['launchable'] = True - return _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path)) - return _install(apk_path, info, pkg, flatscreen, name, source) + return _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path), artwork) + return _install(apk_path, info, pkg, flatscreen, name, source, artwork) -def _install(apk_path, info, pkg, flatscreen, name, source): +def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None): + try: + images = frame_artwork.prepare(name or info['label'], info.get('icon_png'), artwork) + except (ValueError, OSError) as e: + raise FrameError(f'could not prepare artwork: {e}') from e iid = instance_id(pkg) d = f'{APPS_DIR}/{pkg}' existing = read_meta(pkg) - ok = False + ok, created = False, None try: ssh(f'mkdir -p {d}') _copy(apk_path, f'{d}/app.apk.part') _copy(LAUNCHER, f'{d}/launch.sh', executable=True, timeout=120) - icon = '' - if info['icon_png']: - ssh(f'cat > {d}/icon.png', input=info['icon_png']) - icon = f'$HOME/{d}/icon.png' + ssh(f'mkdir -p {d}/artwork') + paths = {} + for slot, (ext, data) in images.items(): + path = f'{d}/icon.{ext}' if slot == 'icon' else f'{d}/artwork/{slot}.{ext}' + ssh(f'cat > {path}.tmp && mv {path}.tmp {path}', input=data) + paths[slot] = path + icon = paths['icon'] marker = f'touch {d}/lepton-show-flatscreen' if flatscreen else f'rm -f {d}/lepton-show-flatscreen' ssh(f'mv {d}/app.apk.part {d}/app.apk && echo {iid} > {d}/instance.id && {marker}') home = ssh('echo $HOME').strip() shortcut = _int((existing or {}).get('shortcut')) if not shortcut or shortcut not in _shortcut_ids(): reply = shortcut_tool('add', name or info['label'], f'{home}/{d}/launch.sh', f'{home}/{d}', - icon.replace('$HOME', home)) + f'{home}/{icon}') shortcut = _int(reply.strip().splitlines()[-1] if reply.strip() else None) if not shortcut: raise FrameError(f'Steam did not return a shortcut id (got {reply[:80]!r})') + created = shortcut + presentation = json.loads(shortcut_tool( + 'configure', str(shortcut), name or info['label'], f'{home}/{d}/launch.sh', + f'{home}/{d}', f'{home}/{icon}', '0' if flatscreen else '1', + json.dumps({slot: f'{home}/{path}' for slot, path in paths.items()})) or '{}') meta = {'package': pkg, 'label': name or info['label'], 'version': info['version'], 'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut), 'vr': info.get('vr', False), 'vr_issues': info.get('vr_issues', []), 'launchable': info.get('launchable', False), 'patched': info.get('patched', []), 'flatscreen': flatscreen, 'installed': time.strftime('%Y-%m-%dT%H:%M:%S'), - 'source': source or os.path.basename(apk_path)} + 'source': source or os.path.basename(apk_path), + 'library_warnings': presentation.get('warnings', [])} _write_meta(d, meta) ok = True return meta finally: + if not ok and created: + try: + shortcut_tool('remove', str(created)) + except FrameError: + pass if not ok and not existing: # A first install that failed part-way: don't leave an orphan folder behind. try: @@ -270,6 +289,11 @@ def launch(pkg): def stop(pkg): m = _meta_or_fail(pkg) + if m['shortcut']: + try: + shortcut_tool('stop', str(int(m['shortcut']))) + except FrameError: + pass # Steam unavailable: the container stop also ends its waiting launcher. ssh(f"podman stop -t 5 lepton-steamlaunch-{int(m['instance'])} >/dev/null 2>&1 || true", timeout=60) return m @@ -278,10 +302,8 @@ def remove(pkg, keep_data=False): m = _meta_or_fail(pkg) stop(pkg) if m['shortcut']: - try: - shortcut_tool('remove', str(int(m['shortcut']))) - except FrameError: - pass # already gone from Steam + result = json.loads(shortcut_tool('remove', str(int(m['shortcut']))) or '{}') + m['library_warnings'] = result.get('warnings', []) iid = int(m['instance']) extra = '' if keep_data else f' {COMPAT}/{iid} {SHADERS}/{iid}' ssh(f'rm -rf {APPS_DIR}/{pkg}{extra}') diff --git a/ui/frame_apk.py b/ui/frame_apk.py index 0be260a..b46def0 100644 --- a/ui/frame_apk.py +++ b/ui/frame_apk.py @@ -254,7 +254,10 @@ def _icon_png(z, names, icons): # Adaptive icons are XML; fall back to the largest launcher PNG. pngs = sorted((n for n in names if n.endswith('.png') and 'ic_launcher' in n and 'foreground' not in n), key=lambda n: z.getinfo(n).file_size) - return _read(z, pngs[-1], MAX_ICON) if pngs else None + if pngs: + return _read(z, pngs[-1], MAX_ICON) + # Godot exports can keep only an adaptive launcher plus the project icon. + return _read(z, 'assets/icon.png', MAX_ICON) if 'assets/icon.png' in names else None if __name__ == '__main__': diff --git a/ui/frame_artwork.py b/ui/frame_artwork.py new file mode 100644 index 0000000..5264344 --- /dev/null +++ b/ui/frame_artwork.py @@ -0,0 +1,241 @@ +"""Small stdlib PNG compositor for Steam's Android library artwork.""" +import hashlib +import struct +import textwrap +import unicodedata +import urllib.parse +import urllib.request +import zlib + +SLOTS = {'grid': (600, 900), 'wide': (920, 430), 'hero': (3840, 1240), + 'logo': (1280, 480), 'icon': (256, 256)} +MAX_IMAGE = 12 * 1024 * 1024 +MAX_PIXELS = 8_000_000 +PNG = b'\x89PNG\r\n\x1a\n' +# Five-column display lettering; labels remain exact in Steam's native text. +GLYPHS = dict(zip('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789- .?', ( + '0E11111F111111', '1E11111E11111E', '0F10101010100F', '1E11111111111E', + '1F10101E10101F', '1F10101E101010', '0F10101711110F', '1111111F111111', + '0E04040404040E', '0702020212120C', '11121418141211', '1010101010101F', + '111B1515111111', '11191513111111', '0E11111111110E', '1E11111E101010', + '0E11111115120D', '1E11111E141211', '0F10100E01011E', '1F040404040404', + '1111111111110E', '11111111110A04', '11111115151B11', '11110A040A1111', + '11110A04040404', '1F01020408101F', '0E11131519110E', '040C040404040E', + '0E11010204081F', '1E01010E01011E', '02060A121F0202', '1F10101E01011E', + '0E10101E11110E', '1F010204080808', '0E11110E11110E', '0E11110F01010E', + '0000001F000000', '00000000000000', '00000000000C0C', '0E110102040004'))) + + +def chunk(kind, data): + return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data)) + + +def png(width, height, pixels): + stride = width * 4 + raw = b''.join(b'\0' + pixels[y * stride:(y + 1) * stride] for y in range(height)) + return PNG + chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 6, 0, 0, 0)) + \ + chunk(b'IDAT', zlib.compress(raw, 6)) + chunk(b'IEND', b'') + + +def decode(data): + """Non-interlaced PNG, including packed palette/grayscale APK icons.""" + if not isinstance(data, bytes) or not data.startswith(PNG) or len(data) > MAX_IMAGE: + raise ValueError('expected a PNG image (at most 12 MiB)') + pos, packed, palette, alpha, header = 8, bytearray(), b'', b'', None + while pos + 12 <= len(data): + size = struct.unpack_from('>I', data, pos)[0] + kind, body = data[pos + 4:pos + 8], data[pos + 8:pos + 8 + size] + if pos + size + 12 > len(data): + raise ValueError('truncated PNG') + crc = struct.unpack_from('>I', data, pos + 8 + size)[0] + if zlib.crc32(kind + body) != crc: + raise ValueError('invalid PNG checksum') + if kind == b'IHDR': + if header is not None or size != 13: + raise ValueError('invalid PNG header') + header = struct.unpack('>IIBBBBB', body) + elif kind == b'PLTE': + palette = body + elif kind == b'tRNS': + alpha = body + elif kind == b'IDAT': + packed.extend(body) + elif kind == b'IEND': + break + pos += size + 12 + else: + raise ValueError('incomplete PNG') + if header is None: + raise ValueError('missing PNG header') + w, h, depth, color, compression, filtering, interlace = header + channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}.get(color) + if not w or not h or w * h > MAX_PIXELS or w > 8192 or h > 8192: + raise ValueError('PNG dimensions exceed limits') + if not channels or compression or filtering or interlace or depth not in (1, 2, 4, 8) or (depth != 8 and color not in (0, 3)): + raise ValueError('unsupported PNG encoding') + stride, bpp = (w * channels * depth + 7) // 8, max(1, channels * depth // 8) + expected = h * (stride + 1) + decoder = zlib.decompressobj() + raw = decoder.decompress(bytes(packed), expected + 1) + if len(raw) != expected or not decoder.eof: + raise ValueError('invalid PNG pixels') + pixels, prev = bytearray(), bytearray(stride) + for y in range(h): + start = y * (stride + 1) + method, row = raw[start], bytearray(raw[start + 1:start + 1 + stride]) + if method > 4: + raise ValueError('invalid PNG filter') + for x in range(stride): + a, b, c = row[x - bpp] if x >= bpp else 0, prev[x], prev[x - bpp] if x >= bpp else 0 + if method == 1: + row[x] = (row[x] + a) & 255 + elif method == 2: + row[x] = (row[x] + b) & 255 + elif method == 3: + row[x] = (row[x] + (a + b) // 2) & 255 + elif method == 4: + p = a + b - c + distances = (abs(p - a), abs(p - b), abs(p - c)) + row[x] = (row[x] + (a, b, c)[distances.index(min(distances))]) & 255 + for x in range(w): + if depth < 8: + value = (row[x * depth // 8] >> (8 - depth - x * depth % 8)) & ((1 << depth) - 1) + values = [value] + else: + values = row[x * channels:(x + 1) * channels] + if color == 3: + i = values[0] + if i * 3 + 3 > len(palette): + raise ValueError('invalid PNG palette') + rgba = palette[i * 3:i * 3 + 3] + bytes([alpha[i] if i < len(alpha) else 255]) + elif color in (0, 4): + gray = values[0] * 255 // ((1 << depth) - 1) + opacity = values[1] if color == 4 else (0 if alpha == struct.pack('>H', values[0]) else 255) + rgba = bytes([gray, gray, gray, opacity]) + else: + opacity = values[3] if color == 6 else (0 if alpha == struct.pack('>HHH', *values) else 255) + rgba = bytes(values[:3]) + bytes([opacity]) + pixels.extend(rgba) + prev = row + return w, h, pixels + + +def image_type(data): + if not isinstance(data, bytes) or len(data) > MAX_IMAGE: + raise ValueError('artwork must be image bytes or an HTTP(S) URL, at most 12 MiB') + if data.startswith(PNG): + try: + decode(data) + except (zlib.error, struct.error) as e: + raise ValueError('invalid PNG image') from e + return 'png' + if data.startswith(b'\xff\xd8') and data.endswith(b'\xff\xd9'): + # Check JPEG SOF dimensions without depending on an image library. + pos = 2 + while pos + 4 <= len(data) and data[pos] == 255: + marker = data[pos + 1] + pos += 2 + if marker == 255: + pos -= 1 + continue + size = struct.unpack_from('>H', data, pos)[0] + if size < 2 or pos + size > len(data): + break + if marker in (0xc0, 0xc1, 0xc2) and size >= 8: + h, w = struct.unpack_from('>HH', data, pos + 3) + if w and h and w * h <= MAX_PIXELS and max(w, h) <= 8192: + return 'jpg' + break + pos += size + raise ValueError('artwork must be a supported PNG or JPEG') + + +def rectangle(pixels, width, x, y, w, h, color): + row = bytes(color) * w + for yy in range(y, y + h): + start = (yy * width + x) * 4 + pixels[start:start + len(row)] = row + + +def lettering(pixels, width, text, y, scale, color): + x = (width - (len(text) * 6 - 1) * scale) // 2 + for ch in text: + glyph = bytes.fromhex(GLYPHS.get(ch, GLYPHS['?'])) + for yy, bits in enumerate(glyph): + for xx in range(5): + if bits & (1 << (4 - xx)): + rectangle(pixels, width, x + xx * scale, y + yy * scale, scale, scale, color) + x += 6 * scale + + +def stamp(pixels, width, icon, x, y, size): + iw, ih, source = icon + dw, dh = max(1, size * iw // max(iw, ih)), max(1, size * ih // max(iw, ih)) + x, y = x + (size - dw) // 2, y + (size - dh) // 2 + for yy in range(dh): + for xx in range(dw): + src = ((yy * ih // dh) * iw + xx * iw // dw) * 4 + dst = ((y + yy) * width + x + xx) * 4 + a = source[src + 3] + for c in range(3): + pixels[dst + c] = (source[src + c] * a + pixels[dst + c] * (255 - a)) // 255 + pixels[dst + 3] = a + pixels[dst + 3] * (255 - a) // 255 + + +def fallback(label, icon_png=None): + text = unicodedata.normalize('NFKD', label).encode('ascii', 'ignore').decode().upper().strip() or 'ANDROID APP' + text = ' '.join(text.split())[:80] + seed = hashlib.sha256(label.encode()).digest() + accent = tuple(100 + c % 110 for c in seed[:3]) + (255,) + try: + icon = decode(icon_png) + except (ValueError, zlib.error, struct.error): + icon = None + out = {} + for slot, (w, h) in SLOTS.items(): + pixels = bytearray(w * h * 4) + if slot != 'logo': + for y in range(h): + color = (14 + 12 * y // h, 22 + 16 * y // h, 34 + 22 * y // h, 255) + rectangle(pixels, w, 0, y, w, 1, color) + rectangle(pixels, w, w // 12, h - max(6, h // 70), w * 5 // 6, max(3, h // 180), accent) + size = {'grid': 320, 'wide': 190, 'hero': 640, 'logo': 0, 'icon': 192}[slot] + if size: + x, y = (w - size) // 2, {'grid': 160, 'wide': 38, 'hero': 200, 'icon': 32}[slot] + if icon: + stamp(pixels, w, icon, x, y, size) + else: + scale = size // 9 + lettering(pixels, w, text[0], y + (size - scale * 7) // 2, scale, accent) + if slot != 'icon': + scale = {'grid': 5, 'wide': 5, 'hero': 12, 'logo': 10}[slot] + limit = (w - w // 6) // (scale * 6) + lines = textwrap.wrap(text, limit)[:3 if slot in ('grid', 'logo') else 2] + if len(textwrap.wrap(text, limit)) > len(lines): + lines[-1] = lines[-1][:-3] + '...' + y = {'grid': 580, 'wide': 280, 'hero': 930, 'logo': (h - len(lines) * scale * 9) // 2}[slot] + for line in lines: + lettering(pixels, w, line, y, scale, (240, 245, 250, 255)) + y += scale * 9 + out[slot] = ('png', png(w, h, pixels)) + return out + + +def prepare(label, icon_png=None, artwork=None): + artwork = artwork or {} + if not isinstance(artwork, dict) or set(artwork) - set(SLOTS): + raise ValueError('artwork slots are grid, wide, hero, logo and icon') + supplied = {} + for slot, value in artwork.items(): + if isinstance(value, str): + if urllib.parse.urlsplit(value).scheme not in ('http', 'https'): + raise ValueError('artwork URLs must use HTTP(S)') + request = urllib.request.Request(value, headers={'User-Agent': 'FrameControl/1.0'}) + with urllib.request.urlopen(request, timeout=20) as response: + if urllib.parse.urlsplit(response.geturl()).scheme not in ('http', 'https'): + raise ValueError('artwork redirect must use HTTP(S)') + value = response.read(MAX_IMAGE + 1) + supplied[slot] = (image_type(value), value) + result = fallback(label, supplied.get('icon', ('', icon_png))[1]) + result.update(supplied) + return result From f695f398dea397e5ae59834dad798e254e4697cd Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:20:27 +1000 Subject: [PATCH 2/2] Render complete Steam artwork for every sideload and fix VR shortcut identity Add optional SteamGridDB settings, source-first fallbacks rendered with Steam canvas, backfill commands and shared APK/native library details. Verify live artwork and Open Saber Steam Play/Stop; document SuperTux's clipboard crash. Co-Authored-By: GPT-6 Astra --- .claude/NOTES-vr-library.md | 215 ++++++++++-------- app/package.json | 6 +- docs/vr-apks.md | 125 ++++++---- frame/android/lepton-app.sh | 7 + frame/android/library_artwork.js | 144 ++++++++++++ frame/android/steam_shortcuts.py | 91 ++++++-- .../usr/local/lib/fakeframe/canvas_stub.js | 32 +++ .../usr/local/lib/fakeframe/cef_shim.js | 3 +- tests/fixtures/library/check-renderer.js | 16 ++ tests/test_frame_android_library.py | 62 ++--- tests/test_library_entrypoints.py | 208 +++++++++++++++++ ui/artwork-settings.js | 31 +++ ui/frame_android.py | 136 +++++++++-- ui/frame_artwork.py | 124 ++++------ ui/frame_steamgriddb.py | 103 +++++++++ ui/frame_titles.py | 55 ++++- ui/index.html | 14 ++ ui/server.py | 14 +- 18 files changed, 1097 insertions(+), 289 deletions(-) create mode 100644 frame/android/library_artwork.js create mode 100644 tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub.js create mode 100644 tests/fixtures/library/check-renderer.js create mode 100644 tests/test_library_entrypoints.py create mode 100644 ui/artwork-settings.js create mode 100644 ui/frame_steamgriddb.py diff --git a/.claude/NOTES-vr-library.md b/.claude/NOTES-vr-library.md index 9ebc857..655ce7c 100644 --- a/.claude/NOTES-vr-library.md +++ b/.claude/NOTES-vr-library.md @@ -1,105 +1,126 @@ # vr-library implementation notes -- Scope: `/tmp/vrapk/p1.md`; work only in steam-frame-vrlib, no delegation, - no push/PR/issues. Parent handles independent review. -- Read source interface, APK/VR docs, catalogue docs and installer/catalogue, - Frame skill and device architecture docs. -- Registry validation passed; no Steam Frame capability in rendered entries. - Prior-work skill absent at both skill roots. Existing worktree explicitly - authorized by task; no portfolio registration or out-of-worktree writes. -- Device preflight: SSH alias timed out; frame.local does not resolve. No - sessions started or stopped; no settings changed. Device evidence pending. -- Current launcher execs setsid --wait, replacing the wrapper and providing - no TERM/INT/HUP cleanup for its podman container. Need testable supervisor. -- Artwork: installer currently copies only APK icon; shortcut updates do not - refresh existing name/icon. Plan stdlib PNG fallbacks and Steam grid files. +Scope: `/tmp/vrapk/p1.md` plus parent updates mandating all-entrypoint artwork, +SteamGridDB, designed fallback art, settings/backfill and two visual iterations. +Worktree `/Users/saphid/projects/steam-frame-vrlib`, branch `vr-library`. +No delegation, push, PR or issue edits. Parent owns independent review and integration. -## Implementation and evidence +## Implementation -- Added `ui/frame_artwork.py`: bounded non-interlaced PNG decode/composition, - five fallback slots, transparent logo, supplied PNG/JPEG bytes or HTTP(S) - URLs. Standard library only; ASCII lettering, original label in native UI. -- Small additive `ui/frame_apk.py` fallback finds `assets/icon.png` in Open - Saber Plus (its manifest/adaptive icon yielded no PNG before). SuperTux's - normal APK icon works. Generated all slots from both supplied APKs and - visually inspected the individual grids plus contact sheet. -- `install(..., artwork=None)` forwards artwork through patching, stages it, - refreshes existing shortcuts instead of duplicating them, marks immersive - shortcuts VR, and stores presentation warnings in meta.json. Source images - retain their dimensions; generated slots use the requested Steam sizes. -- Frame helper uses native SetCustomArtworkForApp/ClearCustomArtworkForApp. - Collections use collectionStore: create static Android/Android VR, preserve - other members and dynamic/read-only names, remove obsolete membership. -- Remove clears artwork/managed memberships before app-folder deletion. A - Steam cleanup failure leaves local metadata for a retry. Failed new install - attempts to remove a newly created shortcut. -- Root-cause feedback loop: original launcher failed both cleanup tests - (no `podman stop` on TERM or normal exit). It exec'd setsid without a signal - handler. New supervising shell stays in Steam's tracked tree, handles - TERM/INT/HUP, stops its container, terminates its child process group, and - keeps the Lepton exit code. Lock + pre-existing container check prevent a - second launch from stopping someone else's session. Data paths unchanged. -- Stop helper calls TerminateApp with the exact 64-bit game-ID string (not - the 32-bit app ID, and not a lossy JavaScript number). Frame Control also - directly stops the container if Steam is unavailable. -- API signatures/collection semantics read from public source, not guessed: - [Steam client types](https://github.com/SteamDeckHomebrew/decky-frontend-lib/blob/main/src/globals/steam-client/App.ts) - and [Steam UI](https://github.com/SteamDatabase/SteamTracking/blob/master/ClientExtracted/steamui/chunk~2dcc5aaf7.js). - The fakeframe CEF/podman fixtures were extended accordingly; their new - library methods are explicitly source-derived, not recorded device calls. +- Initial commit `c06b328`: shared artwork, collection handling, launcher + supervision, APK icon fallback and tests. Follow-up replaces its bitmap + artwork implementation with Steam Chromium canvas + Motiva Sans. +- `ui/frame_steamgriddb.py`: optional API provider, exact title (or trailing VR) + match, highest-scored returned static/non-NSFW artwork, separate portrait + and wide requests. No key means no requests/warnings. Saved settings are + atomic 0600 on POSIX; API never returns the key; auth redirects disabled. +- Precedence: provider, source slots/banner/feature graphic/screenshots, APK + icon/generated art. All five outputs have fixed Steam sizes. Host Python + stays stdlib-only and Python 3.9 compatible; Frame renders consistently + regardless of host OS. Package filters include both new JS resources. +- `frame/android/library_artwork.js`: dominant-colour gradient, blurred icon + backdrop, large icon/shadow, real font, no title in hero, transparent logo. + Removes only opaque near-black matte connected to icon corners. Native + icons remain original; opaque foreground app tiles have rounded corners. +- `apply_library` is mandatory for APK and native-title installs. CLI, upload, + catalogue, versions, web install and source install seam share it. Native + devkit executable/runtime is preserved. Failed initial art application + removes a newly created shortcut; no success with incomplete art. +- Refresh CLI/API/settings button repairs installed Android entries without + reinstalling/stopping, uses cached source art, queries SGDB again if enabled, + repairs missing shortcuts, and reports batch errors independently. +- Details: name/icon/VR flag, sort-as, Android/Android VR collections or native + Sideloaded, Installation details note preserving other notes. No supported + arbitrary description/store-page/developer/achievement metadata found. + Notes are keyed by sanitized name (Steam limitation: equal-name collisions). +- Device discovery: custom-art type 4 is broken on this Steam client: it logs + Unknown asset type and overwrites wide art with the icon. Use types 0–3 and + SetShortcutIcon separately. Confirmed actual cache after correction. +- Launcher retains its supervising shell under Steam, traps TERM/INT/HUP, + stops only its own container, kills its child group, preserves exit status. + Lock/pre-existing container guards prevent duplicate session cleanup. +- VR attribution discovery: Lepton uses SteamAppId for both stable context and + Android SteamVR identity. New shortcut.id + LEPTON_ENV_SteamAppId separates + them using Lepton's existing passthrough; container/data paths unchanged. + On-device source mounting.sh applies LEPTON_ENV_* after its regular setenv. -## Verification (2026-09-28) +## Visual critique and iterations -- `python3 -m unittest discover -s tests -p test_frame_android_library.py`: - initial two launcher cases FAILED on the old implementation; the focused - suite passed after the changes (20 cases at that run, then one JPEG case - added and covered by the final whole-suite run). -- `python3 -m unittest discover -s tests`: **187 tests, OK**, exit 0, - 9.948 seconds. Actual local runtime is **Python 3.9.6** (`python3 --version`), - not just a syntax-compatibility check. Log: `/tmp/vrlib-evidence/tests.log`. -- `bash -n frame/android/lepton-app.sh`, `node --check - tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js`, Python AST parse - with `feature_version=(3,9)`, and `git diff --check`: exit 0. -- Generated self-contained Linux harness using the actual launcher and the - fixture Lepton/podman, retaining the Frame's real setsid/flock: - `ssh -o BatchMode=yes -o ConnectTimeout=8 frame 'python3 - 2>&1' < - /tmp/vrlib-evidence/linux-launcher-check.py` — **3 tests, OK**, exit 0; - normal exit + TERM + INT/HUP subcases, preserved saved-data sentinel. - It uses a TemporaryDirectory; no installed game or global setting touched. - Log: `/tmp/vrlib-evidence/linux-launcher-tests.log`. -- `ssh -o BatchMode=yes -o ConnectTimeout=8 frame python3 - < - ui/frame_vrshot.py`: exit 0; returned `/tmp/frame-vrcap/shot-28947-vr.png`. - Copied to `/tmp/vrlib-evidence/headset-preflight.png`, then removed only the - temporary remote screenshot. The capture shows Steam's startup error. -- Source APKs were read from `/tmp/vrapk/opensaberplus.apk` and - `/tmp/vrapk/supertux.apk`. Ten generated assets plus - `/tmp/vrlib-evidence/artwork-preview.png` are local artwork previews, - **not Steam UI screenshots**. +Evidence `/tmp/vrlib-evidence/design-v1`, `design-v2`, `design-v3` (15 PNGs each). +Open Saber Plus/SuperTux icons came from authorized APKs. AntennaPod is a +preview only using the official F-Droid icon; it was not installed/launched. -## Device blocker and open questions +1. v1: real typography/gradient already improves over old bitmap output, but + Open Saber has a black square matte, and SuperTux palette looks muddy. +2. v2: removed connected black matte; rounded the opaque AntennaPod foreground. + Inspected all three posters. SuperTux still muted; title line balance weak. +3. v3: lifted sampled saturation and balanced two-line labels. Inspected all + three posters plus final all-slot sheet. Large recognisable artwork, readable + typography, richer colours, textless hero and transparent title logo. Icon + source resolution still limits detail; source/SGDB art remains preferable. -- SSH initially timed out; later reachable on BUILD_ID 20260925.6191901, - SteamVR 2.18.1. `podman ps` was empty and vrserver showed no current game - scene. Steam CDP 8080 refused connections; no steamwebhelper was running. - The updater repeatedly extracted/installed, and the headset displayed - "There was an issue launching Steam". Bounded retries remained unavailable; - console_log had no new game launch entries. Captures/logs live under - `/tmp/vrlib-evidence/` (`device-preflight.log`, `device-final.log`). -- No real APK was reinstalled/launched/stopped. Therefore actual Steam Play - and Stop/reaper tracking, app data across a real stop/relaunch, native - library artwork display, collection persistence, VR scene attribution and - automatic dashboard dismissal are **not verified**. No Steam library - screenshot was possible. Next: restore Steam, reinstall the two APKs, - test one brief Steam launch/Stop at a time, capture library/headset output, - and check podman + console_log after each transition. -- VR shortcuts are marked through SetShortcutIsVR. Source inspection shows - Steam Resume hides the dashboard only when scene-app ID matches the - shortcut; whether Lepton gets that attribution remains open. No automatic - dashboard hiding or permanent standby workaround added. Power settings - were never changed, so no restoration was needed. -- Full fakeframe container E2E suite was not run; its updated JS fixture was - exercised by the local Node/V8 test. Native Windows install path untested. -- No independent reviewer process launched: the explicit brief forbids - delegation and assigns review/integration to the parent. No review claimed. -- No registry observation write outside this worktree: brief explicitly - restricts work to this checkout (plus its requested evidence directory). +Final sheet `/tmp/vrlib-evidence/artwork-preview-final.png` is an artwork +preview, NOT a Steam UI screenshot. Rows: portrait, wide, hero, logo/icon. + +## Device verification (2026-09-28, build 20260925.6191901, SteamVR 2.18.1) + +Steam was initially unavailable (old notes/evidence), then recovered. Both +APKs were reinstalled and refreshed successfully with zero library warnings: + +- Open Saber Plus: org.godotengine.open_saber_plus, instance 2802929330, + shortcut 3346865537, game ID 14374678025558032384. +- SuperTux: org.supertux.supertux2, instance 2811892472, + shortcut 2883168793, game ID 12383115674816348160. + +`steam-cache-final.log`: both actual Steam cache sets are 600x900, 920x430, +3840x1240, 1280x480, with app icon 256x256. `steam-details-targets.json`: +both correct names/sort-as, Android + Android VR, existing Played preserved. +Native managed note readback result 1 (success), saved as steam-notes-final.json. + +Open Saber first session: Steam tracked it for 32 seconds; process and +container remained alive until only SteamClient TerminateApp was called. +After identity fix, a second session remained alive at 22 seconds (Android +PID 992), SteamVR identified steam.app.3346865537, and screenshot shows the +actual game scene instead of blank Resume tile. Steam Stop removed tracked +process and container within ~5 seconds. No direct podman-stop fallback was +used for this verification. Same data paths; existing game play count shown +in capture, but no separate save-file sentinel added to the real game. +Evidence: opensaber-identity-session.log, opensaber-identity-headset.png, +opensaber-running.log, opensaber-steam-stop.log, opensaber-headset-running.png. + +SuperTux: Steam Play tracked the wrapper for ~17 seconds. SDLClipboardHandler +crashes with NullPointerException on missing ClipboardManager during activity +creation. Lepton and supervisor then cleaned up; it never reached a VR scene. +Steam Stop was issued after the crash, so sustained SuperTux Stop is NOT proven. +Evidence: supertux-running.log, supertux-lepton.log, supertux-shot.json. + +Other test activity appeared on the device during checks (other Android +container and Gravitas). Neither was stopped or modified. Final Open Saber +preflight had no Android containers; other desktop overlays later appeared +in its headset capture. No global VR standby/dashboard settings changed. +Direct Steam UI Page.captureScreenshot timed out; no library UI screenshot. + +## Automated verification and limits + +- Whole suite: `python3 -m unittest discover -s tests`, Python 3.9.6. Final + result: **206 tests OK, 10.294 seconds, exit 0**. Log: + `/tmp/vrlib-evidence/tests-final.log`. +- Offline entrypoint tests exercise real shared install/render/configure flow + with SSH/API mocked: CLI, upload, catalogue, versions, web download, source + seam, native title and refresh. Provider ranking/failure/settings tests. +- Node contract test runs actual renderer against explicitly synthetic canvas, + validates all PNG dimensions and hero/logo text placement. This canvas is + also used by fakeframe; transparent fixture images are not visual evidence. +- Launcher tests assert stable context plus shortcut passthrough, signals, + normal exit, duplicate guard and saved-data sentinel. Earlier real Linux + setsid/flock fixture run: 3 tests OK (linux-launcher-tests.log). +- `bash -n`, Node syntax checks, Python AST feature_version=(3,9), diff checks. +- Docker E2E not run: `docker info --format '{{.ServerVersion}}'` exits 1; + daemon socket /var/run/docker.sock does not exist. +- Authenticated SGDB lookup/download unverified (no key configured). +- Windows/Linux packaged binaries not built/launched; filters tested. +- Native devkit art uses shared tested seam; no additional native title was + installed on device. Sibling source-search endpoint not in this worktree: + only its public installer contract is tested. +- Independent review not spawned: explicit brief forbids delegation and assigns + parent review/integration. No other provider/model participation claimed. diff --git a/app/package.json b/app/package.json index c9b80b3..abed31a 100644 --- a/app/package.json +++ b/app/package.json @@ -46,7 +46,8 @@ "to": "ui", "filter": [ "*.py", - "*.html" + "*.html", + "*.js" ] }, { @@ -61,7 +62,8 @@ "to": "frame/android", "filter": [ "*.sh", - "*.py" + "*.py", + "*.js" ] }, { diff --git a/docs/vr-apks.md b/docs/vr-apks.md index 7504450..ed0b747 100644 --- a/docs/vr-apks.md +++ b/docs/vr-apks.md @@ -86,50 +86,97 @@ Khronos-style loader and found SteamVR through `/vendor`. ## In the Steam library -**Implemented; Steam UI behavior still inferred (2026-09-28).** Installs and -updates refresh the same shortcut's name, VR flag and five artwork slots: -600×900 grid, 920×430 wide capsule, 3840×1240 hero, transparent 1280×480 logo -and 256×256 icon. Stdlib PNG fallbacks use the APK icon and label (including -Godot's `assets/icon.png`); unsupported/missing icons get a letter tile. -Generated lettering uses ASCII; Steam's native title retains the full label. +Every successful APK install goes through the same mandatory artwork writer: +CLI (including `scripts/install-apk.sh`), upload, catalogue, version finder, +web download and source modules calling `frame_android.install`. Native +Linux/Windows sideloads also use it, preserving their devkit runtime wiring. +A new shortcut is rolled back if artwork fails; failure is never reported as +an installed app with a blank tile. -Sources can pass `install(apk_path, artwork={'hero': image_bytes_or_http_url})`. -Slots are `grid`, `wide`, `hero`, `logo`, `icon`; supplied PNG/JPEG images keep -their dimensions. Limits: 12 MiB and 8 million pixels per image. The PNG -reader accepts non-interlaced 1/2/4-bit palette/grayscale and 8-bit -RGB/RGBA/grayscale. PNG icons also seed missing slots. Images are staged -inside the app directory, then applied through Steam's custom-artwork API. +Artwork preference is **SteamGridDB → source images → generated fallback**. +Set the optional free key in Frame Control's **Library artwork settings**, or +`STEAMGRIDDB_API_KEY` (`FRAME_STEAMGRIDDB_API_KEY` also works). Environment +settings override the saved key. Without a key there are no provider calls or +warnings. Saved keys stay in host app data, mode 0600 on POSIX, and are never +returned by the settings API or copied to the headset. Exact title matches +(including a trailing “VR” variant) use the highest-scored returned static, +non-NSFW image in each slot. Provider failures use the next source. -Immersive installs join **Android** and **Android VR** and get -`SetShortcutIsVR`; `--flat` installs join **Android** only. Existing dynamic -or read-only collections are preserved. Unsupported APIs/collection conflicts -appear in `library_warnings`. Removal clears all five custom slots and managed -memberships before deleting the shortcut and folder; `--keep-data` retains -app data. Failed Steam cleanup leaves metadata for a retry. +Sources pass `install(apk_path, artwork={...})`: keys are `grid`, `wide`, +`hero`, `logo`, `icon`, `banner`, `feature_graphic`, `screenshot`, or a list +`screenshots`. Values are PNG/JPEG bytes or HTTP(S) URLs (12 MiB/8 million +pixels maximum). Banners and feature graphics supply hero/wide art; +screenshots are the next fallback. Source images are cached for refresh. +All images are fitted to 600×900 portrait, 920×430 wide, 3840×1240 hero, +1280×480 logo and 256×256 icon. Explicit logos retain transparency. -The launcher stays alive around Lepton's separate session and handles -TERM/INT/HUP by stopping its own container and child process group. Normal -exit also cleans up. A lock and container check refuse duplicate launches. -The Stop helper uses `SteamClient.Apps.TerminateApp` with the exact 64-bit -game ID string, matching Steam's Stop action; Frame Control also falls back -to a direct container stop. Persistent data remains under the same instance ID. +Generated art uses the APK icon, a dominant-colour gradient, a blurred +backdrop and large foreground icon with shadow. Steam's Chromium canvas and +Motiva Sans render real text consistently regardless of the host OS; no +Pillow, host font installation or bitmap font is needed. The hero has no +title; the generated logo is a transparent title. APKs with no usable icon +get a typographic monogram. The desktop package includes the renderer. -**Verified:** offline artwork, mocked SSH and V8 API tests; signal and -normal-exit tests on the Frame with real Linux `setsid`/`flock` and fake -Lepton/podman. **Not verified:** actual Steam Play/Stop, library rendering, -collections and direct-to-scene launch. On build `20260925.6191901`, the -headset showed “There was an issue launching Steam”, CDP port 8080 refused -connections and the updater was stuck. Capture: -`/tmp/vrlib-evidence/headset-preflight.png` on the development Mac. No installed -apps or global VR settings were changed during verification. +Backfill installed Android apps without reinstalling or stopping them: -The VR flag does not override wear detection. Steam's Resume action hides -the dashboard when its scene-app ID matches the shortcut (inferred from -[Steam's UI source](https://github.com/SteamDatabase/SteamTracking/blob/master/ClientExtracted/steamui/chunk~2dcc5aaf7.js)). -Whether Lepton gets that association still needs a headset test. No forced -hide or power override is installed. If using temporary standby settings -for unattended testing, restore `power.pauseCompositorOnStandby 1` and -`power.turnOffScreensTimeout 5`; see [the device notes](how-the-frame-works.md). +```sh +python3 ui/frame_android.py refresh-art org.godotengine.open_saber_plus +python3 ui/frame_android.py refresh-art --all +``` + +The settings panel offers the same refresh-all action. The API is +`POST /api/android` with `{"action":"refresh-art","all":true}` or a +`package` instead of `all`; it returns a background job. Batch results retain +per-app errors, and the CLI exits nonzero if any failed. + +**Verified on build 20260925.6191901, SteamVR 2.18.1 (2026-09-28):** both +Open Saber Plus and SuperTux were backfilled. Steam's cached portrait, wide, +hero and logo PNGs have the dimensions above; each shortcut points at its +256×256 icon. This Frame client mishandles custom-art type 4 (documented as +Icon), overwriting the wide capsule; the implementation uses custom types +0–3 and **SetShortcutIcon** separately. + +Steam accepts display name, executable/start directory, icon, VR flag and +sort-as name. Android apps join **Android**, immersive apps also **Android +VR**; native sideloads join **Sideloaded**. Existing collection members and +unrelated collections are preserved (both games retained **Played**). +Dynamic/read-only collection conflicts produce warnings. The native notes +API supports a managed **Installation details** note (package, version and +source) while preserving other notes. Notes are keyed by sanitized shortcut +name, so Steam itself cannot distinguish equal-name shortcut notes. No +supported shortcut description/store-page, developer/publisher, release +metadata or custom achievement API was found; these are not fabricated. + +The launcher supervises Lepton and handles TERM/INT/HUP and normal exit by +stopping its own container and child process group. A lock and container check +refuse duplicate launches. Steam Stop uses `TerminateApp` with the exact +64-bit game ID string. Frame Control's Stop additionally has a direct-container +fallback. The stable instance ID and compatdata paths remain unchanged. + +Lepton normally forwards the instance `SteamAppId` to Android, causing +SteamVR to associate the scene with a different, artwork-less app. The +launcher uses Lepton's supported `LEPTON_ENV_SteamAppId` passthrough to send +the actual shortcut ID to Android while retaining the stable container ID. +**Verified:** Open Saber was alive 22 seconds after Steam Play, SteamVR +identified `steam.app.3346865537`, and its scene appeared in the headset +capture without the previous blank Resume tile. Steam Stop then removed its +tracked process and stopped the container. An earlier 32-second session was +also tracked until Steam Stop. No global standby or dashboard overrides were +installed; wear detection and other user-opened overlays still apply. + +**SuperTux limitation:** Steam launched and tracked it, but SDL crashed during +activity creation because Lepton lacks `ClipboardManager`. Its container +cleaned up on exit after about 17 seconds. Consequently sustained SuperTux +Play/Stop and its VR scene could not be verified. This is an APK/runtime +compatibility failure, separate from library presentation. + +Evidence is under `/tmp/vrlib-evidence/` on the development Mac: final artwork +preview and three design passes, `steam-cache-final.log`, +`steam-details-targets.json`, `opensaber-identity-session.log`, +`opensaber-identity-headset.png`, and `supertux-lepton.log`. The preview is +rendered artwork, not a Steam UI screenshot; CDP screenshot capture timed +out. Authenticated SteamGridDB, Windows/Linux packaged builds and the sibling +source-search endpoint remain unverified (the public install seam is tested). ## Out of scope diff --git a/frame/android/lepton-app.sh b/frame/android/lepton-app.sh index e903e57..119850b 100644 --- a/frame/android/lepton-app.sh +++ b/frame/android/lepton-app.sh @@ -20,6 +20,13 @@ done # A number that isn't a real Steam app; it names this app's Lepton context. export SteamAppId="$(cat "$DIR/instance.id")" [[ "$SteamAppId" =~ ^[0-9]+$ ]] || { echo "invalid instance.id" >&2; exit 1; } +# Keep the stable Lepton context, but identify the Android VR client as its +# actual Steam shortcut. Lepton applies LEPTON_ENV_* after its own passthrough. +if [[ -f "$DIR/shortcut.id" ]]; then + shortcut="$(cat "$DIR/shortcut.id")" + [[ "$shortcut" =~ ^[0-9]+$ ]] || { echo "invalid shortcut.id" >&2; exit 1; } + export LEPTON_ENV_SteamAppId="$shortcut" +fi exec 9>"$DIR/launch.lock" flock -n 9 || { echo "Android app is already running" >&2; exit 1; } CONTAINER="lepton-steamlaunch-$SteamAppId" diff --git a/frame/android/library_artwork.js b/frame/android/library_artwork.js new file mode 100644 index 0000000..9680dc5 --- /dev/null +++ b/frame/android/library_artwork.js @@ -0,0 +1,144 @@ +// Runs in Steam's Chromium context: identical fonts/rendering from every host OS. +async function renderLibraryArtwork(input) { + const sizes = {grid:[600,900], wide:[920,430], hero:[3840,1240], logo:[1280,480], icon:[256,256]}; + const label = String(input.label || 'Untitled').trim().slice(0,180); + const font = '"Motiva Sans", "Noto Sans", Arial, sans-serif'; + await document.fonts.load(`800 120px ${font}`, label); + const images = {}, warnings = []; + for (const [slot, item] of Object.entries(input.images || {})) { + try { + const img = new Image(); + img.src = `data:image/${item[0]};base64,${item[1]}`; + await img.decode(); + if (!img.width || !img.height || img.width*img.height > 8000000) throw Error('dimensions'); + images[slot] = img; + } catch (_) { warnings.push(`${slot} could not be decoded; generated art used`); } + } + let icon = images.icon; + if (icon) { + // Remove only a near-black matte connected to the outside of an opaque icon. + const cut=document.createElement('canvas');cut.width=icon.width;cut.height=icon.height; + const c=cut.getContext('2d');c.drawImage(icon,0,0); + const pixels=c.getImageData(0,0,cut.width,cut.height), d=pixels.data, w=cut.width,h=cut.height; + const corners=[0,w-1,(h-1)*w,h*w-1]; + if(corners.every(i=>d[i*4+3]>250 && Math.max(d[i*4],d[i*4+1],d[i*4+2])<24)) { + const seen=new Uint8Array(w*h), queue=corners.slice(); + for(let q=0;q24)continue; + d[i*4+3]=0; + if(i%w)queue.push(i-1);if(i%w=w)queue.push(i-w);if(i220 || hi-lo<25) continue; + const key=rgb.map(v=>Math.round(v/32)*32).join(','); + bins.set(key,(bins.get(key)||0)+1); + } + const ranked=[...bins].sort((a,b)=>b[1]-a[1]); + if (ranked.length) { + colors[0]=ranked[0][0].split(',').map(Number); + colors[1]=(ranked.find(([key])=>key.split(',').reduce((n,v,i)=>n+Math.abs(Number(v)-colors[0][i]),0)>170)||ranked[0])[0].split(',').map(Number); + } + } + // Preserve hue while lifting muted icon colors into a richer background palette. + colors=colors.map(c=>{const low=Math.min(...c),range=Math.max(...c)-low||1; + return c.map(v=>45+(v-low)/range*165);}); + const rgb=(c,a=1)=>`rgba(${c.map(v=>Math.min(255,Math.round(v))).join(',')},${a})`; + function image(ctx,img,x,y,w,h,cover=false) { + const scale=cover?Math.max(w/img.width,h/img.height):Math.min(w/img.width,h/img.height); + const dw=img.width*scale,dh=img.height*scale; + ctx.save(); ctx.beginPath(); ctx.rect(x,y,w,h); ctx.clip(); + ctx.drawImage(img,x+(w-dw)/2,y+(h-dh)/2,dw,dh); ctx.restore(); + } + function title(ctx,w,h,top,bottom,maxSize) { + let lines=[],size=maxSize; + const maxWidth=w*.84; + for (;size>=18;size-=2) { + ctx.font=`800 ${size}px ${font}`; + lines=[]; let line=''; + for (const word of label.split(/\s+/)) { + const next=line?line+' '+word:word; + if (line && ctx.measureText(next).width>maxWidth) {lines.push(line);line=word;} else line=next; + } + lines.push(line); + if (lines.length*size*1.08<=bottom-top && lines.every(l=>ctx.measureText(l).width<=maxWidth)) break; + } + if(lines.length===2) { + const words=lines[0].split(' '); + if(words.length>1) { + const first=words.slice(0,-1).join(' '), second=words.slice(-1)[0]+' '+lines[1]; + if(ctx.measureText(second).width<=maxWidth && + Math.abs(ctx.measureText(first).width-ctx.measureText(second).width)< + Math.abs(ctx.measureText(lines[0]).width-ctx.measureText(lines[1]).width)) lines=[first,second]; + } + } + // A long unbroken label is still fitted, including scripts without spaces. + ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillStyle='#fff'; + ctx.shadowColor='rgba(0,0,0,.45)'; ctx.shadowBlur=size*.28; ctx.shadowOffsetY=size*.06; + let y=top+(bottom-top-lines.length*size*1.08)/2+size*.54; + for (const line of lines) {ctx.fillText(line,w/2,y,maxWidth); y+=size*1.08;} + ctx.shadowBlur=0; ctx.shadowOffsetY=0; + } + const result={}; + for (const [slot,[w,h]] of Object.entries(sizes)) { + const canvas=document.createElement('canvas'); canvas.width=w; canvas.height=h; + const ctx=canvas.getContext('2d'); ctx.imageSmoothingQuality='high'; + const direct=images[slot]; + const feature=images.feature_graphic||images.banner; + const scene=direct || ((slot==='hero'||slot==='wide') && (feature||images.screenshot)); + if (scene) { + if (slot==='logo'||slot==='icon') image(ctx,scene,0,0,w,h); + else image(ctx,scene,0,0,w,h,true); + } else if (slot==='logo') { + title(ctx,w,h,h*.08,h*.92,150); + } else { + const gradient=ctx.createLinearGradient(0,0,w,h); + gradient.addColorStop(0,rgb(colors[0].map(v=>v*.68))); + gradient.addColorStop(.6,rgb(colors[1].map(v=>v*.32))); + gradient.addColorStop(1,'#080c16'); ctx.fillStyle=gradient;ctx.fillRect(0,0,w,h); + if (icon) { + ctx.save();ctx.globalAlpha=.16;ctx.filter=`blur(${Math.round(w*.055)}px) saturate(1.4)`; + image(ctx,icon,-w*.15,-h*.15,w*1.3,h*1.3,true);ctx.restore(); + } + const glow=ctx.createRadialGradient(w*.5,h*.32,0,w*.5,h*.32,w*.8); + glow.addColorStop(0,rgb(colors[0],.27));glow.addColorStop(1,rgb(colors[1],0)); + ctx.fillStyle=glow;ctx.fillRect(0,0,w,h); + const vignette=ctx.createLinearGradient(0,h*.25,0,h); + vignette.addColorStop(0,'rgba(0,0,0,0)');vignette.addColorStop(1,'rgba(0,0,0,.56)'); + ctx.fillStyle=vignette;ctx.fillRect(0,0,w,h); + const box=slot==='grid'?[w*.12,h*.14,w*.76,w*.76]: + slot==='wide'?[w*.36,h*.06,w*.28,h*.59]: + slot==='hero'?[w*.365,h*.12,w*.27,h*.78]:[w*.08,h*.08,w*.84,h*.84]; + if (icon) { + ctx.save();ctx.shadowColor='rgba(0,0,0,.65)';ctx.shadowBlur=Math.min(w,h)*.055; + ctx.shadowOffsetY=Math.min(w,h)*.022; + // Opaque square icons read as deliberate app tiles, not pasted rectangles. + const [x,y,bw,bh]=box, side=Math.min(bw,bh); + if(slot!=='hero' && icon===images.icon) { + ctx.beginPath();ctx.roundRect(x+(bw-side)/2,y+(bh-side)/2,side,side,side*.14);ctx.clip(); + } + image(ctx,icon,...box);ctx.restore(); + } else if (slot !== 'hero') { + // A typographic monogram when the APK contains no usable image. + ctx.font=`800 ${Math.min(w,h)*.48}px ${font}`;ctx.fillStyle='rgba(255,255,255,.94)'; + ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText([...label][0]||'A',w/2,h*.38); + } + if (slot==='grid') title(ctx,w,h,h*.7,h*.93,66); + if (slot==='wide') title(ctx,w,h,h*.69,h*.92,52); + // Hero intentionally has no title: Steam overlays the transparent logo. + } + result[slot]=canvas.toDataURL('image/png').split(',')[1]; + } + return {images:result,warnings,font}; +} diff --git a/frame/android/steam_shortcuts.py b/frame/android/steam_shortcuts.py index 15bf5f9..79fbb1f 100644 --- a/frame/android/steam_shortcuts.py +++ b/frame/android/steam_shortcuts.py @@ -9,7 +9,7 @@ Python stdlib only; the Mac runs it with `ssh frame python3 - < this`. steam_shortcuts.py stop APPID steam_shortcuts.py remove APPID """ -import base64, json, os, socket, struct, sys, urllib.request +import base64, json, os, re, socket, struct, sys, urllib.request DEVTOOLS = 'http://127.0.0.1:8080/json' @@ -89,8 +89,8 @@ def evaluate(js): ASSETS = {'grid': 0, 'hero': 1, 'logo': 2, 'wide': 3, 'icon': 4} -def collections_js(appid, vr=None): - wanted = [] if vr is None else ['Android', *(['Android VR'] if vr else [])] +def collections_js(appid, vr=None, category='Android'): + wanted = [] if vr is None else [category, *(['Android VR'] if vr and category == 'Android' else [])] return f'''async function syncCollections() {{ const wanted = {json.dumps(wanted)}; if (typeof collectionStore === "undefined" || @@ -100,7 +100,7 @@ def collections_js(appid, vr=None): return ["Steam collections API unavailable"]; const app = {{appid: {appid}}}; const warnings = []; - for (const name of ["Android", "Android VR"]) {{ + for (const name of ["Android", "Android VR", "Sideloaded"]) {{ const matches = collectionStore.GetUserCollectionsByName(name); let collection = matches.find(c => !c.bIsDynamic && c.bAllowsDragAndDrop); if (wanted.includes(name)) {{ @@ -123,7 +123,65 @@ def collections_js(appid, vr=None): }}''' -def configure(appid, name, exe, start_dir, icon, vr, artwork): + +def notes_js(name, details): + filename = 'notes_shortcut_' + re.sub(r'[!-/:-@ \[\\\]\^`]', '_', name.strip()) + content = '\n'.join(str(details[k]) for k in ('package', 'version', 'source') if details.get(k)) + return f'''if (SteamClient.GameNotes && typeof SteamClient.GameNotes.GetNotes === "function" && + typeof SteamClient.GameNotes.SaveNotes === "function") {{ + try {{ + const file = {json.dumps(filename)}; + const previous = await SteamClient.GameNotes.GetNotes(file, file + "_images/"); + if (previous.result !== 1 && previous.result !== 9) throw Error("read " + previous.result); + const data = previous.result === 1 ? JSON.parse(previous.notes) : {{notes: [], shortcut_name: {json.dumps(name)}}}; + if (!Array.isArray(data.notes)) throw Error("unexpected notes format"); + const id = "frame-control-library", now = Math.floor(Date.now()/1000); + const old = data.notes.find(n => n.id === id); + const note = {{id, shortcut_name: {json.dumps(name)}, title: "Installation details", + content: {json.dumps(content)}, ordinal: old ? old.ordinal : data.notes.length, + time_created: old ? old.time_created : now, time_modified: now}}; + data.notes = data.notes.filter(n => n.id !== id).concat([note]); + const result = await SteamClient.GameNotes.SaveNotes(file, JSON.stringify(data)); + if (result !== 1) throw Error("save " + result); + }} catch (e) {{ warnings.push("Steam notes: " + String(e)); }} + }}''' + + +def render(plan): + with open(plan) as f: + source = json.load(f) + images = {} + for slot, path in source['images'].items(): + ext = os.path.splitext(path)[1][1:] + with open(path, 'rb') as f: + data = f.read(12 * 1024 * 1024 + 1) + if len(data) > 12 * 1024 * 1024: + raise ValueError('source artwork too large') + images[slot] = [ext, base64.b64encode(data).decode()] + renderer = globals().get('ART_RENDERER') + if renderer is None: + with open(os.path.join(os.path.dirname(__file__), 'library_artwork.js')) as f: + renderer = f.read() + result = evaluate(renderer + '\nrenderLibraryArtwork(' + json.dumps({'label': source['label'], 'images': images}) + ')') + if not isinstance(result, dict) or set(result.get('images', {})) != set(ASSETS): + raise ValueError('incomplete artwork render') + paths = {} + for slot, encoded in result['images'].items(): + path = os.path.join(os.path.dirname(plan), slot + '.png') + data = base64.b64decode(encoded, validate=True) + if not data.startswith(b'\x89PNG\r\n\x1a\n') or len(data) > 12 * 1024 * 1024: + raise ValueError('invalid rendered image') + with open(path + '.tmp', 'wb') as f: + f.write(data) + os.replace(path + '.tmp', path) + paths[slot] = path + return {'paths': paths, 'warnings': result.get('warnings', [])} + + +def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None): + options = options or {} + if set(artwork) != set(ASSETS): + raise ValueError('all five Steam artwork slots are required') images = [] for slot, path in artwork.items(): if slot not in ASSETS: @@ -135,12 +193,15 @@ def configure(appid, name, exe, start_dir, icon, vr, artwork): data = f.read(12 * 1024 * 1024 + 1) if len(data) > 12 * 1024 * 1024: raise ValueError('artwork is too large') - images.append([ASSETS[slot], ext, base64.b64encode(data).decode()]) + if slot != 'icon': # Frame's custom-art API maps type 4 to Header; use SetShortcutIcon. + images.append([ASSETS[slot], ext, base64.b64encode(data).decode()]) return evaluate(f'''(async () => {{ const id = {int(appid)}, warnings = []; SteamClient.Apps.SetShortcutName(id, {json.dumps(name)}); - SteamClient.Apps.SetShortcutExe(id, {json.dumps(exe)}); - SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)}); + if ({json.dumps(exe)}) SteamClient.Apps.SetShortcutExe(id, {json.dumps(exe)}); + if ({json.dumps(start_dir)}) SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)}); + if (typeof SteamClient.Apps.SetShortcutSortAs === "function") + SteamClient.Apps.SetShortcutSortAs(id, {json.dumps(name)}); SteamClient.Apps.SetShortcutIcon(id, {json.dumps(icon)}); if (typeof SteamClient.Apps.SetShortcutIsVR === "function") SteamClient.Apps.SetShortcutIsVR(id, {json.dumps(vr)}); @@ -148,10 +209,11 @@ def configure(appid, name, exe, start_dir, icon, vr, artwork): if (typeof SteamClient.Apps.SetCustomArtworkForApp === "function") {{ for (const [type, ext, data] of {json.dumps(images)}) await SteamClient.Apps.SetCustomArtworkForApp(id, data, ext, type); - }} else warnings.push("Steam artwork API unavailable"); - {collections_js(int(appid), vr)} + }} else throw new Error("Steam artwork API unavailable; installation is incomplete"); + {collections_js(int(appid), vr, options.get('category', 'Android'))} try {{ warnings.push(...await syncCollections()); }} catch (e) {{ warnings.push("Steam collections: " + String(e)); }} + {notes_js(name, options.get('details', {}))} return {{warnings}}; }})()''') @@ -162,7 +224,7 @@ def remove(appid): {collections_js(int(appid))} const warnings = await syncCollections(); if (typeof SteamClient.Apps.ClearCustomArtworkForApp === "function") {{ - for (const type of [0, 1, 2, 3, 4]) + for (const type of [0, 1, 2, 3]) await SteamClient.Apps.ClearCustomArtworkForApp(id, type); }} else throw new Error("Steam artwork removal API unavailable"); SteamClient.Apps.RemoveShortcut(id); @@ -185,10 +247,13 @@ def main(): print(evaluate(js)) elif cmd == 'list': js = '''(() => appStore.allApps.filter(a => a.app_type === 1073741824) - .map(a => ({appid: a.appid, name: a.display_name})))()''' + .map(a => ({appid: a.appid, name: a.display_name, devkit_gameid: a.devkit_gameid})))()''' print(json.dumps(evaluate(js))) + elif cmd == 'render': + print(json.dumps(render(args[0]))) elif cmd == 'configure': - print(json.dumps(configure(int(args[0]), *args[1:5], args[5] == '1', json.loads(args[6])))) + print(json.dumps(configure(int(args[0]), *args[1:5], args[5] == '1', json.loads(args[6]), + json.loads(args[7]) if len(args) > 7 else None))) elif cmd == 'stop': evaluate(f'SteamClient.Apps.TerminateApp({json.dumps(str((int(args[0]) << 32) | 0x02000000))}, false)') print('stopping') diff --git a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub.js b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub.js new file mode 100644 index 0000000..ac21da6 --- /dev/null +++ b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub.js @@ -0,0 +1,32 @@ +// Synthetic canvas for API-path tests only. It emits transparent PNGs, not visual proof. +const zlib = require('zlib'); +function crc(data) { + let c=0xffffffff; + for(const b of data) {c^=b;for(let i=0;i<8;i++)c=(c>>>1)^((c&1)?0xedb88320:0);} + return (c^0xffffffff)>>>0; +} +function chunk(name,data) { + const body=Buffer.concat([Buffer.from(name),data]), n=Buffer.alloc(4), sum=Buffer.alloc(4); + n.writeUInt32BE(data.length);sum.writeUInt32BE(crc(body));return Buffer.concat([n,body,sum]); +} +function png(w,h) { + const header=Buffer.alloc(13);header.writeUInt32BE(w);header.writeUInt32BE(h,4);header[8]=8;header[9]=6; + return Buffer.concat([Buffer.from('89504e470d0a1a0a','hex'),chunk('IHDR',header), + chunk('IDAT',zlib.deflateSync(Buffer.alloc((w*4+1)*h))),chunk('IEND',Buffer.alloc(0))]).toString('base64'); +} +function surface() { + const canvases=[]; + const document={fonts:{load:async()=>[]},createElement(tag) { + if(tag!=='canvas')throw Error('unexpected element'); + const canvas={width:1,height:1,text:[],draws:0}; + const ctx={measureText:t=>({width:String(t).length*30}),fillText(t){canvas.text.push(t);}, + drawImage(){canvas.draws++;},getImageData:()=>({data:new Uint8ClampedArray(canvas.width*canvas.height*4)}), + createLinearGradient:()=>({addColorStop(){}}),createRadialGradient:()=>({addColorStop(){}})}; + for(const method of ['save','restore','beginPath','rect','roundRect','clip','fillRect','putImageData','arc','fill','stroke'])ctx[method]=()=>{}; + canvas.getContext=()=>ctx;canvas.toDataURL=()=> 'data:image/png;base64,'+png(canvas.width,canvas.height); + canvases.push(canvas);return canvas; + }}; + class Image {constructor(){this.width=2;this.height=2;} async decode(){if(this.src.includes('YmFk'))throw Error('bad image');}} + return {document,Image,canvases}; +} +module.exports={surface}; diff --git a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js index 1a3f2da..e0c2a52 100644 --- a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js +++ b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js @@ -37,7 +37,7 @@ function build(steam) { }, }); const shortcutOverview = s => ({ - appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE, + appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE, devkit_gameid: s.devkit_gameid, local_per_client_data: { installed: true, display_status: 1, status_percentage: 0 }, }); const allApps = () => [...steam.apps.map(gameOverview), ...steam.shortcuts.map(shortcutOverview)]; @@ -65,6 +65,7 @@ function build(steam) { value, }); return { + ...require('./canvas_stub').surface(), collectionStore: { GetUserCollectionsByName(name) { return steam.collections.filter(c => c.name === name).map(collection); }, NewUnsavedCollection(name, filter, apps) { return collection({name, apps: apps.map(a => a.appid)}); }, diff --git a/tests/fixtures/library/check-renderer.js b/tests/fixtures/library/check-renderer.js new file mode 100644 index 0000000..83490f4 --- /dev/null +++ b/tests/fixtures/library/check-renderer.js @@ -0,0 +1,16 @@ +const fs=require('fs'),vm=require('vm'),assert=require('assert'); +const stub=require(process.cwd()+'/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub'); +(async()=>{ + const surface=stub.surface(),ctx=vm.createContext(surface); + vm.runInContext(fs.readFileSync('frame/android/library_artwork.js','utf8'),ctx); + const result=await ctx.renderLibraryArtwork({label:'Example Game',images:{icon:['png','fixture']}}); + assert.deepEqual(Object.keys(result.images),['grid','wide','hero','logo','icon']); + for(const [slot,size] of Object.entries({grid:[600,900],wide:[920,430],hero:[3840,1240],logo:[1280,480],icon:[256,256]})) { + const b=Buffer.from(result.images[slot],'base64');assert.equal(b.readUInt32BE(16),size[0]);assert.equal(b.readUInt32BE(20),size[1]); + } + const hero=surface.canvases.find(c=>c.width===3840);assert.equal(hero.text.length,0); + const logo=surface.canvases.find(c=>c.width===1280);assert(logo.text.length);assert.equal(logo.draws,0); + const before=surface.canvases.length;await ctx.renderLibraryArtwork({label:'No Icon',images:{}}); + assert.equal(surface.canvases.slice(before).find(c=>c.width===3840).text.length,0); + console.log('five dimensions, textless hero, title logo: OK'); +})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/tests/test_frame_android_library.py b/tests/test_frame_android_library.py index e2ea704..dea31a6 100644 --- a/tests/test_frame_android_library.py +++ b/tests/test_frame_android_library.py @@ -32,6 +32,7 @@ class LauncherTests(unittest.TestCase): (app / 'launch.sh').write_bytes((ROOT / 'frame/android/lepton-app.sh').read_bytes()) (app / 'app.apk').touch() (app / 'instance.id').write_text('2800000001') + (app / 'shortcut.id').write_text('3346865537') bin_dir = d / 'bin' bin_dir.mkdir() lepton = d / '.local/share/Steam/steamapps/common/Lepton/lepton' @@ -40,6 +41,8 @@ class LauncherTests(unittest.TestCase): path.write_text('#!' + sys.executable + '\n' + body) path.chmod(0o755) script(lepton, 'import os,time\nfrom pathlib import Path\n' + 'assert os.environ["SteamAppId"] == "2800000001"\n' + 'assert os.environ["LEPTON_ENV_SteamAppId"] == "3346865537"\n' 'Path(os.environ["HOME"],"started").write_text(str(os.getpid()))\n' + ('time.sleep(30)\n' if terminate else 'raise SystemExit(23)\n')) script(bin_dir / 'setsid', 'import os,sys\nos.setsid()\nos.execv(sys.argv[2],sys.argv[2:])\n') @@ -118,41 +121,39 @@ class ArtworkTests(unittest.TestCase): self.assertEqual(background[8:12], bytes([4, 9, 142, 255])) self.assertEqual(background[12:], bytes([10, 20, 30, 255])) - def test_all_slots_have_expected_dimensions(self): - images = art.prepare('Open Saber Plus', (FIXTURES / 'icon.png').read_bytes()) - self.assertEqual(set(images), set(art.SLOTS)) - for slot, (ext, data) in images.items(): - self.assertEqual(ext, 'png') - self.assertEqual(struct.unpack_from('>II', data, 16), art.SLOTS[slot]) - # Transparent logo, opaque capsules; corrupt/missing icon gets a monogram. - _, _, logo = art.decode(images['logo'][1]) - self.assertIn(0, logo[3::4]) - self.assertIn(255, logo[3::4]) - self.assertEqual(set(art.fallback('日本語', b'bad icon')), set(art.SLOTS)) - - def test_supplied_slots_and_url(self): + def test_source_inputs_and_url(self): import io data = (FIXTURES / 'icon.png').read_bytes() response = io.BytesIO(data) response.geturl = lambda: 'https://example.org/icon.png' - with patch.object(art.urllib.request, 'urlopen', return_value=response) as fetch: - images = art.prepare('Game', artwork={'hero': data, 'icon': 'https://example.org/icon.png'}) - self.assertEqual(images['hero'], ('png', data)) + with patch('frame_steamgriddb.lookup', return_value=({}, [])), \ + patch.object(art.urllib.request, 'urlopen', return_value=response) as fetch: + images, warnings = art.prepare('Game', artwork={'banner': data, 'icon': 'https://example.org/icon.png'}) + self.assertEqual(images['banner'], ('png', data)) self.assertEqual(images['icon'], ('png', data)) + self.assertEqual(warnings, []) self.assertEqual(fetch.call_count, 1) - self.assertEqual(fetch.call_args.kwargs['timeout'], 20) - self.assertEqual(fetch.call_args.args[0].get_header('User-agent'), 'FrameControl/1.0') + + def test_provider_precedence_and_bad_source_fallback(self): + data = (FIXTURES / 'icon.png').read_bytes() + jpg = (FIXTURES / 'icon.jpg').read_bytes() + with patch('frame_steamgriddb.lookup', return_value=({'hero': jpg}, [])): + images, warnings = art.prepare('Game', data, {'hero': data, 'wide': b'bad', 'screenshots': [b'bad', data]}) + self.assertEqual(images['hero'], ('jpg', jpg)) + self.assertEqual(images['icon'], ('png', data)) + self.assertEqual(images['screenshot'], ('png', data)) + self.assertNotIn('wide', images) + self.assertEqual(len(warnings), 2) def test_supplied_jpeg(self): data = (FIXTURES / 'icon.jpg').read_bytes() self.assertEqual(art.image_type(data), 'jpg') - self.assertEqual(art.prepare('Game', artwork={'wide': data})['wide'], ('jpg', data)) with self.assertRaises(ValueError): art.image_type(data[:30]) def test_bad_artwork_and_expansion_limits(self): import zlib - for value in ({'bad': b'bad'}, {'hero': 'file:///etc/passwd'}, {'hero': b'bad'}, ['hero']): + for value in ({'bad': b'bad'}, ['hero']): with self.subTest(value=value), self.assertRaises(ValueError): art.prepare('Game', artwork=value) data = bytearray((FIXTURES / 'icon.png').read_bytes()) @@ -209,10 +210,12 @@ class InstallTests(unittest.TestCase): def shortcut(*args, **kwargs): if args[0] == 'add': return '3346865537' + if args[0] == 'render': + return json.dumps({'paths': {slot: '/home/steamos/Applications/Android/org.test.vr/artwork/' + slot + '.png' for slot in art.SLOTS}}) if args[0] == 'list': return json.dumps(self.responses['shortcuts']) return json.dumps(self.responses['configure']) - with patch.object(android.frame_artwork, 'prepare', return_value=self.images), \ + with patch.object(android.frame_artwork, 'prepare', return_value=(self.images, [])), \ patch.object(android, 'read_meta', return_value=existing), \ patch.object(android, '_copy') as copy, \ patch.object(android, 'ssh', return_value=self.responses['home']) as ssh, \ @@ -229,7 +232,7 @@ class InstallTests(unittest.TestCase): self.assertEqual(configure[1:3], ('3346865537', 'New name')) self.assertEqual(configure[6], '1') self.assertEqual(set(json.loads(configure[7])), set(art.SLOTS)) - self.assertTrue(configure[5].endswith('/org.test.vr/icon.png')) + self.assertTrue(configure[5].endswith('/org.test.vr/artwork/icon.png')) self.assertEqual(result['shortcut'], self.existing['shortcut']) self.assertEqual(result['label'], 'New name') self.assertEqual(result['library_warnings'], []) @@ -238,7 +241,7 @@ class InstallTests(unittest.TestCase): def test_first_install_adds_shortcut(self): _, _, api, _ = self.install(None) - self.assertEqual([c.args[0] for c in api.call_args_list], ['add', 'configure']) + self.assertEqual([c.args[0] for c in api.call_args_list], ['add', 'render', 'configure']) def test_artwork_forwarded_through_patch(self): artwork = {'hero': b'provided'} @@ -250,13 +253,15 @@ class InstallTests(unittest.TestCase): self.assertIs(install.call_args.args[-1], artwork) def test_failed_new_install_removes_shortcut(self): - def tool(*args): + def tool(*args, **kwargs): if args[0] == 'add': return '3346865537' + if args[0] == 'render': + return json.dumps({'paths': {slot: '/tmp/' + slot + '.png' for slot in art.SLOTS}}) if args[0] == 'configure': raise android.FrameError('write failed') return '{}' - with patch.object(android.frame_artwork, 'prepare', return_value=self.images), \ + with patch.object(android.frame_artwork, 'prepare', return_value=(self.images, [])), \ patch.object(android, 'read_meta', return_value=None), \ patch.object(android, '_copy'), patch.object(android, 'ssh', return_value='/home/steamos') as ssh, \ patch.object(android, 'shortcut_tool', side_effect=tool) as api: @@ -304,7 +309,7 @@ class SteamAPITests(unittest.TestCase): with patch.object(shortcuts, 'evaluate', return_value={}) as evaluate: shortcuts.remove(42) js = evaluate.call_args.args[0] - self.assertIn('[0, 1, 2, 3, 4]', js) + self.assertIn('[0, 1, 2, 3]', js) self.assertLess(js.index('ClearCustomArtworkForApp(id, type)'), js.index('RemoveShortcut(id)')) self.assertIn('const wanted = []', js) @@ -335,10 +340,11 @@ class SteamContextTests(unittest.TestCase): {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS}) self.assertEqual(result['warnings'], []) self.assertTrue(steam['shortcuts'][0]['vr']) - self.assertEqual(set(steam['shortcuts'][0]['artwork']), {'0', '1', '2', '3', '4'}) + self.assertEqual(set(steam['shortcuts'][0]['artwork']), {'0', '1', '2', '3'}) self.assertEqual(steam['collections'], [{'name': 'Android', 'apps': [999, 42]}, {'name': 'Android VR', 'apps': [42]}]) - shortcuts.configure(42, 'Renamed', '/exe', '/dir', '/icon', False, {}) + shortcuts.configure(42, 'Renamed', '/exe', '/dir', '/icon', False, + {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS}) self.assertEqual(steam['shortcuts'][0]['name'], 'Renamed') self.assertEqual(steam['collections'][1]['apps'], []) shortcuts.remove(42) diff --git a/tests/test_library_entrypoints.py b/tests/test_library_entrypoints.py new file mode 100644 index 0000000..e49104b --- /dev/null +++ b/tests/test_library_entrypoints.py @@ -0,0 +1,208 @@ +"""Every public sideload entry point reaches the mandatory five-slot writer.""" +import contextlib +import io +import json +import shutil +import subprocess +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'ui')) +import frame_android as android +import frame_artwork as artwork +import frame_catalog as catalog +import frame_apk_versions as versions +import frame_titles as titles +import frame_steamgriddb as sgdb +import server +import frame_webinstall as webinstall + + +class EntryPoints(unittest.TestCase): + def setUp(self): + self.stack = contextlib.ExitStack() + self.addCleanup(self.stack.close) + self.info = {'package':'org.example.game', 'label':'Example', 'version':'1', 'version_code':1, + 'vr':False, 'repairable':False, 'launchable':True, 'min_sdk':24, 'abis':[], 'icon_png':None} + self.stack.enter_context(patch.object(android, 'apk_info', return_value=self.info)) + self.stack.enter_context(patch.object(android, 'read_meta', return_value=None)) + self.stack.enter_context(patch.object(android, '_copy')) + self.stack.enter_context(patch.object(android, 'ssh', return_value='/home/steamos')) + self.stack.enter_context(patch.object(artwork, 'prepare', return_value=({}, []))) + self.api = self.stack.enter_context(patch.object(android, 'shortcut_tool', side_effect=self.steam)) + + def steam(self, *args, **kwargs): + if args[0] == 'add': return '3346865537' + if args[0] == 'render': return json.dumps({'paths': {s:'/tmp/'+s+'.png' for s in artwork.SLOTS}}) + if args[0] == 'list': return json.dumps([{'appid':3346865537,'name':'Example','devkit_gameid':'Example'}]) + return '{"warnings":[]}' + + def assert_art(self): + calls = [c.args for c in self.api.call_args_list] + self.assertEqual(sum(c[0] == 'render' for c in calls), 1) + configs = [c for c in calls if c[0] == 'configure'] + self.assertEqual(len(configs), 1) + self.assertEqual(set(json.loads(configs[0][7])), set(artwork.SLOTS)) + + def test_cli_install(self): + with patch.object(sys, 'argv', ['frame_android.py', 'install', 'game.apk']), patch('builtins.print'): + android.main() + self.assert_art() + + def test_file_upload(self): + class Request: + headers = {'X-Filename':'game.apk','X-Mode':'apk','Content-Length':'3'} + rfile = io.BytesIO(b'apk') + with patch.object(server, 'ensure_master'): + result = server.Handler.upload(Request()) + self.assertEqual(result['app']['package'], self.info['package']) + self.assert_art() + + def test_catalogue_install(self): + with patch.object(catalog, 'app', return_value={'r':'yes','t':False,'n':'Example'}), \ + patch.object(catalog, 'fetch_apk', return_value='game.apk'), \ + patch.object(catalog, 'fetch_icon', return_value=None): + catalog.install(self.info['package']) + self.assert_art() + + def test_version_finder_install(self): + record = {'url':'https://example.org/app.apk','sha256':'fixture','version_code':1,'source':'F-Droid'} + with patch.object(versions, '_versions', return_value=([record], [])), \ + patch.object(catalog, 'fetch_apk', return_value='game.apk'): + versions.install(self.info['package'], record['url']) + self.assert_art() + + def test_web_download_install(self): + result = webinstall.dispatch('game.apk', source='https://example.org/game.apk') + self.assertEqual(result['kind'], 'apk') + self.assert_art() + + def test_refresh_api(self): + with patch.object(server, 'ensure_master'), \ + patch.object(server, 'start_job', side_effect=lambda label, work: work()), \ + patch.object(android, 'refresh_art', return_value=[]) as refresh: + self.assertEqual(server.android({'action':'refresh-art', 'all':True}), {'apps':[]}) + refresh.assert_called_once_with(None) + + def test_source_search_shared_installer_contract(self): + # Source workers hand their download and optional images to this public seam. + android.install('game.apk', name='Example', source='source-search', artwork={'banner': b'fixture'}) + self.assert_art() + + def test_native_title_install(self): + with tempfile.TemporaryDirectory() as root: + plan = {'id':'Example','name':'Example','root':root,'size':3,'target':'game.exe', + 'runtime':'proton-experimental','source':'example.zip'} + def ssh(cmd, **kwargs): + if 'test -d' in cmd: return '' + if 'steamos-prepare-upload' in cmd: return '{"directory":"/home/steamos/devkit-game/Example"}' + if 'steam-client-create-shortcut' in cmd: return '{"success":"registered"}' + return '' + with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'), \ + patch.object(titles, '_copy_tree'), patch.object(titles, '_rsync', return_value=True): + result = titles._install(plan, lambda *args: None) + self.assertEqual(result['shortcut'], 3346865537) + self.assert_art() + config = next(c.args for c in self.api.call_args_list if c.args[0] == 'configure') + self.assertEqual(json.loads(config[8])['category'], 'Sideloaded') + self.assertEqual(config[3:5], ('','')) # Never replace devkit's executable/runtime wiring. + + def test_native_renamed_shortcut_uses_saved_identity(self): + self.api.side_effect = lambda *args, **kw: '[{"appid":42,"name":"Renamed"}]' + with patch.object(titles, 'ssh', return_value='{"shortcut":42}'): + self.assertEqual(titles._library_shortcut('Original', 'Original'), 42) + + def test_native_failure_removes_new_blank_shortcut(self): + with tempfile.TemporaryDirectory() as root: + plan = {'id':'Example','name':'Example','root':root,'size':3,'target':'game.exe', + 'runtime':'proton-experimental','source':'example.zip'} + def ssh(cmd, **kwargs): + if 'steamos-prepare-upload' in cmd: return '{"directory":"/home/steamos/devkit-game/Example"}' + if 'steam-client-create-shortcut' in cmd: return '{"success":"registered"}' + return '' + with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'), \ + patch.object(titles, '_copy_tree'), patch.object(titles, '_rsync', return_value=True), \ + patch.object(android, 'apply_library', side_effect=android.FrameError('render failed')): + with self.assertRaisesRegex(android.FrameError, 'render failed'): + titles._install(plan, lambda *args: None) + self.assertIn(('remove', '3346865537'), [c.args for c in self.api.call_args_list]) + + def test_refresh_does_not_reinstall_or_stop(self): + meta = {**self.info, 'instance':2800000001, 'shortcut':3346865537, 'flatscreen':False} + with patch.object(android, '_meta_or_fail', return_value=meta), \ + patch.object(android, 'ssh', side_effect=lambda cmd, **kw: json.dumps({'icon_png':''}) if cmd == 'python3 -' else '/home/steamos'), \ + patch.object(android, 'stop') as stop, patch.object(android, '_copy') as copy: + android.refresh_art(self.info['package']) + stop.assert_not_called(); copy.assert_not_called(); self.assert_art() + + def test_all_refresh_reports_partial_failures(self): + with patch.object(android, 'list_apps', return_value=[{'package':'org.a.game'},{'package':'org.b.game'}]), \ + patch.object(android, '_meta_or_fail', side_effect=android.FrameError('missing')): + result=android.refresh_art() + self.assertEqual(len(result),2) + self.assertTrue(all('error' in a for a in result)) + + def test_render_failure_cannot_report_success(self): + self.api.side_effect = lambda *args, **kw: '3346865537' if args[0]=='add' else '{"paths":{}}' + with self.assertRaisesRegex(android.FrameError,'every slot'): + android.install('game.apk') + + +class Renderer(unittest.TestCase): + @unittest.skipUnless(shutil.which('node'), 'Node is needed for the canvas contract fixture') + def test_canvas_slots_and_title_placement(self): + subprocess.run(['node', str(ROOT / 'tests/fixtures/library/check-renderer.js')], + cwd=str(ROOT), check=True, capture_output=True, timeout=30) + + def test_desktop_packages_include_renderer_and_settings(self): + config = json.loads((ROOT / 'app/package.json').read_text()) + resources = {r['from']: r['filter'] for r in config['build']['extraResources']} + self.assertIn('*.js', resources['../ui']) + self.assertIn('*.js', resources['../frame/android']) + + +class SteamGridDB(unittest.TestCase): + def test_no_key_is_silent_and_offline(self): + with patch.object(sgdb,'api_key',return_value=''), patch.object(sgdb,'_get') as get: + self.assertEqual(sgdb.lookup('Game'),({},[])) + get.assert_not_called() + + def test_exact_match_and_top_votes_per_slot(self): + calls=[] + def get(path,key): + calls.append(path) + if 'search' in path: return [{'id':1,'name':'Other Game'},{'id':2,'name':'Game'}] + dims=(600,900) if '600x900' in path else (920,430) + return [{'url':'https://example.org/low.png','score':2,'width':dims[0],'height':dims[1]}, + {'url':'https://example.org/top.png','score':20,'width':dims[0],'height':dims[1]}, + {'url':'https://example.org/nsfw.png','score':99,'nsfw':True,'width':dims[0],'height':dims[1]}] + with patch.object(sgdb,'api_key',return_value='test-key'),patch.object(sgdb,'_get',side_effect=get): + images,warnings=sgdb.lookup('Game VR') + self.assertEqual(set(images),set(artwork.SLOTS));self.assertEqual(warnings,[]) + self.assertTrue(all(url.endswith('/top.png') for url in images.values())) + self.assertTrue(all('/game/2?' in p for p in calls[1:])) + + def test_wrong_title_and_failed_lookup_fall_back(self): + with patch.object(sgdb,'api_key',return_value='test-key'),patch.object(sgdb,'_get',return_value=[{'id':1,'name':'Unrelated'}]): + self.assertEqual(sgdb.lookup('Game'),({},[])) + with patch.object(sgdb,'api_key',return_value='test-key'),patch.object(sgdb,'_get',side_effect=OSError('private-secret')): + result=sgdb.lookup('Game') + self.assertNotIn('private-secret',str(result));self.assertEqual(result[0],{}) + + def test_settings_never_return_key(self): + with tempfile.TemporaryDirectory() as root, patch.object(sgdb,'settings_path',return_value=Path(root)/'settings.json'), \ + patch.dict(sgdb.os.environ,{},clear=True): + result=sgdb.save_settings({'steamgriddb_api_key':'secret-fixture'}) + self.assertTrue(result['steamgriddb_configured']) + self.assertNotIn('secret-fixture',json.dumps(result)) + self.assertEqual(sgdb.api_key(),'secret-fixture') + if sys.platform!='win32':self.assertEqual((Path(root)/'settings.json').stat().st_mode&0o777,0o600) + sgdb.save_settings({'steamgriddb_api_key':''}) + self.assertFalse(sgdb.settings()['steamgriddb_configured']) + + +if __name__=='__main__':unittest.main() diff --git a/ui/artwork-settings.js b/ui/artwork-settings.js new file mode 100644 index 0000000..5d815fd --- /dev/null +++ b/ui/artwork-settings.js @@ -0,0 +1,31 @@ +(() => { + const get=id=>document.getElementById(id), status=get('steamGridStatus'); + async function request(url,body) { + const r=await fetch(url,body===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); + const data=await r.json(); if(!r.ok) throw Error(data.error||'Request failed'); return data; + } + function show(data) { + status.textContent=data.environment?'SteamGridDB key is set by an environment variable.': + data.steamgriddb_configured?'SteamGridDB key saved.':'No key configured. Source images and generated art are enabled.'; + } + async function save(value) { + try {show(await request('/api/settings/artwork',{steamgriddb_api_key:value}));get('steamGridKey').value='';} + catch(e) {status.textContent=e.message;} + } + get('saveSteamGridKey').onclick=()=>save(get('steamGridKey').value.trim()); + get('clearSteamGridKey').onclick=()=>save(''); + get('refreshAndroidArt').onclick=async()=>{ + const button=get('refreshAndroidArt');button.disabled=true; + try { + const result=await runJob('Refresh Android artwork','android-artwork',()=>request('/api/android',{action:'refresh-art',all:true})); + if (result) { + const apps=Array.isArray(result.apps)?result.apps:[result.apps]; + const failed=apps.filter(a=>a.error); + status.textContent=failed.length?`${failed.length} refresh failed: ${failed.map(a=>a.package+': '+a.error).join('; ')}`: + `Refreshed artwork for ${apps.length} apps.`; + } + } catch(e) {status.textContent=e.message;} + finally {button.disabled=false;} + }; + request('/api/settings/artwork').then(show).catch(e=>{status.textContent=e.message;}); +})(); diff --git a/ui/frame_android.py b/ui/frame_android.py index 79f8183..032c232 100644 --- a/ui/frame_android.py +++ b/ui/frame_android.py @@ -8,7 +8,7 @@ Lepton Development, which wipes its apps on exit. See docs/apks.md. Python stdlib only. CLI: python3 ui/frame_android.py install APK [--vr|--flat] [--no-xr-compat] | info APK | versions APK-or-PKG - patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG + refresh-art PKG|--all | patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG """ import json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib @@ -57,8 +57,12 @@ def ssh(cmd, input=None, timeout=120): def shortcut_tool(*args, timeout=60): with open(SHORTCUTS) as f: - return ssh('python3 - ' + ' '.join(shlex.quote(a) for a in args), input=f.read(), - timeout=timeout).strip() + script = f.read() + if args and args[0] == 'render': + with open(os.path.join(ROOT, 'frame/android/library_artwork.js')) as f: + script = 'ART_RENDERER = ' + repr(f.read()) + '\n' + script + return ssh('python3 - ' + ' '.join(shlex.quote(a) for a in args), input=script, + timeout=timeout).strip() def instance_id(pkg): @@ -161,7 +165,7 @@ def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None): try: - images = frame_artwork.prepare(name or info['label'], info.get('icon_png'), artwork) + images, art_warnings = frame_artwork.prepare(name or info['label'], info.get('icon_png'), artwork) except (ValueError, OSError) as e: raise FrameError(f'could not prepare artwork: {e}') from e iid = instance_id(pkg) @@ -172,35 +176,31 @@ def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None): ssh(f'mkdir -p {d}') _copy(apk_path, f'{d}/app.apk.part') _copy(LAUNCHER, f'{d}/launch.sh', executable=True, timeout=120) - ssh(f'mkdir -p {d}/artwork') - paths = {} - for slot, (ext, data) in images.items(): - path = f'{d}/icon.{ext}' if slot == 'icon' else f'{d}/artwork/{slot}.{ext}' - ssh(f'cat > {path}.tmp && mv {path}.tmp {path}', input=data) - paths[slot] = path - icon = paths['icon'] marker = f'touch {d}/lepton-show-flatscreen' if flatscreen else f'rm -f {d}/lepton-show-flatscreen' ssh(f'mv {d}/app.apk.part {d}/app.apk && echo {iid} > {d}/instance.id && {marker}') home = ssh('echo $HOME').strip() shortcut = _int((existing or {}).get('shortcut')) if not shortcut or shortcut not in _shortcut_ids(): reply = shortcut_tool('add', name or info['label'], f'{home}/{d}/launch.sh', f'{home}/{d}', - f'{home}/{icon}') + '') shortcut = _int(reply.strip().splitlines()[-1] if reply.strip() else None) if not shortcut: raise FrameError(f'Steam did not return a shortcut id (got {reply[:80]!r})') created = shortcut - presentation = json.loads(shortcut_tool( - 'configure', str(shortcut), name or info['label'], f'{home}/{d}/launch.sh', - f'{home}/{d}', f'{home}/{icon}', '0' if flatscreen else '1', - json.dumps({slot: f'{home}/{path}' for slot, path in paths.items()})) or '{}') + presentation = apply_library(shortcut, name or info['label'], d, images, + vr=not flatscreen, home=home, + exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}', + details={'package': pkg, 'version': info['version'], + 'source': source or os.path.basename(apk_path)}) + presentation['warnings'] = art_warnings + presentation.get('warnings', []) meta = {'package': pkg, 'label': name or info['label'], 'version': info['version'], 'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut), 'vr': info.get('vr', False), 'vr_issues': info.get('vr_issues', []), 'launchable': info.get('launchable', False), 'patched': info.get('patched', []), 'flatscreen': flatscreen, 'installed': time.strftime('%Y-%m-%dT%H:%M:%S'), 'source': source or os.path.basename(apk_path), - 'library_warnings': presentation.get('warnings', [])} + 'library_warnings': presentation.get('warnings', []), + 'artwork': presentation.get('artwork', {}), 'library_version': 2} _write_meta(d, meta) ok = True return meta @@ -218,6 +218,101 @@ def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None): pass + +def apply_library(shortcut, label, directory, images, vr=False, home=None, exe='', start_dir='', details=None, + category='Android'): + """Mandatory for every sideload: render all five slots before reporting success.""" + home = home or ssh('echo $HOME').strip() + d = directory + ssh(f'mkdir -p {shlex.quote(d)}/artwork') + paths = {} + for slot, (ext, data) in images.items(): + path = f'{d}/artwork/source-{slot}.{ext}' + ssh(f'cat > {shlex.quote(path)}', input=data) + paths[slot] = f'{home}/{path}' if not path.startswith('/') else path + manifest = {'label': label, 'images': paths} + plan = f'{d}/artwork/input.json' + ssh(f'cat > {shlex.quote(plan)}', input=json.dumps(manifest)) + absolute = f'{home}/{plan}' if not plan.startswith('/') else plan + rendered = json.loads(shortcut_tool('render', absolute, timeout=120)) + art = rendered['paths'] + if set(art) != set(frame_artwork.SLOTS): + raise FrameError('Steam artwork renderer did not produce every slot') + result = json.loads(shortcut_tool('configure', str(shortcut), label, exe, start_dir, art['icon'], + '1' if vr else '0', json.dumps(art), + json.dumps({'category': category, 'details': details or {}}), timeout=120)) + result['warnings'] = rendered.get('warnings', []) + result.get('warnings', []) + result['artwork'] = art + if category == 'Android': + ssh(f'cat > {shlex.quote(d)}/shortcut.id', input=str(int(shortcut))) + return result + + +def refresh_art(pkg=None, artwork=None): + """Refresh existing APK library entries without reinstalling or stopping them.""" + if pkg is None: + results = [] + for app in list_apps(): + try: + results.append(refresh_art(app['package'], artwork)) + except (FrameError, OSError, ValueError) as e: + results.append({'package': app['package'], 'error': str(e)}) + return results + with _install_lock: + m = _meta_or_fail(pkg) + d = f'{APPS_DIR}/{pkg}' + # Parse the APK on the Frame, transferring only its icon/label, not the APK. + modules = {} + for module in ('frame_apk', 'frame_apk_vr'): + with open(os.path.join(ROOT, 'ui', module + '.py')) as f: + modules[module] = f.read() + script = 'import sys, types, json, base64\n' + for module, source in modules.items(): + script += f'm = types.ModuleType({module!r}); sys.modules[{module!r}] = m; exec({source!r}, m.__dict__)\n' + script += f"info = sys.modules['frame_apk'].apk_info({(d + '/app.apk')!r})\n" + script += "info['icon_png'] = base64.b64encode(info.get('icon_png') or b'').decode()\n" + script += "import os\ninfo['artwork'] = {}\n" + script += f"directory = os.path.realpath({d!r})\n" + script += """try: + with open(os.path.join(directory, 'artwork/input.json')) as f: + cached = json.load(f) + for slot, path in cached.get('images', {}).items(): + if os.path.commonpath([os.path.realpath(path), directory]) != directory: + continue + with open(path, 'rb') as f: + data = f.read(12 * 1024 * 1024 + 1) + if len(data) <= 12 * 1024 * 1024: + info['artwork'][slot] = base64.b64encode(data).decode() +except (OSError, ValueError, TypeError): + pass +print(json.dumps(info)) +""" + info = json.loads(ssh('python3 -', input=script)) + import base64 + icon = base64.b64decode(info['icon_png']) + cached = {k: base64.b64decode(v) for k, v in info.get('artwork', {}).items()} + images, warnings = frame_artwork.prepare(m['label'], icon, artwork if artwork is not None else cached) + home = ssh('echo $HOME').strip() + created = False + if not m['shortcut'] or m['shortcut'] not in _shortcut_ids(): + m['shortcut'] = _int(shortcut_tool('add', m['label'], f'{home}/{d}/launch.sh', f'{home}/{d}')) + if not m['shortcut']: + raise FrameError('Steam did not create the missing shortcut') + m['game_id'] = game_id(m['shortcut']) + created = True + try: + result = apply_library(m['shortcut'], m['label'], d, images, vr=not m.get('flatscreen', True), + home=home, exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}', details=m) + except Exception: + if created: + shortcut_tool('remove', str(m['shortcut'])) + raise + m.update(artwork=result.get('artwork', {}), library_version=2) + m['library_warnings'] = warnings + result.get('warnings', []) + m['artwork_refreshed'] = time.strftime('%Y-%m-%dT%H:%M:%S') + _write_meta(d, m) + return m + def _int(v): try: n = int(v) @@ -375,6 +470,13 @@ def main(): elif cmd == 'install': r = install(args[0], flatscreen=False if '--vr' in args else True if '--flat' in args else None, xr_compat=False if '--no-xr-compat' in args else None) + elif cmd == 'refresh-art': + if len(args) != 1: + raise FrameError('usage: refresh-art PACKAGE or refresh-art --all') + r = refresh_art(None if args[0] == '--all' else args[0]) + if isinstance(r, list) and any('error' in item for item in r): + print(json.dumps(r, indent=1)) + raise SystemExit(1) elif cmd == 'patch': import argparse parser = argparse.ArgumentParser(description='Patch and v2-sign an APK locally') diff --git a/ui/frame_artwork.py b/ui/frame_artwork.py index 5264344..41ed93f 100644 --- a/ui/frame_artwork.py +++ b/ui/frame_artwork.py @@ -1,8 +1,5 @@ -"""Small stdlib PNG compositor for Steam's Android library artwork.""" -import hashlib +"""Bounded artwork inputs for Steam library canvas rendering.""" import struct -import textwrap -import unicodedata import urllib.parse import urllib.request import zlib @@ -12,18 +9,6 @@ SLOTS = {'grid': (600, 900), 'wide': (920, 430), 'hero': (3840, 1240), MAX_IMAGE = 12 * 1024 * 1024 MAX_PIXELS = 8_000_000 PNG = b'\x89PNG\r\n\x1a\n' -# Five-column display lettering; labels remain exact in Steam's native text. -GLYPHS = dict(zip('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789- .?', ( - '0E11111F111111', '1E11111E11111E', '0F10101010100F', '1E11111111111E', - '1F10101E10101F', '1F10101E101010', '0F10101711110F', '1111111F111111', - '0E04040404040E', '0702020212120C', '11121418141211', '1010101010101F', - '111B1515111111', '11191513111111', '0E11111111110E', '1E11111E101010', - '0E11111115120D', '1E11111E141211', '0F10100E01011E', '1F040404040404', - '1111111111110E', '11111111110A04', '11111115151B11', '11110A040A1111', - '11110A04040404', '1F01020408101F', '0E11131519110E', '040C040404040E', - '0E11010204081F', '1E01010E01011E', '02060A121F0202', '1F10101E01011E', - '0E10101E11110E', '1F010204080808', '0E11110E11110E', '0E11110F01010E', - '0000001F000000', '00000000000000', '00000000000C0C', '0E110102040004'))) def chunk(kind, data): @@ -150,24 +135,6 @@ def image_type(data): raise ValueError('artwork must be a supported PNG or JPEG') -def rectangle(pixels, width, x, y, w, h, color): - row = bytes(color) * w - for yy in range(y, y + h): - start = (yy * width + x) * 4 - pixels[start:start + len(row)] = row - - -def lettering(pixels, width, text, y, scale, color): - x = (width - (len(text) * 6 - 1) * scale) // 2 - for ch in text: - glyph = bytes.fromhex(GLYPHS.get(ch, GLYPHS['?'])) - for yy, bits in enumerate(glyph): - for xx in range(5): - if bits & (1 << (4 - xx)): - rectangle(pixels, width, x + xx * scale, y + yy * scale, scale, scale, color) - x += 6 * scale - - def stamp(pixels, width, icon, x, y, size): iw, ih, source = icon dw, dh = max(1, size * iw // max(iw, ih)), max(1, size * ih // max(iw, ih)) @@ -182,60 +149,45 @@ def stamp(pixels, width, icon, x, y, size): pixels[dst + 3] = a + pixels[dst + 3] * (255 - a) // 255 -def fallback(label, icon_png=None): - text = unicodedata.normalize('NFKD', label).encode('ascii', 'ignore').decode().upper().strip() or 'ANDROID APP' - text = ' '.join(text.split())[:80] - seed = hashlib.sha256(label.encode()).digest() - accent = tuple(100 + c % 110 for c in seed[:3]) + (255,) - try: - icon = decode(icon_png) - except (ValueError, zlib.error, struct.error): - icon = None - out = {} - for slot, (w, h) in SLOTS.items(): - pixels = bytearray(w * h * 4) - if slot != 'logo': - for y in range(h): - color = (14 + 12 * y // h, 22 + 16 * y // h, 34 + 22 * y // h, 255) - rectangle(pixels, w, 0, y, w, 1, color) - rectangle(pixels, w, w // 12, h - max(6, h // 70), w * 5 // 6, max(3, h // 180), accent) - size = {'grid': 320, 'wide': 190, 'hero': 640, 'logo': 0, 'icon': 192}[slot] - if size: - x, y = (w - size) // 2, {'grid': 160, 'wide': 38, 'hero': 200, 'icon': 32}[slot] - if icon: - stamp(pixels, w, icon, x, y, size) - else: - scale = size // 9 - lettering(pixels, w, text[0], y + (size - scale * 7) // 2, scale, accent) - if slot != 'icon': - scale = {'grid': 5, 'wide': 5, 'hero': 12, 'logo': 10}[slot] - limit = (w - w // 6) // (scale * 6) - lines = textwrap.wrap(text, limit)[:3 if slot in ('grid', 'logo') else 2] - if len(textwrap.wrap(text, limit)) > len(lines): - lines[-1] = lines[-1][:-3] + '...' - y = {'grid': 580, 'wide': 280, 'hero': 930, 'logo': (h - len(lines) * scale * 9) // 2}[slot] - for line in lines: - lettering(pixels, w, line, y, scale, (240, 245, 250, 255)) - y += scale * 9 - out[slot] = ('png', png(w, h, pixels)) - return out + +def fetch(value): + if isinstance(value, str): + if urllib.parse.urlsplit(value).scheme not in ('http', 'https'): + raise ValueError('artwork URLs must use HTTP(S)') + request = urllib.request.Request(value, headers={'User-Agent': 'FrameControl/1.0'}) + with urllib.request.urlopen(request, timeout=20) as response: + if urllib.parse.urlsplit(response.geturl()).scheme not in ('http', 'https'): + raise ValueError('artwork redirect must use HTTP(S)') + value = response.read(MAX_IMAGE + 1) + return image_type(value), value def prepare(label, icon_png=None, artwork=None): + """Gather inputs; the Frame's Chromium canvas renders every final slot.""" + import frame_steamgriddb artwork = artwork or {} - if not isinstance(artwork, dict) or set(artwork) - set(SLOTS): - raise ValueError('artwork slots are grid, wide, hero, logo and icon') - supplied = {} + allowed = set(SLOTS) | {'banner', 'feature_graphic', 'screenshots', 'screenshot'} + if not isinstance(artwork, dict) or set(artwork) - allowed: + raise ValueError('unknown artwork slot') + supplied, warnings = {}, [] for slot, value in artwork.items(): - if isinstance(value, str): - if urllib.parse.urlsplit(value).scheme not in ('http', 'https'): - raise ValueError('artwork URLs must use HTTP(S)') - request = urllib.request.Request(value, headers={'User-Agent': 'FrameControl/1.0'}) - with urllib.request.urlopen(request, timeout=20) as response: - if urllib.parse.urlsplit(response.geturl()).scheme not in ('http', 'https'): - raise ValueError('artwork redirect must use HTTP(S)') - value = response.read(MAX_IMAGE + 1) - supplied[slot] = (image_type(value), value) - result = fallback(label, supplied.get('icon', ('', icon_png))[1]) - result.update(supplied) - return result + values = value if slot == 'screenshots' and isinstance(value, (list, tuple)) else [value] + for candidate in values[:4]: + try: + supplied['screenshot' if slot == 'screenshots' else slot] = fetch(candidate) + break + except (ValueError, OSError): + warnings.append('Source ' + slot + ' unavailable; using fallback art') + if 'icon' not in supplied and icon_png: + try: + supplied['icon'] = fetch(icon_png) + except (ValueError, OSError): + pass + provider, provider_warnings = frame_steamgriddb.lookup(label) + warnings.extend(provider_warnings) + for slot, value in provider.items(): + try: + supplied[slot] = fetch(value) + except (ValueError, OSError): + warnings.append('SteamGridDB ' + slot + ' download failed; using fallback art') + return supplied, warnings diff --git a/ui/frame_steamgriddb.py b/ui/frame_steamgriddb.py new file mode 100644 index 0000000..2a580c9 --- /dev/null +++ b/ui/frame_steamgriddb.py @@ -0,0 +1,103 @@ +"""Optional SteamGridDB artwork. Credentials stay on the host, never in app metadata.""" +import json +import os +import re +import tempfile +import urllib.parse +import urllib.request + +import frame_host + +API = 'https://www.steamgriddb.com/api/v2' +MAX_JSON = 2 * 1024 * 1024 + + +def settings_path(): + return frame_host.data_dir('artwork-settings.json') + + +def api_key(): + env = os.environ.get('STEAMGRIDDB_API_KEY') or os.environ.get('FRAME_STEAMGRIDDB_API_KEY') + if env: + return env.strip() + try: + return str(json.loads(settings_path().read_text()).get('steamgriddb_api_key') or '') + except (OSError, ValueError, AttributeError): + return '' + + +def settings(): + return {'steamgriddb_configured': bool(api_key()), + 'environment': bool(os.environ.get('STEAMGRIDDB_API_KEY') or os.environ.get('FRAME_STEAMGRIDDB_API_KEY'))} + + +def save_settings(body): + key = body.get('steamgriddb_api_key') + if not isinstance(key, str) or len(key) > 200 or (key and not re.fullmatch(r'[A-Za-z0-9_-]+', key)): + raise ValueError('enter a valid SteamGridDB API key, or an empty value to remove it') + path = settings_path() + path.parent.mkdir(parents=True, exist_ok=True) + fd, temp = tempfile.mkstemp(prefix='.artwork-', dir=str(path.parent)) + try: + with os.fdopen(fd, 'w') as f: + json.dump({'steamgriddb_api_key': key}, f) + os.replace(temp, path) + finally: + if os.path.exists(temp): + os.remove(temp) + return settings() + + +def _get(path, key): + request = urllib.request.Request(API + path, headers={'Authorization': 'Bearer ' + key, + 'User-Agent': 'FrameControl/1.0'}) + # Do not carry the credential to redirects or include it in error messages. + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + with urllib.request.build_opener(NoRedirect()).open(request, timeout=12) as response: + data = response.read(MAX_JSON + 1) + if len(data) > MAX_JSON: + raise ValueError('SteamGridDB response too large') + result = json.loads(data) + if not result.get('success') or not isinstance(result.get('data'), list): + raise ValueError('SteamGridDB lookup failed') + return result['data'] + + +def _name(value): + return re.sub(r'[^a-z0-9]', '', str(value).casefold()) + + +def lookup(name): + """Best-voted art per slot for an exact title match; unrelated games are never guessed.""" + key = api_key() + if not key: + return {}, [] + try: + matches = _get('/search/autocomplete/' + urllib.parse.quote(name, safe=''), key) + names = {_name(name), _name(re.sub(r'\s+VR$', '', name, flags=re.I))} + game = next((g for g in matches if _name(g.get('name')) in names), None) + if not game: + return {}, [] + gid = int(game['id']) + result, warnings = {}, [] + for slot, kind, dimensions in [('grid', 'grids', '600x900'), ('wide', 'grids', '920x430'), + ('hero', 'heroes', ''), ('logo', 'logos', ''), ('icon', 'icons', '')]: + try: + query = {'types': 'static', 'nsfw': 'false', 'humor': 'false', 'mimes': 'image/png,image/jpeg'} + if dimensions: + query['dimensions'] = dimensions + records = _get('/' + kind + '/game/' + str(gid) + '?' + urllib.parse.urlencode(query), key) + records = [r for r in records if r.get('url', '').startswith('https://') and not r.get('nsfw')] + if dimensions: + w, h = map(int, dimensions.split('x')) + records = [r for r in records if (r.get('width'), r.get('height')) == (w, h)] + records.sort(key=lambda r: (int(r.get('score') or 0), int(r.get('upvotes') or 0)), reverse=True) + if records: + result[slot] = records[0]['url'] + except (OSError, ValueError, KeyError, TypeError): + warnings.append('SteamGridDB ' + slot + ' unavailable; using source or generated art') + return result, warnings + except (OSError, ValueError, KeyError, TypeError): + return {}, ['SteamGridDB unavailable; using source or generated art'] diff --git a/ui/frame_titles.py b/ui/frame_titles.py index bc208a0..d2d3588 100644 --- a/ui/frame_titles.py +++ b/ui/frame_titles.py @@ -632,7 +632,7 @@ def _rsync(): _install_lock = threading.Lock() -def install(path, name=None, exe=None, runtime=None, progress=None): +def install(path, name=None, exe=None, runtime=None, progress=None, artwork=None): """Sideload a .zip, folder or executable as a Devkit Game; returns the title dict. name: the Steam name (sanitised to the title id), default from the file name. @@ -643,12 +643,12 @@ def install(path, name=None, exe=None, runtime=None, progress=None): """ plan = inspect(path, name) try: - return install_plan(plan, name=name, exe=exe, runtime=runtime, progress=progress) + return install_plan(plan, name=name, exe=exe, runtime=runtime, progress=progress, artwork=artwork) finally: discard(plan) -def install_plan(plan, name=None, exe=None, runtime=None, progress=None): +def install_plan(plan, name=None, exe=None, runtime=None, progress=None, artwork=None): """Install an inspect() plan, optionally with another name, target or runtime.""" if name: plan['name'], plan['id'] = name, title_id(name) @@ -656,13 +656,21 @@ def install_plan(plan, name=None, exe=None, runtime=None, progress=None): _choose(plan, exe or plan['target'], runtime) step = progress or (lambda *a: None) with _install_lock: - return _install(plan, step) + return _install(plan, step, artwork) -def _install(plan, step): +def _install(plan, step, artwork=None): gid = plan['id'] if not NEW_ID_RE.match(gid) or gid.lower() in RESERVED_IDS: raise FrameError(f'bad title id {gid!r}') + icon = None + for filename in ('icon.png', 'logo.png'): + path = os.path.join(plan['root'], filename) + if os.path.isfile(path): + with open(path, 'rb') as f: + icon = f.read(frame_android.frame_artwork.MAX_IMAGE + 1) + break + images, warnings = frame_android.frame_artwork.prepare(plan['name'], icon, artwork) step("Syncing Valve's devkit tools to the Frame", 0.02) ensure_utils() existed = ssh(f'test -d {GAMES}/{gid} && echo yes || true', timeout=30).strip() == 'yes' @@ -672,6 +680,7 @@ def _install(plan, step): if not DIR_RE.match(directory) or not directory.endswith(f'/{GAMES}/{gid}'): raise FrameError(f'steamos-prepare-upload returned an unexpected folder {directory!r}') registered = False + library_ready, shortcut, steam_registered = False, None, False try: step(f"Copying {plan['size'] / 1e6:.0f} MB to the Frame", 0.1) if _rsync(): @@ -694,10 +703,26 @@ def _install(plan, step): err = str(reply['error']).strip().rstrip('.') hint = ' With Steam running on the Frame, install it again.' if 'not running' in err else '' raise FrameError(f"Uploaded, but Steam didn't register it: {err}.{hint}") + steam_registered = True + shortcut = _library_shortcut(gid, plan['name']) + if not shortcut: + raise FrameError('Steam registered the title but its shortcut is not available for mandatory artwork; retry install') + result = frame_android.apply_library(shortcut, plan['name'], directory + '/.frame-artwork', images, + category='Sideloaded', details={'source': plan['source']}) + meta.update(shortcut=shortcut, library_warnings=warnings + result.get('warnings', [])) + ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30) + library_ready = True step('Done', 1.0) meta.update(runtime_label=RUNTIMES[plan['runtime']]['label'], steam=str(reply.get('success', '')).strip()) return meta finally: + if steam_registered and not library_ready and not existed: + # A newly registered title must not remain as a blank library tile. + if shortcut: + frame_android.shortcut_tool('remove', str(shortcut)) + else: + ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120) + registered = False if not registered and not existed: # A first install that failed part-way: don't leave an orphan folder behind. try: @@ -706,6 +731,23 @@ def _install(plan, step): pass + +def _library_shortcut(gid, name): + shortcuts = json.loads(frame_android.shortcut_tool('list')) + matches = [s for s in shortcuts if s.get('devkit_gameid') == gid] + if not matches: + try: + meta = json.loads(ssh(f'cat {GAMES}/{gid}-framecontrol.json 2>/dev/null || true')) + matches = [s for s in shortcuts if s.get('appid') == meta.get('shortcut')] + except (ValueError, AttributeError): + pass + if not matches: + matches = [s for s in shortcuts if s.get('name') in (gid, name)] + if len(matches) > 1: + raise FrameError('ambiguous Steam shortcut for ' + gid) + return int(matches[0]['appid']) if matches else None + + LIST_SCRIPT = r''' import json, os root = os.path.expanduser('~/devkit-game') @@ -772,6 +814,9 @@ def remove(gid): try: gid = _check_id(gid) ensure_utils() + shortcut = _library_shortcut(gid, gid) + if shortcut: + frame_android.shortcut_tool('remove', str(shortcut)) # steamos-delete removes the folder and syncs Steam's shortcuts; its json files stay, so clear them too. ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120) ssh(f'rm -f {_json_files(gid)}', timeout=30) diff --git a/ui/index.html b/ui/index.html index 073e2b7..c3dbe42 100644 --- a/ui/index.html +++ b/ui/index.html @@ -628,6 +628,19 @@ +
+

Library artwork settings

+

SteamGridDB supplies community artwork for sideloaded games. Without a key, Frame Control uses source images and creates the remaining art.

+ + +
+ + + Get a free key +
+
+ +

Remote & power

@@ -2305,5 +2318,6 @@ setView("headset"); refresh().then(loadShots); // after status, so app names resolve document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); }); + diff --git a/ui/server.py b/ui/server.py index 8000468..ca2b733 100755 --- a/ui/server.py +++ b/ui/server.py @@ -39,6 +39,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) import frame_android # noqa: E402 import frame_apk_versions # noqa: E402 import frame_catalog # noqa: E402 +import frame_steamgriddb import frame_host # noqa: E402 import frame_store # noqa: E402 import frame_titles # noqa: E402 @@ -588,6 +589,11 @@ def android(body): return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.", "app": m} return start_job(f"Install {pkg}", work) + if action == "refresh-art": + if not pkg and not body.get("all"): + raise Failure('choose a package or all apps', 400) + return start_job('Refresh Steam artwork', lambda: { + 'apps': frame_android.refresh_art(None if body.get('all') else pkg)}) if action in ("launch", "stop"): m = getattr(frame_android, action)(pkg) return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"} @@ -1227,7 +1233,9 @@ def _sweep_one(prefix, d): pass -POST = {"/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard, +POST = { + "/api/settings/artwork": frame_steamgriddb.save_settings, + "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard, "/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots, "/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start, "/api/webinstall/cancel": webinstall_cancel} @@ -1331,6 +1339,10 @@ class Handler(BaseHTTPRequestHandler): try: if path in ("/", "/index.html"): self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8") + elif path == "/api/settings/artwork": + self.send_json(frame_steamgriddb.settings()) + elif path == "/artwork-settings.js": + self.send_bytes((HERE / 'artwork-settings.js').read_bytes(), 'text/javascript; charset=utf-8') elif path == "/api/host": self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else {"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,