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 2db3527..fdee8cb 100644 --- a/tests/test_frame_android_library.py +++ b/tests/test_frame_android_library.py @@ -280,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), \ @@ -312,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) @@ -322,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']), \ @@ -335,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, @@ -357,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 1560633..204b72b 100644 --- a/tests/test_library_entrypoints.py +++ b/tests/test_library_entrypoints.py @@ -110,11 +110,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: diff --git a/ui/frame_android.py b/ui/frame_android.py index 25d43eb..bcb2fc9 100644 --- a/ui/frame_android.py +++ b/ui/frame_android.py @@ -11,7 +11,7 @@ Python stdlib only. CLI: python3 ui/frame_android.py install-obb PKG OBB [OBB ...] | backup-data PKG ARCHIVE | restore-data PKG ARCHIVE refresh-art PKG|--all | patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG """ -import json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib +import base64, json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib import frame_apk import frame_artwork @@ -220,9 +220,11 @@ def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None): -def apply_library(shortcut, label, directory, images, vr=False, home=None, exe='', start_dir='', details=None, +def apply_library(shortcut, label, directory, images, vr=None, home=None, exe='', start_dir='', details=None, category='Android'): - """Mandatory for every sideload: render all five slots before reporting success.""" + """Mandatory for every sideload: render all five slots before reporting success. + + vr None leaves Steam's VR flag as it is (devkit titles declare their own).""" home = home or ssh('echo $HOME').strip() d = directory ssh(f'mkdir -p {shlex.quote(d)}/artwork') @@ -235,12 +237,13 @@ def apply_library(shortcut, label, directory, images, vr=False, home=None, exe=' plan = f'{d}/artwork/input.json' ssh(f'cat > {shlex.quote(plan)}', input=json.dumps(manifest)) absolute = f'{home}/{plan}' if not plan.startswith('/') else plan - rendered = json.loads(shortcut_tool('render', absolute, timeout=120)) + # The Frame retries once with generated art, each attempt allowed 75 s. + rendered = json.loads(shortcut_tool('render', absolute, timeout=200)) art = rendered['paths'] if set(art) != set(frame_artwork.SLOTS): raise FrameError('Steam artwork renderer did not produce every slot') result = json.loads(shortcut_tool('configure', str(shortcut), label, exe, start_dir, art['icon'], - '1' if vr else '0', json.dumps(art), + '' if vr is None else '1' if vr else '0', json.dumps(art), json.dumps({'category': category, 'details': details or {}}), timeout=120)) result['warnings'] = rendered.get('warnings', []) + result.get('warnings', []) result['artwork'] = art @@ -249,6 +252,31 @@ def apply_library(shortcut, label, directory, images, vr=False, home=None, exe=' return result +def cached_art_script(d): + """Frame-side Python that loads the source images kept beside d's last render into `cached`.""" + return f"""import base64, json, os +cached = {{}} +directory = os.path.realpath({d!r}) +try: + with open(os.path.join(directory, 'artwork/input.json')) as f: + plan = json.load(f) + for slot, path in plan.get('images', {{}}).items(): + if os.path.commonpath([os.path.realpath(path), directory]) != directory: + continue + with open(path, 'rb') as f: + data = f.read(12 * 1024 * 1024 + 1) + if len(data) <= 12 * 1024 * 1024: + cached[slot] = base64.b64encode(data).decode() +except (OSError, ValueError, TypeError, AttributeError): + pass +""" + + +def art_missing(m): + """True when a Frame Control install has no complete Steam artwork on record.""" + return set((m or {}).get('artwork') or {}) != set(frame_artwork.SLOTS) + + def refresh_art(pkg=None, artwork=None): """Refresh existing APK library entries without reinstalling or stopping them.""" if pkg is None: @@ -272,24 +300,8 @@ def refresh_art(pkg=None, artwork=None): script += f'm = types.ModuleType({module!r}); sys.modules[{module!r}] = m; exec({source!r}, m.__dict__)\n' script += f"info = sys.modules['frame_apk'].apk_info({(d + '/app.apk')!r})\n" script += "info['icon_png'] = base64.b64encode(info.get('icon_png') or b'').decode()\n" - script += "import os\ninfo['artwork'] = {}\n" - script += f"directory = os.path.realpath({d!r})\n" - script += """try: - with open(os.path.join(directory, 'artwork/input.json')) as f: - cached = json.load(f) - for slot, path in cached.get('images', {}).items(): - if os.path.commonpath([os.path.realpath(path), directory]) != directory: - continue - with open(path, 'rb') as f: - data = f.read(12 * 1024 * 1024 + 1) - if len(data) <= 12 * 1024 * 1024: - info['artwork'][slot] = base64.b64encode(data).decode() -except (OSError, ValueError, TypeError): - pass -print(json.dumps(info)) -""" + script += cached_art_script(d) + "info['artwork'] = cached\nprint(json.dumps(info))\n" info = json.loads(ssh('python3 -', input=script)) - import base64 icon = base64.b64decode(info['icon_png']) cached = {k: base64.b64decode(v) for k, v in info.get('artwork', {}).items()} images, warnings = frame_artwork.prepare(m['label'], icon, artwork if artwork is not None else cached) @@ -306,7 +318,10 @@ print(json.dumps(info)) home=home, exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}', details=m) except Exception: if created: - shortcut_tool('remove', str(m['shortcut'])) + try: + shortcut_tool('remove', str(m['shortcut'])) + except FrameError: + pass # keep the render error, not the cleanup's raise m.update(artwork=result.get('artwork', {}), library_version=2) m['library_warnings'] = warnings + result.get('warnings', []) @@ -398,8 +413,12 @@ def remove(pkg, keep_data=False): m = _meta_or_fail(pkg) stop(pkg) if m['shortcut']: - result = json.loads(shortcut_tool('remove', str(int(m['shortcut']))) or '{}') - m['library_warnings'] = result.get('warnings', []) + # Best effort: Steam may not be running, and the files must still go. + try: + result = json.loads(shortcut_tool('remove', str(int(m['shortcut']))) or '{}') + m['library_warnings'] = result.get('warnings', []) + except (FrameError, ValueError, AttributeError) as e: + m['library_warnings'] = [f'Steam shortcut not removed: {e}'] iid = int(m['instance']) extra = '' if keep_data else f' {COMPAT}/{iid} {SHADERS}/{iid}' ssh(f'rm -rf {APPS_DIR}/{pkg}{extra}') diff --git a/ui/frame_titles.py b/ui/frame_titles.py index d2d3588..891bcfc 100644 --- a/ui/frame_titles.py +++ b/ui/frame_titles.py @@ -16,9 +16,9 @@ checked on a headset; see docs/sideloading.md. Python stdlib only. CLI: python3 ui/frame_titles.py inspect PATH python3 ui/frame_titles.py install PATH [--name N] [--exe REL] [--runtime R] - python3 ui/frame_titles.py list | launch ID | remove ID + python3 ui/frame_titles.py list | launch ID | remove ID | refresh-art ID|--all """ -import hashlib, json, os, posixpath, re, shlex, shutil, stat, struct, subprocess, sys, tempfile, threading, time, zipfile +import base64, hashlib, json, os, posixpath, re, shlex, shutil, stat, struct, subprocess, sys, tempfile, threading, time, zipfile import frame_android import frame_host @@ -704,12 +704,13 @@ def _install(plan, step, artwork=None): hint = ' With Steam running on the Frame, install it again.' if 'not running' in err else '' raise FrameError(f"Uploaded, but Steam didn't register it: {err}.{hint}") steam_registered = True - shortcut = _library_shortcut(gid, plan['name']) + shortcut = _library_shortcut(gid, directory) if not shortcut: raise FrameError('Steam registered the title but its shortcut is not available for mandatory artwork; retry install') result = frame_android.apply_library(shortcut, plan['name'], directory + '/.frame-artwork', images, category='Sideloaded', details={'source': plan['source']}) - meta.update(shortcut=shortcut, library_warnings=warnings + result.get('warnings', [])) + meta.update(shortcut=shortcut, artwork=result.get('artwork', {}), + library_warnings=warnings + result.get('warnings', [])) ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30) library_ready = True step('Done', 1.0) @@ -717,11 +718,15 @@ def _install(plan, step, artwork=None): return meta finally: if steam_registered and not library_ready and not existed: - # A newly registered title must not remain as a blank library tile. - if shortcut: - frame_android.shortcut_tool('remove', str(shortcut)) - else: - ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120) + # A newly registered title must not remain as a blank library tile. Cleanup + # failures are swallowed so the error that got us here is the one reported. + try: + if shortcut: + frame_android.shortcut_tool('remove', str(shortcut)) + else: + ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120) + except FrameError: + pass registered = False if not registered and not existed: # A first install that failed part-way: don't leave an orphan folder behind. @@ -732,22 +737,86 @@ def _install(plan, step, artwork=None): -def _library_shortcut(gid, name): +def _library_shortcut(gid, directory): + """The Steam shortcut of title gid, only ever one that is provably this title's. + + Steam's app overviews don't expose devkit_gameid (checked 2026-09-28, build 20260922.6101926), + so after the saved id this matches the shortcut's executable or start folder inside directory. + Never by display name: another non-Steam shortcut could share it and would be renamed or deleted. + """ shortcuts = json.loads(frame_android.shortcut_tool('list')) matches = [s for s in shortcuts if s.get('devkit_gameid') == gid] if not matches: try: - meta = json.loads(ssh(f'cat {GAMES}/{gid}-framecontrol.json 2>/dev/null || true')) - matches = [s for s in shortcuts if s.get('appid') == meta.get('shortcut')] + meta = json.loads(ssh(f'cat {GAMES}/{gid}-framecontrol.json 2>/dev/null || true') or 'null') + matches = [s for s in shortcuts if meta.get('shortcut') and s.get('appid') == meta.get('shortcut')] except (ValueError, AttributeError): pass if not matches: - matches = [s for s in shortcuts if s.get('name') in (gid, name)] + root = posixpath.normpath(directory) + + def inside(path): + path = str(path or '').strip().strip('"') + return bool(path) and (posixpath.normpath(path) + '/').startswith(root + '/') + matches = [s for s in shortcuts if inside(s.get('exe')) or inside(s.get('start_dir'))] if len(matches) > 1: raise FrameError('ambiguous Steam shortcut for ' + gid) return int(matches[0]['appid']) if matches else None +def _home(): + return ssh('echo $HOME', timeout=30).strip() + + +def refresh_art(gid=None, artwork=None): + """Render and apply Steam artwork for Frame Control's titles (all of them when gid is None).""" + if gid is None: + results = [] + for t in list_titles(): + if not t['frame_control']: + continue + try: + results.append(refresh_art(t['id'], artwork)) + except Exception as e: # report each title; one failure doesn't stop the rest + results.append({'id': t['id'], 'name': t['name'], 'error': str(e) or type(e).__name__}) + return results + with _install_lock: + gid = _check_id(gid) + try: + meta = json.loads(ssh(f'cat {GAMES}/{gid}-framecontrol.json', timeout=30)) + except (FrameError, ValueError): + meta = None + if not isinstance(meta, dict): + raise FrameError(f'{gid} was not installed by Frame Control') + directory = f'{_home()}/{GAMES}/{gid}' + script = frame_android.cached_art_script(directory + '/.frame-artwork') + f""" +icon = '' +for name in ('icon.png', 'logo.png'): + try: + with open(os.path.join({directory!r}, name), 'rb') as f: + icon = base64.b64encode(f.read(12 * 1024 * 1024 + 1)).decode() + break + except OSError: + pass +print(json.dumps({{'artwork': cached, 'icon': icon}})) +""" + found = _json_out(ssh('python3 -', input=script, timeout=60), 'the title artwork') + cached = {k: base64.b64decode(v) for k, v in (found.get('artwork') or {}).items()} + icon = base64.b64decode(found.get('icon') or '') or None + name = str(meta.get('name') or gid) + images, warnings = frame_android.frame_artwork.prepare(name, icon, artwork if artwork is not None else cached) + shortcut = _library_shortcut(gid, directory) + if not shortcut: + raise FrameError(f"Steam hasn't registered {gid} yet; with Steam running on the Frame, refresh again") + result = frame_android.apply_library(shortcut, name, directory + '/.frame-artwork', images, + category='Sideloaded', details={'source': meta.get('source')}) + meta.update(shortcut=shortcut, artwork=result.get('artwork', {}), + library_warnings=warnings + result.get('warnings', []), + artwork_refreshed=time.strftime('%Y-%m-%dT%H:%M:%S')) + ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30) + return meta + + LIST_SCRIPT = r''' import json, os root = os.path.expanduser('~/devkit-game') @@ -814,9 +883,13 @@ def remove(gid): try: gid = _check_id(gid) ensure_utils() - shortcut = _library_shortcut(gid, gid) - if shortcut: - frame_android.shortcut_tool('remove', str(shortcut)) + # Tidying Steam's side is best effort; steamos-delete removes the title regardless. + try: + shortcut = _library_shortcut(gid, f'{_home()}/{GAMES}/{gid}') + if shortcut: + frame_android.shortcut_tool('remove', str(shortcut)) + except (FrameError, ValueError): + pass # steamos-delete removes the folder and syncs Steam's shortcuts; its json files stay, so clear them too. ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120) ssh(f'rm -f {_json_files(gid)}', timeout=30)