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] 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