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

@@ -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: