diff --git a/docs/vr-apks.md b/docs/vr-apks.md index c8b5ff5..7f6adef 100644 --- a/docs/vr-apks.md +++ b/docs/vr-apks.md @@ -104,11 +104,20 @@ non-NSFW image in each slot. Provider failures use the next source. Sources pass `install(apk_path, artwork={...})`: keys are `grid`, `wide`, `hero`, `logo`, `icon`, `banner`, `feature_graphic`, `screenshot`, or a list -`screenshots`. Values are PNG/JPEG bytes or HTTP(S) URLs (12 MiB/8 million -pixels maximum). Banners and feature graphics supply hero/wide art; +`screenshots`. Values are PNG/JPEG bytes or HTTP(S) URLs (12 MiB and +4096×4096 pixels maximum; any PNG depth or interlace, since the Frame's +Chromium decodes them). URLs must resolve to public addresses, follow at most +three redirects and share one deadline per install. Any source that fails, +for any reason, becomes a warning and generated art. Banners and feature graphics supply hero/wide art; screenshots are the next fallback. Source images are cached for refresh. All images are fitted to 600×900 portrait, 920×430 wide, 3840×1240 hero, 1280×480 logo and 256×256 icon. Explicit logos retain transparency. +Photo-based portrait, wide and hero slots are JPEG: Steam takes at most +12 MiB per slot, and on the Frame (2026-09-28) a noise-heavy 3840×1240 hero +came to more than 12 MiB as PNG, 3.7 MB as JPEG (2.7 s to render); a +landscape photo hero 5.6 MB as PNG, 0.76 MB as JPEG (0.75 s). A render that +still fails is retried once with generated art. Steam keeps a slot's `.png` +and `.jpg` side by side, so each slot is cleared before it is set. Generated art uses the APK icon, a dominant-colour gradient, a blurred backdrop and large foreground icon with shadow. Steam's Chromium canvas and @@ -124,10 +133,26 @@ python3 ui/frame_android.py refresh-art org.godotengine.open_saber_plus python3 ui/frame_android.py refresh-art --all ``` -The settings panel offers the same refresh-all action. The API is -`POST /api/android` with `{"action":"refresh-art","all":true}` or a -`package` instead of `all`; it returns a background job. Batch results retain -per-app errors, and the CLI exits nonzero if any failed. +Devkit titles installed by Frame Control have the same command, +`python3 ui/frame_titles.py refresh-art ID|--all`. The settings panel's +refresh covers both. The API is `POST /api/android` with +`{"action":"refresh-art","all":true}` (apps and titles) or a `package`, and +`POST /api/titles` with `{"action":"refresh-art","id":…}`; each returns a +background job. Batch results retain per-item errors, and the CLIs exit +nonzero if any failed. Apps and titles without complete artwork show **Add +artwork**, `list` prints the command, and when Frame Control lists them while +Steam is running it re-applies their art in the background (at most every +five minutes), for example for a title Steam registered after an install made +while it wasn't running. + +Steam's app overviews carry no `devkit_gameid` (checked 2026-09-28, build +20260925.6191901, on every non-Steam shortcut). A title's shortcut is found by +its saved id, or by an executable or start folder inside +`~/devkit-game//`, read from `appDetailsStore`; never by display name. +That the devkit shortcut's exe/start folder sit inside the title folder is +inferred from `docs/sideloading.md` (`proton waitforexitandrun +"/home/steamos/devkit-game//"`), not yet seen in app details. +Devkit titles keep the VR flag Steam gave them. **Verified on build 20260925.6191901, SteamVR 2.18.1 (2026-09-28):** both Open Saber Plus and SuperTux were backfilled. Steam's cached portrait, wide, @@ -148,8 +173,11 @@ supported shortcut description/store-page, developer/publisher, release metadata or custom achievement API was found; these are not fabricated. The launcher supervises Lepton and handles TERM/INT/HUP and normal exit by -stopping its own container and child process group. A lock and container check -refuse duplicate launches. Steam Stop uses `TerminateApp` with the exact +stopping its own container and child process group. A lock refuses duplicate launches; +a container still running while the lock is free was orphaned by a killed +launcher and is stopped before the new launch. Lepton doesn't inherit the +lock. Removing an app or title still deletes its files when Steam isn't +running; tidying Steam's collections and artwork is best effort. Steam Stop uses `TerminateApp` with the exact 64-bit game ID string. Frame Control's Stop additionally has a direct-container fallback. The stable instance ID and compatdata paths remain unchanged. diff --git a/frame/android/lepton-app.sh b/frame/android/lepton-app.sh index 119850b..594a6a9 100644 --- a/frame/android/lepton-app.sh +++ b/frame/android/lepton-app.sh @@ -30,9 +30,11 @@ fi exec 9>"$DIR/launch.lock" flock -n 9 || { echo "Android app is already running" >&2; exit 1; } CONTAINER="lepton-steamlaunch-$SteamAppId" +# Holding the lock means no launcher owns a running container: it was orphaned +# (this script SIGKILLed), so stop it rather than refuse every later Play. if [[ "$(podman inspect --format '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" == true ]]; then - echo "Android container is already running" >&2 - exit 1 + echo "Stopping orphaned $CONTAINER" >&2 + podman stop -t 5 "$CONTAINER" >/dev/null 2>&1 || true fi export STEAM_COMPAT_INSTALL_PATH="$DIR" # Must be under ~/.local/share/Steam: only that tree is mounted in the container. @@ -63,7 +65,8 @@ trap cleanup EXIT trap 'exit 143' TERM trap 'exit 130' INT trap 'exit 129' HUP -setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" & +# 9>&-: the lock is this launcher's alone; Lepton's tree mustn't keep it held. +setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" 9>&- & child=$! rc=0 wait "$child" || rc=$? diff --git a/frame/android/library_artwork.js b/frame/android/library_artwork.js index 9680dc5..89ef0d6 100644 --- a/frame/android/library_artwork.js +++ b/frame/android/library_artwork.js @@ -10,7 +10,7 @@ async function renderLibraryArtwork(input) { const img = new Image(); img.src = `data:image/${item[0]};base64,${item[1]}`; await img.decode(); - if (!img.width || !img.height || img.width*img.height > 8000000) throw Error('dimensions'); + if (!img.width || !img.height || img.width*img.height > 16777216) throw Error('dimensions'); images[slot] = img; } catch (_) { warnings.push(`${slot} could not be decoded; generated art used`); } } @@ -138,7 +138,9 @@ async function renderLibraryArtwork(input) { if (slot==='wide') title(ctx,w,h,h*.69,h*.92,52); // Hero intentionally has no title: Steam overlays the transparent logo. } - result[slot]=canvas.toDataURL('image/png').split(',')[1]; + // Photos as PNG can pass Steam's 12 MiB limit at hero size; the logo keeps its transparency. + const jpeg=scene && slot!=='logo' && slot!=='icon'; + result[slot]=[jpeg?'jpg':'png', canvas.toDataURL(jpeg?'image/jpeg':'image/png',.9).split(',')[1]]; } return {images:result,warnings,font}; } diff --git a/frame/android/steam_shortcuts.py b/frame/android/steam_shortcuts.py index 79fbb1f..5e3a405 100644 --- a/frame/android/steam_shortcuts.py +++ b/frame/android/steam_shortcuts.py @@ -24,10 +24,10 @@ def target_ws(): class WS: """Just enough RFC 6455 for one CDP request/response on loopback.""" - def __init__(self, url): + def __init__(self, url, timeout=20): host_port, path = url[len('ws://'):].split('/', 1) host, port = host_port.split(':') - self.s = socket.create_connection((host, int(port)), timeout=20) + self.s = socket.create_connection((host, int(port)), timeout=timeout) key = base64.b64encode(os.urandom(16)).decode() self.s.sendall((f'GET /{path} HTTP/1.1\r\nHost: {host_port}\r\nUpgrade: websocket\r\n' f'Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n' @@ -71,8 +71,8 @@ class WS: return msg.decode() -def evaluate(js): - ws = WS(target_ws()) +def evaluate(js, timeout=20): + ws = WS(target_ws(), timeout) ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate', 'params': { 'expression': js, 'awaitPromise': True, 'returnByValue': True}})) while True: @@ -89,8 +89,8 @@ def evaluate(js): ASSETS = {'grid': 0, 'hero': 1, 'logo': 2, 'wide': 3, 'icon': 4} -def collections_js(appid, vr=None, category='Android'): - wanted = [] if vr is None else [category, *(['Android VR'] if vr and category == 'Android' else [])] +def collections_js(appid, wanted=()): + wanted = list(wanted) return f'''async function syncCollections() {{ const wanted = {json.dumps(wanted)}; if (typeof collectionStore === "undefined" || @@ -147,6 +147,9 @@ def notes_js(name, details): }}''' +MAX_ART = 12 * 1024 * 1024 # Steam's custom artwork limit per slot + + def render(plan): with open(plan) as f: source = json.load(f) @@ -154,32 +157,50 @@ def render(plan): for slot, path in source['images'].items(): ext = os.path.splitext(path)[1][1:] with open(path, 'rb') as f: - data = f.read(12 * 1024 * 1024 + 1) - if len(data) > 12 * 1024 * 1024: + data = f.read(MAX_ART + 1) + if len(data) > MAX_ART: raise ValueError('source artwork too large') images[slot] = [ext, base64.b64encode(data).decode()] renderer = globals().get('ART_RENDERER') if renderer is None: with open(os.path.join(os.path.dirname(__file__), 'library_artwork.js')) as f: renderer = f.read() - result = evaluate(renderer + '\nrenderLibraryArtwork(' + json.dumps({'label': source['label'], 'images': images}) + ')') + try: + return _render(plan, renderer, source['label'], images) + except (ValueError, OSError, EOFError, SystemExit) as e: + # Generated art from the icon alone always fits; a photo that didn't must not fail the install. + result = _render(plan, renderer, source['label'], {k: v for k, v in images.items() if k == 'icon'}) + result['warnings'].insert(0, 'Source artwork could not be rendered (' + str(e)[:120] + '); generated art used') + return result + + +def _render(plan, renderer, label, images): + # A 4K photo takes seconds to decode and encode on the Frame; allow well beyond that. + result = evaluate(renderer + '\nrenderLibraryArtwork(' + json.dumps({'label': label, 'images': images}) + ')', + timeout=75) if not isinstance(result, dict) or set(result.get('images', {})) != set(ASSETS): raise ValueError('incomplete artwork render') paths = {} - for slot, encoded in result['images'].items(): - path = os.path.join(os.path.dirname(plan), slot + '.png') + for slot, (ext, encoded) in result['images'].items(): data = base64.b64decode(encoded, validate=True) - if not data.startswith(b'\x89PNG\r\n\x1a\n') or len(data) > 12 * 1024 * 1024: - raise ValueError('invalid rendered image') - with open(path + '.tmp', 'wb') as f: + signature = {'png': b'\x89PNG\r\n\x1a\n', 'jpg': b'\xff\xd8\xff'}.get(ext) + if not signature or not data.startswith(signature) or len(data) > MAX_ART: + raise ValueError(slot + ' render is ' + str(len(data)) + ' bytes of ' + str(ext)) + paths[slot] = os.path.join(os.path.dirname(plan), slot + '.' + ext) + with open(paths[slot] + '.tmp', 'wb') as f: f.write(data) + for slot, path in paths.items(): os.replace(path + '.tmp', path) - paths[slot] = path - return {'paths': paths, 'warnings': result.get('warnings', [])} + for stale in ('png', 'jpg'): + other = os.path.join(os.path.dirname(plan), slot + '.' + stale) + if other != path and os.path.exists(other): + os.remove(other) + return {'paths': paths, 'warnings': list(result.get('warnings', []))} def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None): options = options or {} + category = options.get('category', 'Android') if set(artwork) != set(ASSETS): raise ValueError('all five Steam artwork slots are required') images = [] @@ -190,8 +211,8 @@ def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None): 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: + data = f.read(MAX_ART + 1) + if len(data) > MAX_ART: raise ValueError('artwork is too large') if slot != 'icon': # Frame's custom-art API maps type 4 to Header; use SetShortcutIcon. images.append([ASSETS[slot], ext, base64.b64encode(data).decode()]) @@ -203,30 +224,41 @@ def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None): if (typeof SteamClient.Apps.SetShortcutSortAs === "function") SteamClient.Apps.SetShortcutSortAs(id, {json.dumps(name)}); SteamClient.Apps.SetShortcutIcon(id, {json.dumps(icon)}); - if (typeof SteamClient.Apps.SetShortcutIsVR === "function") - SteamClient.Apps.SetShortcutIsVR(id, {json.dumps(vr)}); - else warnings.push("Steam VR shortcut flag API unavailable"); + // null (devkit titles): leave the VR flag as Steam registered it. + if ({json.dumps(vr)} !== null) {{ + 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)}) + for (const [type, ext, data] of {json.dumps(images)}) {{ + // Steam keeps a slot's PNG and JPEG side by side; clear it so a stale one can't win. + if (typeof SteamClient.Apps.ClearCustomArtworkForApp === "function") + try {{ await SteamClient.Apps.ClearCustomArtworkForApp(id, type); }} catch (e) {{}} await SteamClient.Apps.SetCustomArtworkForApp(id, data, ext, type); + }} }} else throw new Error("Steam artwork API unavailable; installation is incomplete"); - {collections_js(int(appid), vr, options.get('category', 'Android'))} + {collections_js(int(appid), [category] + (['Android VR'] if vr and category == 'Android' else []))} try {{ warnings.push(...await syncCollections()); }} catch (e) {{ warnings.push("Steam collections: " + String(e)); }} {notes_js(name, options.get('details', {}))} return {{warnings}}; - }})()''') + }})()''', timeout=60) def remove(appid): + # Collections and artwork are tidy-up: only a missing RemoveShortcut may fail the removal. return evaluate(f'''(async () => {{ - const id = {int(appid)}; + const id = {int(appid)}, warnings = []; {collections_js(int(appid))} - const warnings = await syncCollections(); + try {{ warnings.push(...await syncCollections()); }} + catch (e) {{ warnings.push("Steam collections: " + String(e)); }} if (typeof SteamClient.Apps.ClearCustomArtworkForApp === "function") {{ - for (const type of [0, 1, 2, 3]) - await SteamClient.Apps.ClearCustomArtworkForApp(id, type); - }} else throw new Error("Steam artwork removal API unavailable"); + for (const type of [0, 1, 2, 3]) {{ + try {{ await SteamClient.Apps.ClearCustomArtworkForApp(id, type); }} + catch (e) {{ warnings.push("Steam artwork " + type + ": " + String(e)); }} + }} + }} else warnings.push("Steam artwork removal API unavailable"); SteamClient.Apps.RemoveShortcut(id); return {{warnings}}; }})()''') @@ -246,13 +278,24 @@ def main(): }})()''' print(evaluate(js)) elif cmd == 'list': - js = '''(() => appStore.allApps.filter(a => a.app_type === 1073741824) - .map(a => ({appid: a.appid, name: a.display_name, devkit_gameid: a.devkit_gameid})))()''' + # Overviews carry no exe or devkit id (checked 2026-09-28); app details do, once registered. + js = '''(async () => Promise.all(appStore.allApps.filter(a => a.app_type === 1073741824).map(async a => { + let d = typeof appDetailsStore !== "undefined" && appDetailsStore.GetAppDetails(a.appid); + if (!d && typeof SteamClient.Apps.RegisterForAppDetails === "function") d = await new Promise(ok => { + let reg; + const timer = setTimeout(() => { if (reg) reg.unregister(); ok(null); }, 3000); + reg = SteamClient.Apps.RegisterForAppDetails(a.appid, x => { + clearTimeout(timer); setTimeout(() => reg && reg.unregister()); ok(x); }); + }); + return {appid: a.appid, name: a.display_name, devkit_gameid: a.devkit_gameid, + exe: d ? d.strShortcutExe || "" : "", start_dir: d ? d.strShortcutStartDir || "" : ""}; + })))()''' print(json.dumps(evaluate(js))) elif cmd == 'render': print(json.dumps(render(args[0]))) elif cmd == 'configure': - print(json.dumps(configure(int(args[0]), *args[1:5], args[5] == '1', json.loads(args[6]), + vr = {'1': True, '0': False}.get(args[5]) # '' leaves Steam's VR flag alone + print(json.dumps(configure(int(args[0]), *args[1:5], vr, json.loads(args[6]), json.loads(args[7]) if len(args) > 7 else None))) elif cmd == 'stop': evaluate(f'SteamClient.Apps.TerminateApp({json.dumps(str((int(args[0]) << 32) | 0x02000000))}, false)') diff --git a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub.js b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub.js index ac21da6..8a95e86 100644 --- a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub.js +++ b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub.js @@ -23,7 +23,8 @@ function surface() { drawImage(){canvas.draws++;},getImageData:()=>({data:new Uint8ClampedArray(canvas.width*canvas.height*4)}), createLinearGradient:()=>({addColorStop(){}}),createRadialGradient:()=>({addColorStop(){}})}; for(const method of ['save','restore','beginPath','rect','roundRect','clip','fillRect','putImageData','arc','fill','stroke'])ctx[method]=()=>{}; - canvas.getContext=()=>ctx;canvas.toDataURL=()=> 'data:image/png;base64,'+png(canvas.width,canvas.height); + canvas.getContext=()=>ctx;canvas.toDataURL=type=>type==='image/jpeg'?'data:image/jpeg;base64,'+Buffer.from('ffd8ffe000104a464946','hex').toString('base64'): + 'data:image/png;base64,'+png(canvas.width,canvas.height); canvases.push(canvas);return canvas; }}; class Image {constructor(){this.width=2;this.height=2;} async decode(){if(this.src.includes('YmFk'))throw Error('bad image');}} diff --git a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js index e0c2a52..cf0905e 100644 --- a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js +++ b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js @@ -71,6 +71,13 @@ function build(steam) { 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); }, }, + // Shortcut exe/start folder live in app details, not overviews (Frame, 2026-09-28). + appDetailsStore: { + GetAppDetails(id) { + const s = findShortcut(id); + return s ? { strShortcutExe: s.exe, strShortcutStartDir: s.start_dir, bShortcutIsVR: !!s.vr } : null; + }, + }, appStore: { get allApps() { return allApps(); }, GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; }, diff --git a/tests/fixtures/library/check-renderer.js b/tests/fixtures/library/check-renderer.js index 83490f4..1e2e06f 100644 --- a/tests/fixtures/library/check-renderer.js +++ b/tests/fixtures/library/check-renderer.js @@ -6,8 +6,13 @@ const stub=require(process.cwd()+'/tests/fakeframe/rootfs/usr/local/lib/fakefram const result=await ctx.renderLibraryArtwork({label:'Example Game',images:{icon:['png','fixture']}}); assert.deepEqual(Object.keys(result.images),['grid','wide','hero','logo','icon']); for(const [slot,size] of Object.entries({grid:[600,900],wide:[920,430],hero:[3840,1240],logo:[1280,480],icon:[256,256]})) { - const b=Buffer.from(result.images[slot],'base64');assert.equal(b.readUInt32BE(16),size[0]);assert.equal(b.readUInt32BE(20),size[1]); + assert.equal(result.images[slot][0],'png'); + const b=Buffer.from(result.images[slot][1],'base64');assert.equal(b.readUInt32BE(16),size[0]);assert.equal(b.readUInt32BE(20),size[1]); } + // A photo scene is JPEG (Steam's 12 MiB limit at hero size); the logo stays transparent PNG. + const photo=await ctx.renderLibraryArtwork({label:'Photo',images:{hero:['jpg','fixture'],banner:['jpg','fixture']}}); + for(const slot of ['wide','hero'])assert.equal(photo.images[slot][0],'jpg'); + for(const slot of ['grid','logo','icon'])assert.equal(photo.images[slot][0],'png'); const hero=surface.canvases.find(c=>c.width===3840);assert.equal(hero.text.length,0); const logo=surface.canvases.find(c=>c.width===1280);assert(logo.text.length);assert.equal(logo.draws,0); const before=surface.canvases.length;await ctx.renderLibraryArtwork({label:'No Icon',images:{}}); diff --git a/tests/test_frame_android_library.py b/tests/test_frame_android_library.py index dea31a6..fdee8cb 100644 --- a/tests/test_frame_android_library.py +++ b/tests/test_frame_android_library.py @@ -24,7 +24,7 @@ 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): + def exercise(self, terminate, sig=signal.SIGTERM, blocked=None, orphan=False): with tempfile.TemporaryDirectory() as tmp: d = Path(tmp) app = d / 'Applications/Android/org.test.app' @@ -44,6 +44,7 @@ class LauncherTests(unittest.TestCase): 'assert os.environ["SteamAppId"] == "2800000001"\n' 'assert os.environ["LEPTON_ENV_SteamAppId"] == "3346865537"\n' 'Path(os.environ["HOME"],"started").write_text(str(os.getpid()))\n' + 'try:\n os.fstat(9); Path(os.environ["HOME"],"inherited-lock").touch()\nexcept OSError: pass\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 @@ -54,6 +55,8 @@ class LauncherTests(unittest.TestCase): env = {**os.environ, 'HOME': str(d), 'PATH': str(bin_dir) + os.pathsep + os.environ['PATH']} if blocked: env['TEST_' + blocked] = '1' + if orphan: + env['TEST_RUNNING'] = '1' saved = d / '.local/share/Steam/steamapps/compatdata/2800000001/internal/save' saved.parent.mkdir(parents=True) saved.write_text('saved game') @@ -70,12 +73,14 @@ class LauncherTests(unittest.TestCase): 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') + self.assertFalse((d / 'inherited-lock').exists(), 'Lepton inherited the launch lock') 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(calls.count('stop -t 5'), 2 if orphan else 1) self.assertEqual(proc.returncode, 128 + sig if terminate else 23) self.assertEqual(saved.read_text(), 'saved game') self.assertTrue((app / 'app.apk').exists()) @@ -98,9 +103,11 @@ class LauncherTests(unittest.TestCase): 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) + self.exercise(False, blocked='LOCKED') + + def test_orphaned_container_is_stopped_and_play_proceeds(self): + # Container running but the lock free: its launcher was SIGKILLed. + self.exercise(False, orphan=True) def test_normal_exit_cleans_container_and_keeps_exit_code(self): self.exercise(False) @@ -110,29 +117,17 @@ 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_source_inputs_and_url(self): - import io + from apk_sources import _images data = (FIXTURES / 'icon.png').read_bytes() - response = io.BytesIO(data) - response.geturl = lambda: 'https://example.org/icon.png' with patch('frame_steamgriddb.lookup', return_value=({}, [])), \ - patch.object(art.urllib.request, 'urlopen', return_value=response) as fetch: + patch.object(_images, 'fetch', return_value=(data, 'image/png')) as fetch: images, warnings = art.prepare('Game', artwork={'banner': data, 'icon': 'https://example.org/icon.png'}) self.assertEqual(images['banner'], ('png', data)) self.assertEqual(images['icon'], ('png', data)) self.assertEqual(warnings, []) - self.assertEqual(fetch.call_count, 1) + self.assertEqual(fetch.call_args.args[0], 'https://example.org/icon.png') + self.assertIsNotNone(fetch.call_args.kwargs['deadline']) def test_provider_precedence_and_bad_source_fallback(self): data = (FIXTURES / 'icon.png').read_bytes() @@ -143,48 +138,63 @@ class ArtworkTests(unittest.TestCase): self.assertEqual(images['icon'], ('png', data)) self.assertEqual(images['screenshot'], ('png', data)) self.assertNotIn('wide', images) - self.assertEqual(len(warnings), 2) + self.assertEqual(warnings, ['Source wide unavailable; using fallback art']) # one per slot, not per candidate + + def test_any_source_failure_falls_back_to_generated_art(self): + import http.client + from apk_sources import _images + data = (FIXTURES / 'icon.png').read_bytes() + for error in (http.client.RemoteDisconnected('gone'), http.client.IncompleteRead(b''), AttributeError('x')): + with self.subTest(error=type(error).__name__), \ + patch.object(_images, 'fetch', side_effect=error), \ + patch('frame_steamgriddb.lookup', side_effect=error): + images, warnings = art.prepare('Game', data, {'banner': 'https://example.org/b.png'}) + self.assertEqual(set(images), {'icon'}) + self.assertEqual(len(warnings), 2) + + def test_url_fetch_refuses_private_hosts_and_honours_deadline(self): + from apk_sources import _images, SourceError + local = [(2, 1, 6, '', ('127.0.0.1', 443))] + with patch.object(_images.socket, 'getaddrinfo', return_value=local), \ + self.assertRaisesRegex(SourceError, 'Private'): + art.fetch('https://example.org/icon.png') + public = [(2, 1, 6, '', ('93.184.216.34', 443))] + with patch.object(_images.socket, 'getaddrinfo', return_value=public), \ + patch.object(_images.socket, 'create_connection') as connect, \ + self.assertRaisesRegex(SourceError, 'too long'): + art.fetch('https://example.org/icon.png', deadline=time.monotonic() - 1) + connect.assert_not_called() + with self.assertRaises(SourceError): + art.fetch('file:///etc/passwd') def test_supplied_jpeg(self): data = (FIXTURES / 'icon.jpg').read_bytes() self.assertEqual(art.image_type(data), 'jpg') + self.assertEqual(art.image_type(data + b'\0' * 64), 'jpg') # trailing padding after EOI with self.assertRaises(ValueError): art.image_type(data[:30]) - def test_bad_artwork_and_expansion_limits(self): - import zlib + def test_png_variants_left_to_chromium_and_limits(self): + def png(w, h, depth, color, interlace): + return art.PNG + art.chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, depth, color, 0, 0, interlace)) + \ + art.chunk(b'IEND', b'') + self.assertEqual(art.image_type(png(3840, 1240, 16, 6, 0)), 'png') + self.assertEqual(art.image_type(png(3840, 2160, 8, 2, 1)), 'png') + for bad, message in ((png(10000, 10, 8, 6, 0), 'dimensions'), (png(5000, 5000, 8, 6, 0), 'dimensions'), + (png(10, 10, 3, 6, 0), 'encoding'), (png(10, 10, 8, 5, 0), 'encoding')): + with self.subTest(message=message), self.assertRaisesRegex(ValueError, message): + art.image_type(bad) + broken = bytearray(png(10, 10, 8, 6, 0)) + broken[20] ^= 1 + with self.assertRaisesRegex(ValueError, 'checksum'): + art.image_type(bytes(broken)) + with self.assertRaises(ValueError): + art.image_type(art.PNG + b'junk') + + def test_bad_artwork_arguments(self): for value in ({'bad': b'bad'}, ['hero']): with self.subTest(value=value), self.assertRaises(ValueError): art.prepare('Game', artwork=value) - data = bytearray((FIXTURES / 'icon.png').read_bytes()) - 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 @@ -270,19 +280,21 @@ class InstallTests(unittest.TestCase): 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)), \ + def test_remove_keeps_data_when_requested_and_survives_steam_failure(self): + with patch.object(android, '_meta_or_fail', side_effect=lambda pkg: 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') + api.side_effect = android.FrameError('SharedJSContext not found: is the Steam client running?') ssh.reset_mock() - with self.assertRaises(android.FrameError): - android.remove('org.test.vr') - ssh.assert_not_called() + result = android.remove('org.test.vr') + ssh.assert_called_once_with('rm -rf Applications/Android/org.test.vr ' + '.local/share/Steam/steamapps/compatdata/2800000001 ' + '.local/share/Steam/steamapps/shadercache/2800000001') + self.assertIn('Steam client running', result['library_warnings'][0]) def test_stop_requests_steam_and_has_container_fallback(self): with patch.object(android, '_meta_or_fail', return_value=self.existing), \ @@ -302,6 +314,7 @@ class SteamAPITests(unittest.TestCase): self.assertIn('SetShortcutIsVR(id, true)', js) self.assertIn('SetShortcutName(id, "A \\"name\\"\\n")', js) self.assertIn('SetCustomArtworkForApp(id, data, ext, type)', js) + self.assertLess(js.index('ClearCustomArtworkForApp(id, type)'), js.index('SetCustomArtworkForApp(id, data, ext, type)')) self.assertEqual(shortcuts.ASSETS, {'grid': 0, 'hero': 1, 'logo': 2, 'wide': 3, 'icon': 4}) self.assertIn('NewUnsavedCollection(name, undefined, [app])', js) @@ -312,6 +325,49 @@ class SteamAPITests(unittest.TestCase): self.assertIn('[0, 1, 2, 3]', js) self.assertLess(js.index('ClearCustomArtworkForApp(id, type)'), js.index('RemoveShortcut(id)')) self.assertIn('const wanted = []', js) + self.assertNotIn('throw', js) # tidy-up failures are warnings; RemoveShortcut always runs + + def test_devkit_configure_leaves_vr_flag_and_uses_sideloaded(self): + slots = {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS} + with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate: + shortcuts.configure(42, 'Game', '', '', '/icon', None, slots, {'category': 'Sideloaded'}) + js = evaluate.call_args.args[0] + self.assertIn('if (null !== null)', js) + self.assertIn('const wanted = ["Sideloaded"]', js) + with patch.object(sys, 'argv', ['steam_shortcuts.py', 'configure', '42', 'Game', '', '', '/icon', '', + json.dumps(slots), '{}']), \ + patch.object(shortcuts, 'configure', return_value={}) as configure, patch('builtins.print'): + shortcuts.main() + self.assertIsNone(configure.call_args.args[5]) + + def test_render_writes_jpeg_and_retries_oversized_photo_with_generated_art(self): + import base64 + png = base64.b64encode((FIXTURES / 'icon.png').read_bytes()).decode() + jpg = base64.b64encode((FIXTURES / 'icon.jpg').read_bytes()).decode() + huge = base64.b64encode(b'\xff\xd8\xff' + b'\0' * (12 * 1024 * 1024)).decode() + calls = [] + def evaluate(js, timeout=20): + calls.append((json.loads(js[js.rindex('renderLibraryArtwork(') + 21:-1]), timeout)) + hero = ['jpg', huge] if len(calls) == 1 else ['png', png] + return {'images': {'grid': ['jpg', jpg], 'wide': ['jpg', jpg], 'hero': hero, + 'logo': ['png', png], 'icon': ['png', png]}, 'warnings': []} + with tempfile.TemporaryDirectory() as tmp: + for name in ('icon.png', 'hero.jpg'): + Path(tmp, 'source-' + name).write_bytes((FIXTURES / ('icon.jpg' if name.endswith('jpg') else 'icon.png')).read_bytes()) + Path(tmp, 'hero.png').write_bytes(b'stale') + plan = Path(tmp, 'input.json') + plan.write_text(json.dumps({'label': 'Game', 'images': {'icon': str(Path(tmp, 'source-icon.png')), + 'hero': str(Path(tmp, 'source-hero.jpg'))}})) + with patch.object(shortcuts, 'evaluate', side_effect=evaluate): + result = shortcuts.render(str(plan)) + self.assertEqual(set(calls[0][0]['images']), {'icon', 'hero'}) + self.assertEqual(set(calls[1][0]['images']), {'icon'}) + self.assertEqual([c[1] for c in calls], [75, 75]) + self.assertTrue(result['paths']['grid'].endswith('grid.jpg')) + self.assertTrue(result['paths']['hero'].endswith('hero.png')) + self.assertIn('generated art used', result['warnings'][0]) + self.assertFalse(Path(tmp, 'hero.jpg').exists()) + self.assertEqual(Path(tmp, 'grid.jpg').read_bytes(), (FIXTURES / 'icon.jpg').read_bytes()) def test_stop_uses_exact_64_bit_game_id_string(self): with patch.object(sys, 'argv', ['steam_shortcuts.py', 'stop', '3346865537']), \ @@ -325,7 +381,7 @@ class SteamContextTests(unittest.TestCase): 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): + def evaluate(expression, timeout=20): 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, @@ -347,10 +403,35 @@ class SteamContextTests(unittest.TestCase): {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS}) self.assertEqual(steam['shortcuts'][0]['name'], 'Renamed') self.assertEqual(steam['collections'][1]['apps'], []) + with patch.object(sys, 'argv', ['steam_shortcuts.py', 'list']), patch('builtins.print') as out: + shortcuts.main() + self.assertEqual(json.loads(out.call_args.args[0]), + [{'appid': 42, 'name': 'Renamed', 'exe': '/exe', 'start_dir': '/dir'}]) shortcuts.remove(42) self.assertEqual(steam['shortcuts'], []) self.assertEqual(steam['collections'][0]['apps'], [999]) + @unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node') + def test_remove_without_collections_or_artwork_api_still_removes(self): + steam = {'apps': [], 'shortcuts': [{'appid': 42, 'name': 'Game', 'exe': '"/home/steamos/devkit-game/G/g"', + 'start_dir': '/home/steamos/devkit-game/G'}], 'compat_tools': {}} + def evaluate(expression, timeout=20): + nonlocal steam + expression = ('delete globalThis.collectionStore;' + 'SteamClient.Apps.ClearCustomArtworkForApp = async () => { throw Error("busy"); };' + expression) + 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.remove(42) + self.assertEqual(steam['shortcuts'], []) + self.assertEqual(len(result['warnings']), 5) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_library_entrypoints.py b/tests/test_library_entrypoints.py index e49104b..a8c59c6 100644 --- a/tests/test_library_entrypoints.py +++ b/tests/test_library_entrypoints.py @@ -81,12 +81,37 @@ class EntryPoints(unittest.TestCase): self.assertEqual(result['kind'], 'apk') self.assert_art() - def test_refresh_api(self): + def test_refresh_api_covers_android_apps_and_titles(self): with patch.object(server, 'ensure_master'), \ patch.object(server, 'start_job', side_effect=lambda label, work: work()), \ - patch.object(android, 'refresh_art', return_value=[]) as refresh: - self.assertEqual(server.android({'action':'refresh-art', 'all':True}), {'apps':[]}) - refresh.assert_called_once_with(None) + patch.object(android, 'refresh_art', return_value=[]) as refresh, \ + patch.object(titles, 'refresh_art', return_value=[{'id':'G','error':'x'}]) as title_refresh: + self.assertEqual(server.android({'action':'refresh-art', 'all':True}), + {'apps':[], 'titles':[{'id':'G','error':'x'}]}) + refresh.assert_called_once_with(); title_refresh.assert_called_once_with() + server.titles({'action':'refresh-art', 'id':'G'}) + title_refresh.assert_called_with('G') + + def test_backfill_applies_missing_art_once_steam_answers(self): + import threading + ran = threading.Event() + with patch.dict(server._backfill, {'running': False, 'last': 0.0}), \ + patch.object(android, 'refresh_art', side_effect=[RuntimeError('odd'), None]) as refresh, \ + patch.object(titles, 'refresh_art', side_effect=lambda gid: ran.set()) as title_refresh: + apps = [{'package':'org.a.x','art_missing':True}, {'package':'org.b.x','art_missing':True}, + {'package':'org.c.x','art_missing':False}] + with contextlib.redirect_stderr(io.StringIO()): + self.assertTrue(server.backfill_art(apps=apps, titles=[{'id':'G','art_missing':True}])) + self.assertTrue(ran.wait(5)) + self.assertFalse(server.backfill_art(apps=apps)) # throttled + self.assertEqual([c.args for c in refresh.call_args_list], [('org.a.x',), ('org.b.x',)]) + title_refresh.assert_called_once_with('G') + self.assertFalse(server.backfill_art(apps=[{'package':'org.c.x','art_missing':False}])) + + def test_art_missing_flags(self): + self.assertTrue(android.art_missing({'artwork': {}})) + self.assertTrue(android.art_missing({'artwork': {'grid': 'x'}})) + self.assertFalse(android.art_missing({'artwork': {s: 'x' for s in artwork.SLOTS}})) def test_source_search_shared_installer_contract(self): # Source workers hand their download and optional images to this public seam. @@ -110,11 +135,83 @@ class EntryPoints(unittest.TestCase): config = next(c.args for c in self.api.call_args_list if c.args[0] == 'configure') self.assertEqual(json.loads(config[8])['category'], 'Sideloaded') self.assertEqual(config[3:5], ('','')) # Never replace devkit's executable/runtime wiring. + self.assertEqual(config[6], '') # nor the VR flag the title declares + self.assertEqual(set(result['artwork']), set(artwork.SLOTS)) def test_native_renamed_shortcut_uses_saved_identity(self): self.api.side_effect = lambda *args, **kw: '[{"appid":42,"name":"Renamed"}]' with patch.object(titles, 'ssh', return_value='{"shortcut":42}'): - self.assertEqual(titles._library_shortcut('Original', 'Original'), 42) + self.assertEqual(titles._library_shortcut('Original', '/home/steamos/devkit-game/Original'), 42) + + def test_native_shortcut_never_matched_by_name_alone(self): + d = '/home/steamos/devkit-game/Game' + shortcuts = [{'appid':1,'name':'Game','exe':'"/home/steamos/.local/bin/game"','start_dir':'/home/steamos'}, + {'appid':2,'name':'Other','exe':'"/home/steamos/devkit-game/Game2/g.exe"','start_dir':''}] + self.api.side_effect = lambda *args, **kw: json.dumps(shortcuts) + with patch.object(titles, 'ssh', return_value=''): + self.assertIsNone(titles._library_shortcut('Game', d)) + shortcuts.append({'appid':3,'name':'Renamed','exe':'"/home/steamos/devkit-game/Game/bin/g.exe"','start_dir':''}) + self.assertEqual(titles._library_shortcut('Game', d), 3) + shortcuts.append({'appid':4,'name':'Copy','exe':'','start_dir':d}) + with self.assertRaisesRegex(android.FrameError, 'ambiguous'): + titles._library_shortcut('Game', d) + + def test_native_cleanup_failure_keeps_original_error(self): + with tempfile.TemporaryDirectory() as root: + plan = {'id':'Example','name':'Example','root':root,'size':3,'target':'game.exe', + 'runtime':'proton-experimental','source':'example.zip'} + def ssh(cmd, **kwargs): + if 'steamos-prepare-upload' in cmd: return '{"directory":"/home/steamos/devkit-game/Example"}' + if 'steam-client-create-shortcut' in cmd: return '{"success":"registered"}' + return '' + def steam(*args, **kwargs): + if args[0] == 'remove': raise android.FrameError('Steam went away') + return self.steam(*args) + self.api.side_effect = steam + with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'), \ + patch.object(titles, '_copy_tree'), patch.object(titles, '_rsync', return_value=True), \ + patch.object(android, 'apply_library', side_effect=android.FrameError('render failed')): + with self.assertRaisesRegex(android.FrameError, 'render failed'): + titles._install(plan, lambda *args: None) + + def test_native_remove_survives_steam_being_down(self): + cmds = [] + def ssh(cmd, **kwargs): + cmds.append(cmd) + return 'yes' if 'test -d' in cmd else '/home/steamos' if 'HOME' in cmd else '' + self.api.side_effect = android.FrameError('SharedJSContext not found') + with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'): + titles.remove('Game') + self.assertTrue(any('steamos-delete --delete-title Game' in c for c in cmds)) + + def test_native_refresh_art_backfills_registered_title(self): + meta = {'id':'Game','name':'My Game','source':'game.zip'} + writes = [] + def ssh(cmd, input=None, **kwargs): + if 'test -d' in cmd: return 'yes' + if 'HOME' in cmd: return '/home/steamos' + if cmd.startswith('cat devkit-game/Game-framecontrol.json'): return json.dumps(meta) + if cmd == 'python3 -': + self.assertIn("/home/steamos/devkit-game/Game/.frame-artwork", input) + return json.dumps({'artwork': {'banner': 'YmFubmVy'}, 'icon': ''}) + if cmd.startswith('cat > devkit-game/Game-framecontrol.json'): writes.append(json.loads(input)) + return '' + shortcuts = [{'appid':7,'name':'My Game','exe':'','start_dir':'/home/steamos/devkit-game/Game'}] + self.api.side_effect = lambda *args, **kw: json.dumps(shortcuts) if args[0] == 'list' else self.steam(*args) + with patch.object(titles, 'ssh', side_effect=ssh), \ + patch.object(artwork, 'prepare', return_value=({}, [])) as prepare: + result = titles.refresh_art('Game') + self.assertEqual(prepare.call_args.args[2], {'banner': b'banner'}) + self.assertEqual(result['shortcut'], 7) + self.assertEqual(set(writes[-1]['artwork']), set(artwork.SLOTS)) + self.assert_art() + shortcuts.clear() + with patch.object(titles, 'ssh', side_effect=ssh), \ + patch.object(titles, 'list_titles', return_value=[{'id':'Game','name':'My Game','frame_control':True}, + {'id':'Valve','name':'V','frame_control':False}]): + results = titles.refresh_art() + self.assertEqual(len(results), 1) + self.assertIn("hasn't registered", results[0]['error']) def test_native_failure_removes_new_blank_shortcut(self): with tempfile.TemporaryDirectory() as root: @@ -140,8 +237,10 @@ class EntryPoints(unittest.TestCase): stop.assert_not_called(); copy.assert_not_called(); self.assert_art() def test_all_refresh_reports_partial_failures(self): + import http.client with patch.object(android, 'list_apps', return_value=[{'package':'org.a.game'},{'package':'org.b.game'}]), \ - patch.object(android, '_meta_or_fail', side_effect=android.FrameError('missing')): + patch.object(android, '_meta_or_fail', side_effect=[http.client.RemoteDisconnected('gone'), + AttributeError('odd')]): result=android.refresh_art() self.assertEqual(len(result),2) self.assertTrue(all('error' in a for a in result)) @@ -173,7 +272,7 @@ class SteamGridDB(unittest.TestCase): def test_exact_match_and_top_votes_per_slot(self): calls=[] - def get(path,key): + def get(path,key,deadline=None): calls.append(path) if 'search' in path: return [{'id':1,'name':'Other Game'},{'id':2,'name':'Game'}] dims=(600,900) if '600x900' in path else (920,430) @@ -186,6 +285,17 @@ class SteamGridDB(unittest.TestCase): self.assertTrue(all(url.endswith('/top.png') for url in images.values())) self.assertTrue(all('/game/2?' in p for p in calls[1:])) + def test_unicode_titles_match_exactly_and_symbols_never_match_all(self): + self.assertEqual(sgdb._name('ビートセイバー VR!'), 'ビートセイバーvr') + with patch.object(sgdb,'api_key',return_value='test-key'), \ + patch.object(sgdb,'_get',return_value=[{'id':1,'name':'Unrelated'},{'id':2,'name':'!!!'}]) as get: + self.assertEqual(sgdb.lookup('★★★'),({},[])) + get.assert_not_called() + self.assertEqual(sgdb.lookup('ビートセイバー'),({},[])) + with patch.object(sgdb,'api_key',return_value='test-key'), \ + patch.object(sgdb,'_get',side_effect=AttributeError("'list' object has no attribute 'get'")): + self.assertEqual(sgdb.lookup('Game')[0],{}) + def test_wrong_title_and_failed_lookup_fall_back(self): with patch.object(sgdb,'api_key',return_value='test-key'),patch.object(sgdb,'_get',return_value=[{'id':1,'name':'Unrelated'}]): self.assertEqual(sgdb.lookup('Game'),({},[])) diff --git a/tests/test_server.py b/tests/test_server.py index ec389bd..47334c2 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -235,6 +235,56 @@ class ServerGuards(unittest.TestCase): @unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script") +class ArtworkSettings(unittest.TestCase): + """The settings panel's endpoints, with and without the page's X-Frame-UI key.""" + + def test_settings_need_and_accept_the_ui_key(self): + with tempfile.TemporaryDirectory() as home: + port = free_port() + env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1", + "HOME": home, "APPDATA": home, "XDG_DATA_HOME": home} + for name in ("STEAMGRIDDB_API_KEY", "FRAME_STEAMGRIDDB_API_KEY", "FRAME_UI_KEY"): + env.pop(name, None) + proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(port)], + env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + def request(method, path, body=None, headers=None): + conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10) + conn.request(method, path, body=json.dumps(body).encode() if body is not None else None, + headers=headers or {}) + r = conn.getresponse() + payload = r.read() + conn.close() + return r.status, payload + for _ in range(100): + try: + if request("GET", "/")[0] == 200: + break + except OSError: + time.sleep(0.05) + key = {"X-Frame-UI": "1", "Content-Type": "application/json"} + self.assertEqual(request("GET", "/api/settings/artwork")[0], 403) + self.assertEqual(request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc"})[0], 403) + status, payload = request("GET", "/api/settings/artwork", headers=key) + self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, False)) + status, payload = request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc_1"}, key) + self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, True)) + self.assertNotIn(b"abc_1", payload) + status, payload = request("GET", "/api/settings/artwork", headers=key) + self.assertTrue(json.loads(payload)["steamgriddb_configured"]) + self.assertEqual(request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "a b"}, key)[0], 400) + finally: + proc.terminate() + proc.wait(timeout=10) + + def test_panel_script_uses_the_keyed_api_helper(self): + script = (ROOT / "ui" / "artwork-settings.js").read_text() + self.assertNotIn("fetch(", script) + self.assertIn("api('/api/settings/artwork'", script) + page = (ROOT / "ui" / "index.html").read_text() + self.assertLess(page.index("async function api("), page.index('