Library backfill only fills art Frame Control couldn't apply; remove serialised with refresh

- Automatic backfill touches only entries marked art_pending at install (a
  devkit title Steam registered later) and fills only slots Steam has no art
  for: no name, exe, VR flag or icon changes, no clearing. Older installs
  without the flag are left alone and refreshed only when the user asks.
- Android remove takes the install lock that install and refresh hold, so a
  refresh in progress can't recreate a removed app; a refresh after removal
  finds it not installed.

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:23:20 +10:00
1 parent 2df0f0e32a
commit 1b39fc1718
7 files changed
+161 -40

No files matched your search

+18 -8
View File
@@ -221,10 +221,11 @@ def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
def apply_library(shortcut, label, directory, images, vr=None, home=None, exe='', start_dir='', details=None,
category='Android'):
category='Android', fill_only=False):
"""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)."""
vr None leaves Steam's VR flag as it is (devkit titles declare their own). fill_only (automatic
backfill) sets only slots Steam has no art for, and never the name, exe or flags."""
home = home or ssh('echo $HOME').strip()
d = directory
ssh(f'mkdir -p {shlex.quote(d)}/artwork')
@@ -244,7 +245,8 @@ def apply_library(shortcut, label, directory, images, vr=None, home=None, exe=''
raise FrameError('Steam artwork renderer did not produce every slot')
result = json.loads(shortcut_tool('configure', str(shortcut), label, exe, start_dir, art['icon'],
'' if vr is None else '1' if vr else '0', json.dumps(art),
json.dumps({'category': category, 'details': details or {}}), timeout=120))
json.dumps({'category': category, 'details': details or {},
'fill_only': fill_only}), timeout=120))
result['warnings'] = rendered.get('warnings', []) + result.get('warnings', [])
result['artwork'] = art
if category == 'Android':
@@ -277,8 +279,10 @@ def art_missing(m):
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."""
def refresh_art(pkg=None, artwork=None, fill_only=False):
"""Refresh existing APK library entries without reinstalling or stopping them.
fill_only: the automatic backfill; it only fills empty Steam slots (see apply_library)."""
if pkg is None:
results = []
for app in list_apps():
@@ -287,7 +291,7 @@ def refresh_art(pkg=None, artwork=None):
except Exception as e: # one app's failure must not stop the others
results.append({'package': app['package'], 'label': app.get('label'), 'error': str(e) or type(e).__name__})
return results
with _install_lock:
with _install_lock: # shared with install and remove: a removed app is never recreated
m = _meta_or_fail(pkg)
d = f'{APPS_DIR}/{pkg}'
# Parse the APK on the Frame, transferring only its icon/label, not the APK.
@@ -315,7 +319,8 @@ def refresh_art(pkg=None, artwork=None):
created = True
try:
result = apply_library(m['shortcut'], m['label'], d, images, vr=not m.get('flatscreen', True),
home=home, exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}', details=m)
home=home, exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}', details=m,
fill_only=fill_only and not created)
except Exception:
if created:
try:
@@ -323,7 +328,7 @@ def refresh_art(pkg=None, artwork=None):
except FrameError:
pass # keep the render error, not the cleanup's
raise
m.update(artwork=result.get('artwork', {}), library_version=2)
m.update(artwork=result.get('artwork', {}), library_version=2, art_pending=False)
m['library_warnings'] = warnings + result.get('warnings', [])
m['artwork_refreshed'] = time.strftime('%Y-%m-%dT%H:%M:%S')
_write_meta(d, m)
@@ -411,6 +416,11 @@ def stop(pkg):
def remove(pkg, keep_data=False):
with _install_lock: # never while an install or artwork refresh of any app is writing
return _remove(pkg, keep_data)
def _remove(pkg, keep_data):
m = _meta_or_fail(pkg)
stop(pkg)
if m['shortcut']:
+14 -7
View File
@@ -696,7 +696,9 @@ def _install(plan, step, artwork=None):
reply = _json_out(ssh(f'{PY}steam-client-create-shortcut --parms {shlex.quote(json.dumps(parms))}',
timeout=90), 'steam-client-create-shortcut')
meta = {'id': gid, 'name': plan['name'], 'target': plan['target'], 'runtime': plan['runtime'],
'source': plan['source'], 'size': plan['size'], 'installed': time.strftime('%Y-%m-%dT%H:%M:%S')}
'source': plan['source'], 'size': plan['size'], 'installed': time.strftime('%Y-%m-%dT%H:%M:%S'),
# Until art is applied: the only entries automatic backfill may touch.
'art_pending': True}
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
registered = True # the files stay: Steam registers them once it's running
if 'error' in reply:
@@ -709,7 +711,7 @@ def _install(plan, step, artwork=None):
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, artwork=result.get('artwork', {}),
meta.update(shortcut=shortcut, artwork=result.get('artwork', {}), art_pending=False,
library_warnings=warnings + result.get('warnings', []))
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
library_ready = True
@@ -768,8 +770,10 @@ 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)."""
def refresh_art(gid=None, artwork=None, fill_only=False):
"""Render and apply Steam artwork for Frame Control's titles (all of them when gid is None).
fill_only: the automatic backfill; it only fills empty Steam slots (see apply_library)."""
if gid is None:
results = []
for t in list_titles():
@@ -809,8 +813,9 @@ print(json.dumps({{'artwork': cached, 'icon': icon}}))
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', {}),
category='Sideloaded', details={'source': meta.get('source')},
fill_only=fill_only)
meta.update(shortcut=shortcut, artwork=result.get('artwork', {}), art_pending=False,
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)
@@ -852,7 +857,9 @@ def list_titles():
'runtime': alias, 'runtime_label': RUNTIMES.get(alias, {}).get('label', alias or 'not set'),
'source': str(meta.get('source') or ''), 'size': meta.get('size'),
'installed': meta.get('installed'), 'registered': t.get('settings') is not None,
'frame_control': bool(meta), 'art_missing': bool(meta) and frame_android.art_missing(meta)})
'frame_control': bool(meta),
# Only a title whose install couldn't apply art; older installs have no flag and keep theirs.
'art_pending': bool(meta.get('art_pending')), 'art_missing': bool(meta.get('art_pending'))})
return titles
+6 -5
View File
@@ -153,11 +153,12 @@ _backfill_lock = threading.Lock()
def backfill_art(apps=(), titles=()):
"""Give installs that lack Steam artwork (Steam wasn't running, say) their art once Steam is up.
"""Apply art Frame Control couldn't at install time (Steam wasn't running), once Steam is up.
Runs in the background, at most once every five minutes; refresh-art reports failures on demand."""
pkgs = [a["package"] for a in apps if a.get("art_missing")]
gids = [t["id"] for t in titles if t.get("art_missing")]
Only entries marked art_pending at install; it fills empty Steam slots and never replaces art or
names the user may have customised. Runs in the background, at most once every five minutes."""
pkgs = [a["package"] for a in apps if a.get("art_pending")]
gids = [t["id"] for t in titles if t.get("art_pending")]
with _backfill_lock:
if not (pkgs or gids) or _backfill["running"] or time.time() - _backfill["last"] < 300:
return False
@@ -169,7 +170,7 @@ def backfill_art(apps=(), titles=()):
for refresh, key in [(frame_android.refresh_art, p) for p in pkgs] + \
[(frame_titles.refresh_art, g) for g in gids]:
try:
refresh(key)
refresh(key, fill_only=True)
except Exception as e:
print(f"artwork backfill for {key}: {e}", file=sys.stderr)
except Exception: