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

+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)