Steam library: safe removal and title matching, artwork within Steam's limit

- Remove: collections and artwork clearing are best effort in Steam's JS, and
  the host carries on to delete the files when Steam isn't running (Android
  apps and devkit titles).
- Devkit titles: the shortcut is found by devkit id, the saved id, or an
  executable/start folder inside the title's folder; never by display name.
  Steam's overviews don't carry devkit_gameid (checked on the Frame
  2026-09-28), so 'list' now reads exe/start dir from app details.
- Photo-based grid/wide/hero slots render as JPEG (a noise-heavy 3840x1240
  hero was over 12 MiB as PNG on the Frame); the logo stays transparent PNG.
  Rendering gets 75 s and retries once with generated art. Each slot is
  cleared before it is set, since Steam keeps .png and .jpg side by side.
- Devkit titles keep their own VR flag (vr=None skips SetShortcutIsVR) and
  their Sideloaded collection.
- A failed title install's cleanup can't replace the original error.
- refresh_art for devkit titles (frame_titles.refresh_art).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-28 23:06:25 +10:00
1 parent baefa105a9
commit f0db805d4b
9 files changed
+378 -85

No files matched your search

+4 -2
View File
@@ -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};
}
+75 -32
View File
@@ -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)')
@@ -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');}}
@@ -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; },
+6 -1
View File
@@ -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:{}});
+78 -7
View File
@@ -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()
+73 -1
View File
@@ -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:
+44 -25
View File
@@ -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}')
+89 -16
View File
@@ -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)