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:
saphidandGPT-6 Astra committed 2026-09-28 21:15:43 +10:00
1 parent 268afccf05
commit c06b3285f2
14 files changed
+969 -19

No files matched your search

+105
View File
@@ -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).
+47
View File
@@ -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
+33 -1
View File
@@ -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"
+93 -2
View File
@@ -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)];
+17
View File
@@ -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.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 834 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 B

+12
View File
@@ -0,0 +1,12 @@
{
"home": "/home/steamos",
"shortcuts": [
{
"appid": 3346865537,
"name": "Open Saber Plus"
}
],
"configure": {
"warnings": []
}
}
+350
View File
@@ -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
View File
@@ -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
View File
@@ -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__':
+241
View File
@@ -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