mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 13:00:33 +02:00
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 <noreply@openai.com>
This commit is contained in:
1 parent
268afccf05
commit
c06b3285f2
14 files changed
+969
-19
No files matched your search
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -5,6 +5,8 @@ Python stdlib only; the Mac runs it with `ssh frame python3 - <args> < 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__)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)];
|
||||
|
||||
Vendored
+17
@@ -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.
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 834 B |
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 77 B |
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"home": "/home/steamos",
|
||||
"shortcuts": [
|
||||
{
|
||||
"appid": 3346865537,
|
||||
"name": "Open Saber Plus"
|
||||
}
|
||||
],
|
||||
"configure": {
|
||||
"warnings": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
"""Offline artwork, Steam API and launcher supervision regressions."""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
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 art
|
||||
|
||||
spec = importlib.util.spec_from_file_location('steam_shortcuts', ROOT / 'frame/android/steam_shortcuts.py')
|
||||
shortcuts = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(shortcuts)
|
||||
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'POSIX launcher')
|
||||
class LauncherTests(unittest.TestCase):
|
||||
def exercise(self, terminate, sig=signal.SIGTERM, blocked=None):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
d = Path(tmp)
|
||||
app = d / 'Applications/Android/org.test.app'
|
||||
app.mkdir(parents=True)
|
||||
(app / 'launch.sh').write_bytes((ROOT / 'frame/android/lepton-app.sh').read_bytes())
|
||||
(app / 'app.apk').touch()
|
||||
(app / 'instance.id').write_text('2800000001')
|
||||
bin_dir = d / 'bin'
|
||||
bin_dir.mkdir()
|
||||
lepton = d / '.local/share/Steam/steamapps/common/Lepton/lepton'
|
||||
lepton.parent.mkdir(parents=True)
|
||||
def script(path, body):
|
||||
path.write_text('#!' + sys.executable + '\n' + body)
|
||||
path.chmod(0o755)
|
||||
script(lepton, 'import os,time\nfrom pathlib import Path\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')
|
||||
script(bin_dir / 'flock', 'import os\nraise SystemExit(1 if os.environ.get("TEST_LOCKED") else 0)\n') # lock semantics belong to Linux; no flock on macOS
|
||||
script(bin_dir / 'podman', 'import os,sys\nfrom pathlib import Path\n'
|
||||
'p=Path(os.environ["HOME"],"podman-calls")\n'
|
||||
'with p.open("a") as f: f.write(" ".join(sys.argv[1:])+"\\n")\n'
|
||||
'if sys.argv[1:2]==["inspect"] and os.environ.get("TEST_RUNNING"): print("true")\n')
|
||||
env = {**os.environ, 'HOME': str(d), 'PATH': str(bin_dir) + os.pathsep + os.environ['PATH']}
|
||||
if blocked:
|
||||
env['TEST_' + blocked] = '1'
|
||||
saved = d / '.local/share/Steam/steamapps/compatdata/2800000001/internal/save'
|
||||
saved.parent.mkdir(parents=True)
|
||||
saved.write_text('saved game')
|
||||
proc = subprocess.Popen(['bash', str(app / 'launch.sh')], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
try:
|
||||
if blocked:
|
||||
proc.communicate(timeout=5)
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertFalse((d / 'started').exists())
|
||||
calls = (d / 'podman-calls').read_text() if (d / 'podman-calls').exists() else ''
|
||||
self.assertNotIn('stop ', calls)
|
||||
return
|
||||
deadline = time.monotonic() + 5
|
||||
while not (d / 'started').exists() and proc.poll() is None and time.monotonic() < deadline:
|
||||
time.sleep(.02)
|
||||
self.assertTrue((d / 'started').exists(), 'launcher did not start Lepton')
|
||||
if terminate:
|
||||
self.assertIsNone(proc.poll(), 'Steam-tracked wrapper exited during the session')
|
||||
proc.send_signal(sig)
|
||||
_, err = proc.communicate(timeout=5)
|
||||
calls = (d / 'podman-calls').read_text() if (d / 'podman-calls').exists() else ''
|
||||
self.assertIn('stop -t 5 lepton-steamlaunch-2800000001', calls, err.decode())
|
||||
self.assertEqual(proc.returncode, 128 + sig if terminate else 23)
|
||||
self.assertEqual(saved.read_text(), 'saved game')
|
||||
self.assertTrue((app / 'app.apk').exists())
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
proc.communicate()
|
||||
if (d / 'started').exists():
|
||||
try:
|
||||
os.kill(int((d / 'started').read_text()), signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
def test_steam_stop_cleans_container(self):
|
||||
self.exercise(True)
|
||||
|
||||
def test_hangup_and_interrupt_cleanup(self):
|
||||
for sig in (signal.SIGHUP, signal.SIGINT):
|
||||
with self.subTest(sig=sig):
|
||||
self.exercise(True, sig)
|
||||
|
||||
def test_duplicate_launch_leaves_existing_session_alone(self):
|
||||
for blocked in ('LOCKED', 'RUNNING'):
|
||||
with self.subTest(blocked=blocked):
|
||||
self.exercise(False, blocked=blocked)
|
||||
|
||||
def test_normal_exit_cleans_container_and_keeps_exit_code(self):
|
||||
self.exercise(False)
|
||||
|
||||
|
||||
FIXTURES = ROOT / 'tests/fixtures/library'
|
||||
|
||||
|
||||
class ArtworkTests(unittest.TestCase):
|
||||
def test_icon_roundtrip_and_transparency(self):
|
||||
w, h, pixels = art.decode((FIXTURES / 'icon.png').read_bytes())
|
||||
self.assertEqual((w, h), (2, 2))
|
||||
self.assertEqual(pixels, bytes([255, 0, 0, 255, 0, 255, 0, 255,
|
||||
0, 0, 255, 128, 0, 0, 0, 0]))
|
||||
background = bytearray([10, 20, 30, 255] * 4)
|
||||
art.stamp(background, 2, (w, h, pixels), 0, 0, 2)
|
||||
self.assertEqual(background[:8], pixels[:8])
|
||||
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):
|
||||
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()
|
||||
+37
-15
@@ -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}')
|
||||
|
||||
+4
-1
@@ -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__':
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user