Merge branch 'library-fixes' into apk-store-fixes

This commit is contained in:
saphid committed 2026-09-28 23:23:44 +10:00
commit 4589221661
12 files changed
+325 -80

No files matched your search

+8 -5
View File
@@ -140,10 +140,12 @@ refresh covers both. The API is `POST /api/android` with
`POST /api/titles` with `{"action":"refresh-art","id":…}`; each returns a `POST /api/titles` with `{"action":"refresh-art","id":…}`; each returns a
background job. Batch results retain per-item errors, and the CLIs exit background job. Batch results retain per-item errors, and the CLIs exit
nonzero if any failed. Apps and titles without complete artwork show **Add nonzero if any failed. Apps and titles without complete artwork show **Add
artwork**, `list` prints the command, and when Frame Control lists them while artwork** and `list` prints the command. Only entries marked `art_pending` at
Steam is running it re-applies their art in the background (at most every install (a title Steam registered after an install made while it wasn't
five minutes), for example for a title Steam registered after an install made running) are backfilled automatically, when Frame Control lists them with
while it wasn't running. Steam running (at most every five minutes), and that backfill only fills
slots Steam has no art for: names, icons, flags and any art the user set are
kept. Older installs without the flag are refreshed only on request.
Steam's app overviews carry no `devkit_gameid` (checked 2026-09-28, build Steam's app overviews carry no `devkit_gameid` (checked 2026-09-28, build
20260925.6191901, on every non-Steam shortcut). A title's shortcut is found by 20260925.6191901, on every non-Steam shortcut). A title's shortcut is found by
@@ -176,7 +178,8 @@ The launcher supervises Lepton and handles TERM/INT/HUP and normal exit by
stopping its own container and child process group. A lock refuses duplicate launches; stopping its own container and child process group. A lock refuses duplicate launches;
a container still running while the lock is free was orphaned by a killed a container still running while the lock is free was orphaned by a killed
launcher and is stopped before the new launch. Lepton doesn't inherit the launcher and is stopped before the new launch. Lepton doesn't inherit the
lock. Removing an app or title still deletes its files when Steam isn't lock; the launcher records Lepton's process group and a later launch ends a
recorded group still running this APK. Removing an app or title still deletes its files when Steam isn't
running; tidying Steam's collections and artwork is best effort. Steam Stop uses `TerminateApp` with the exact running; tidying Steam's collections and artwork is best effort. Steam Stop uses `TerminateApp` with the exact
64-bit game ID string. Frame Control's Stop additionally has a direct-container 64-bit game ID string. Frame Control's Stop additionally has a direct-container
fallback. The stable instance ID and compatdata paths remain unchanged. fallback. The stable instance ID and compatdata paths remain unchanged.
+16
View File
@@ -30,6 +30,20 @@ fi
exec 9>"$DIR/launch.lock" exec 9>"$DIR/launch.lock"
flock -n 9 || { echo "Android app is already running" >&2; exit 1; } flock -n 9 || { echo "Android app is already running" >&2; exit 1; }
CONTAINER="lepton-steamlaunch-$SteamAppId" CONTAINER="lepton-steamlaunch-$SteamAppId"
# Lepton doesn't hold the lock, so a launcher SIGKILLed before Lepton made its
# container leaves a Lepton that nothing tracks: end that process group first.
# Only a group still running this app.apk, never an unrelated reused id.
PGID_FILE="$DIR/launch.pgid"
if [[ -f "$PGID_FILE" ]]; then
old="$(cat "$PGID_FILE")"
if [[ "$old" =~ ^[0-9]+$ ]] && ps -A -o pgid=,args= | awk -v g="$old" '$1 == g' | grep -qF -- "$DIR/app.apk"; then
echo "Stopping the previous launch (process group $old)" >&2
kill -TERM -- "-$old" 2>/dev/null || true
for _ in 1 2 3 4 5 6 7 8 9 10; do kill -0 -- "-$old" 2>/dev/null || break; sleep 0.5; done
kill -KILL -- "-$old" 2>/dev/null || true
fi
rm -f "$PGID_FILE"
fi
# Holding the lock means no launcher owns a running container: it was orphaned # Holding the lock means no launcher owns a running container: it was orphaned
# (this script SIGKILLed), so stop it rather than refuse every later Play. # (this script SIGKILLed), so stop it rather than refuse every later Play.
if [[ "$(podman inspect --format '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" == true ]]; then if [[ "$(podman inspect --format '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" == true ]]; then
@@ -60,6 +74,7 @@ cleanup() {
kill -KILL "$child" 2>/dev/null || true kill -KILL "$child" 2>/dev/null || true
wait "$child" 2>/dev/null || true wait "$child" 2>/dev/null || true
fi fi
rm -f "$PGID_FILE"
} }
trap cleanup EXIT trap cleanup EXIT
trap 'exit 143' TERM trap 'exit 143' TERM
@@ -68,6 +83,7 @@ trap 'exit 129' HUP
# 9>&-: the lock is this launcher's alone; Lepton's tree mustn't keep it held. # 9>&-: the lock is this launcher's alone; Lepton's tree mustn't keep it held.
setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" 9>&- & setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" 9>&- &
child=$! child=$!
echo "$child" > "$PGID_FILE"
rc=0 rc=0
wait "$child" || rc=$? wait "$child" || rc=$?
child="" child=""
+33 -12
View File
@@ -9,7 +9,7 @@ Python stdlib only; the Mac runs it with `ssh frame python3 - <args> < this`.
steam_shortcuts.py stop APPID steam_shortcuts.py stop APPID
steam_shortcuts.py remove APPID steam_shortcuts.py remove APPID
""" """
import base64, json, os, re, socket, struct, sys, urllib.request import base64, glob, json, os, re, socket, struct, sys, urllib.request
DEVTOOLS = 'http://127.0.0.1:8080/json' DEVTOOLS = 'http://127.0.0.1:8080/json'
@@ -198,9 +198,26 @@ def _render(plan, renderer, label, images):
return {'paths': paths, 'warnings': list(result.get('warnings', []))} return {'paths': paths, 'warnings': list(result.get('warnings', []))}
# Steam's own file for each custom-art type in userdata/*/config/grid/.
GRID_FILES = {0: 'p', 1: '_hero', 2: '_logo', 3: ''}
def custom_art(appid):
"""The custom-art types this shortcut already has in Steam, for any local user."""
found = set()
for kind, suffix in GRID_FILES.items():
pattern = os.path.expanduser(f'~/.local/share/Steam/userdata/*/config/grid/{int(appid)}{suffix}.*')
if any(os.path.splitext(p)[1].lower() in ('.png', '.jpg', '.jpeg') for p in glob.glob(pattern)):
found.add(kind)
return found
def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None): def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None):
"""options: category, details, fill_only (only empty slots and a missing icon; name and flags untouched)."""
options = options or {} options = options or {}
category = options.get('category', 'Android') category = options.get('category', 'Android')
fill = bool(options.get('fill_only'))
existing = custom_art(appid) if fill else set()
if set(artwork) != set(ASSETS): if set(artwork) != set(ASSETS):
raise ValueError('all five Steam artwork slots are required') raise ValueError('all five Steam artwork slots are required')
images = [] images = []
@@ -214,18 +231,22 @@ def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None):
data = f.read(MAX_ART + 1) data = f.read(MAX_ART + 1)
if len(data) > MAX_ART: if len(data) > MAX_ART:
raise ValueError('artwork is too large') raise ValueError('artwork is too large')
if slot != 'icon': # Frame's custom-art API maps type 4 to Header; use SetShortcutIcon. # Frame's custom-art API maps type 4 to Header; use SetShortcutIcon.
if slot != 'icon' and ASSETS[slot] not in existing:
images.append([ASSETS[slot], ext, base64.b64encode(data).decode()]) images.append([ASSETS[slot], ext, base64.b64encode(data).decode()])
return evaluate(f'''(async () => {{ return evaluate(f'''(async () => {{
const id = {int(appid)}, warnings = []; const id = {int(appid)}, warnings = [], fill = {json.dumps(fill)};
SteamClient.Apps.SetShortcutName(id, {json.dumps(name)}); const overview = appStore.GetAppOverviewByAppID(id);
if ({json.dumps(exe)}) SteamClient.Apps.SetShortcutExe(id, {json.dumps(exe)}); if (!fill) {{
if ({json.dumps(start_dir)}) SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)}); SteamClient.Apps.SetShortcutName(id, {json.dumps(name)});
if (typeof SteamClient.Apps.SetShortcutSortAs === "function") if ({json.dumps(exe)}) SteamClient.Apps.SetShortcutExe(id, {json.dumps(exe)});
SteamClient.Apps.SetShortcutSortAs(id, {json.dumps(name)}); if ({json.dumps(start_dir)}) SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)});
SteamClient.Apps.SetShortcutIcon(id, {json.dumps(icon)}); if (typeof SteamClient.Apps.SetShortcutSortAs === "function")
// null (devkit titles): leave the VR flag as Steam registered it. SteamClient.Apps.SetShortcutSortAs(id, {json.dumps(name)});
if ({json.dumps(vr)} !== null) {{ }}
if (!fill || !(overview && overview.icon_data)) SteamClient.Apps.SetShortcutIcon(id, {json.dumps(icon)});
// null (devkit titles) or filling gaps: leave the VR flag as Steam has it.
if ({json.dumps(vr)} !== null && !fill) {{
if (typeof SteamClient.Apps.SetShortcutIsVR === "function") if (typeof SteamClient.Apps.SetShortcutIsVR === "function")
SteamClient.Apps.SetShortcutIsVR(id, {json.dumps(vr)}); SteamClient.Apps.SetShortcutIsVR(id, {json.dumps(vr)});
else warnings.push("Steam VR shortcut flag API unavailable"); else warnings.push("Steam VR shortcut flag API unavailable");
@@ -233,7 +254,7 @@ def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None):
if (typeof SteamClient.Apps.SetCustomArtworkForApp === "function") {{ 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. // 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") if (!fill && typeof SteamClient.Apps.ClearCustomArtworkForApp === "function")
try {{ await SteamClient.Apps.ClearCustomArtworkForApp(id, type); }} catch (e) {{}} try {{ await SteamClient.Apps.ClearCustomArtworkForApp(id, type); }} catch (e) {{}}
await SteamClient.Apps.SetCustomArtworkForApp(id, data, ext, type); await SteamClient.Apps.SetCustomArtworkForApp(id, data, ext, type);
}} }}
@@ -38,6 +38,7 @@ function build(steam) {
}); });
const shortcutOverview = s => ({ const shortcutOverview = s => ({
appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE, devkit_gameid: s.devkit_gameid, appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE, devkit_gameid: s.devkit_gameid,
icon_data: s.icon ? 'fake-icon' : undefined,
local_per_client_data: { installed: true, display_status: 1, status_percentage: 0 }, local_per_client_data: { installed: true, display_status: 1, status_percentage: 0 },
}); });
const allApps = () => [...steam.apps.map(gameOverview), ...steam.shortcuts.map(shortcutOverview)]; const allApps = () => [...steam.apps.map(gameOverview), ...steam.shortcuts.map(shortcutOverview)];
+143 -2
View File
@@ -24,7 +24,7 @@ spec.loader.exec_module(shortcuts)
@unittest.skipIf(os.name == 'nt', 'POSIX launcher') @unittest.skipIf(os.name == 'nt', 'POSIX launcher')
class LauncherTests(unittest.TestCase): class LauncherTests(unittest.TestCase):
def exercise(self, terminate, sig=signal.SIGTERM, blocked=None, orphan=False): def exercise(self, terminate, sig=signal.SIGTERM, blocked=None, orphan=False, stale=None):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp) d = Path(tmp)
app = d / 'Applications/Android/org.test.app' app = d / 'Applications/Android/org.test.app'
@@ -60,6 +60,13 @@ class LauncherTests(unittest.TestCase):
saved = d / '.local/share/Steam/steamapps/compatdata/2800000001/internal/save' saved = d / '.local/share/Steam/steamapps/compatdata/2800000001/internal/save'
saved.parent.mkdir(parents=True) saved.parent.mkdir(parents=True)
saved.write_text('saved game') saved.write_text('saved game')
previous = None
if stale:
# A Lepton left by a SIGKILLed launcher (its own session), or an unrelated reused id.
args = [sys.executable, '-c', 'import time; time.sleep(30)']
previous = subprocess.Popen(args + ([str(app / 'app.apk')] if stale == 'lepton' else ['other']),
start_new_session=True)
(app / 'launch.pgid').write_text(str(previous.pid))
proc = subprocess.Popen(['bash', str(app / 'launch.sh')], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) proc = subprocess.Popen(['bash', str(app / 'launch.sh')], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try: try:
if blocked: if blocked:
@@ -73,6 +80,10 @@ class LauncherTests(unittest.TestCase):
while not (d / 'started').exists() and proc.poll() is None and time.monotonic() < deadline: while not (d / 'started').exists() and proc.poll() is None and time.monotonic() < deadline:
time.sleep(.02) time.sleep(.02)
self.assertTrue((d / 'started').exists(), 'launcher did not start Lepton') self.assertTrue((d / 'started').exists(), 'launcher did not start Lepton')
if previous:
self.assertEqual(previous.poll() is not None, stale == 'lepton')
self.assertEqual((app / 'launch.pgid').read_text().strip(),
(d / 'started').read_text().strip()) # the new group is recorded
self.assertFalse((d / 'inherited-lock').exists(), 'Lepton inherited the launch lock') self.assertFalse((d / 'inherited-lock').exists(), 'Lepton inherited the launch lock')
if terminate: if terminate:
self.assertIsNone(proc.poll(), 'Steam-tracked wrapper exited during the session') self.assertIsNone(proc.poll(), 'Steam-tracked wrapper exited during the session')
@@ -84,7 +95,12 @@ class LauncherTests(unittest.TestCase):
self.assertEqual(proc.returncode, 128 + sig if terminate else 23) self.assertEqual(proc.returncode, 128 + sig if terminate else 23)
self.assertEqual(saved.read_text(), 'saved game') self.assertEqual(saved.read_text(), 'saved game')
self.assertTrue((app / 'app.apk').exists()) self.assertTrue((app / 'app.apk').exists())
self.assertFalse((app / 'launch.pgid').exists())
finally: finally:
if previous and previous.poll() is None:
previous.kill()
if previous:
previous.wait()
if proc.poll() is None: if proc.poll() is None:
proc.kill() proc.kill()
proc.communicate() proc.communicate()
@@ -105,6 +121,12 @@ class LauncherTests(unittest.TestCase):
def test_duplicate_launch_leaves_existing_session_alone(self): def test_duplicate_launch_leaves_existing_session_alone(self):
self.exercise(False, blocked='LOCKED') self.exercise(False, blocked='LOCKED')
def test_previous_launch_left_by_sigkill_is_reaped_first(self):
self.exercise(True, stale='lepton')
def test_reused_process_group_id_is_left_alone(self):
self.exercise(True, stale='other')
def test_orphaned_container_is_stopped_and_play_proceeds(self): def test_orphaned_container_is_stopped_and_play_proceeds(self):
# Container running but the lock free: its launcher was SIGKILLed. # Container running but the lock free: its launcher was SIGKILLed.
self.exercise(False, orphan=True) self.exercise(False, orphan=True)
@@ -167,6 +189,57 @@ class ArtworkTests(unittest.TestCase):
with self.assertRaises(SourceError): with self.assertRaises(SourceError):
art.fetch('file:///etc/passwd') art.fetch('file:///etc/passwd')
def trickle(self, head, seconds):
# A server that answers one byte every 20 ms, over a socketpair standing in for the network.
import socket
import threading
from apk_sources import _images
client, server = socket.socketpair()
def serve():
try:
server.recv(65536)
for byte in head + b'x' * 1000:
server.sendall(bytes([byte]))
time.sleep(0.02)
except OSError:
pass
finally:
server.close()
threading.Thread(target=serve, daemon=True).start()
public = [(2, 1, 6, '', ('93.184.216.34', 80))]
with patch.object(_images.socket, 'getaddrinfo', return_value=public), \
patch.object(_images.socket, 'create_connection', return_value=client):
start = time.monotonic()
with self.assertRaisesRegex(_images.SourceError, 'too long'):
_images.get('http://example.org/a.png', deadline=start + seconds)
return time.monotonic() - start
def test_deadline_bounds_trickling_headers_and_body(self):
self.assertLess(self.trickle(b'HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\n', 0.15), 0.4)
self.assertLess(self.trickle(b'HTTP/1.1 200 OK\r\n', 0.15), 0.4) # headers never finish
def test_deadline_covers_name_resolution(self):
import threading
from apk_sources import _images
gate = threading.Event()
with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(5) and []):
start = time.monotonic()
with self.assertRaisesRegex(_images.SourceError, 'too long'):
_images.get('https://example.org/a.png', deadline=start + 0.1)
self.assertLess(time.monotonic() - start, 0.4)
gate.set()
with self.assertRaisesRegex(_images.SourceError, 'too long'):
_images.get('https://example.org/a.png', deadline=time.monotonic() - 1)
def test_steamgriddb_uses_the_bounded_fetch_without_redirects(self):
import frame_steamgriddb as sgdb
from apk_sources import _images
with patch.object(_images, 'get', return_value=b'{"success": true, "data": [1]}') as get:
self.assertEqual(sgdb._get('/search/x', 'secret', time.monotonic() + 5), [1])
self.assertEqual(get.call_args.kwargs['redirects'], 0)
self.assertEqual(get.call_args.args[1]['Authorization'], 'Bearer secret')
self.assertLessEqual(get.call_args.kwargs['deadline'] - time.monotonic(), 5)
def test_supplied_jpeg(self): def test_supplied_jpeg(self):
data = (FIXTURES / 'icon.jpg').read_bytes() data = (FIXTURES / 'icon.jpg').read_bytes()
self.assertEqual(art.image_type(data), 'jpg') self.assertEqual(art.image_type(data), 'jpg')
@@ -296,6 +369,44 @@ class InstallTests(unittest.TestCase):
'.local/share/Steam/steamapps/shadercache/2800000001') '.local/share/Steam/steamapps/shadercache/2800000001')
self.assertIn('Steam client running', result['library_warnings'][0]) self.assertIn('Steam client running', result['library_warnings'][0])
def test_remove_waits_for_refresh_and_is_never_undone(self):
import threading
state = {'meta': dict(self.existing, flatscreen=False), 'shortcuts': {3346865537}}
in_refresh, release, added = threading.Event(), threading.Event(), []
def meta(pkg):
if not state['meta']:
raise android.FrameError(pkg + ' is not installed')
return dict(state['meta'])
def ssh(cmd, input=None, **kw):
if cmd.startswith('rm -rf Applications/Android/org.test.vr'):
state['meta'] = None
return json.dumps({'icon_png': ''}) if cmd == 'python3 -' else '/home/steamos'
def tool(*args, **kw):
if args[0] == 'list': return json.dumps([{'appid': a} for a in state['shortcuts']])
if args[0] == 'remove': state['shortcuts'].discard(int(args[1])); return '{"warnings": []}'
if args[0] == 'add': added.append(args); return '99'
if args[0] == 'render': return json.dumps({'paths': {s: '/tmp/' + s + '.png' for s in art.SLOTS}})
return '{"warnings": []}'
def prepare(*args, **kw):
in_refresh.set(); release.wait(5)
return self.images, []
with patch.object(android, '_meta_or_fail', side_effect=meta), patch.object(android, 'ssh', side_effect=ssh), \
patch.object(android, 'shortcut_tool', side_effect=tool), patch.object(android, 'stop'), \
patch.object(android, '_write_meta', side_effect=lambda d, m: state.__setitem__('meta', m)), \
patch.object(android.frame_artwork, 'prepare', side_effect=prepare):
refresh = threading.Thread(target=android.refresh_art, args=('org.test.vr',), kwargs={'fill_only': True})
refresh.start()
self.assertTrue(in_refresh.wait(5))
remove = threading.Thread(target=android.remove, args=('org.test.vr',))
remove.start()
remove.join(0.3)
self.assertTrue(remove.is_alive(), 'remove ran while a refresh was writing')
release.set(); refresh.join(5); remove.join(5)
self.assertIsNone(state['meta']); self.assertEqual(state['shortcuts'], set())
with self.assertRaisesRegex(android.FrameError, 'not installed'):
android.refresh_art('org.test.vr', fill_only=True) # a queued backfill after removal
self.assertEqual(added, [])
def test_stop_requests_steam_and_has_container_fallback(self): def test_stop_requests_steam_and_has_container_fallback(self):
with patch.object(android, '_meta_or_fail', return_value=self.existing), \ with patch.object(android, '_meta_or_fail', return_value=self.existing), \
patch.object(android, 'shortcut_tool', side_effect=android.FrameError('offline')) as api, \ patch.object(android, 'shortcut_tool', side_effect=android.FrameError('offline')) as api, \
@@ -332,7 +443,7 @@ class SteamAPITests(unittest.TestCase):
with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate: with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate:
shortcuts.configure(42, 'Game', '', '', '/icon', None, slots, {'category': 'Sideloaded'}) shortcuts.configure(42, 'Game', '', '', '/icon', None, slots, {'category': 'Sideloaded'})
js = evaluate.call_args.args[0] js = evaluate.call_args.args[0]
self.assertIn('if (null !== null)', js) self.assertIn('if (null !== null && !fill)', js)
self.assertIn('const wanted = ["Sideloaded"]', js) self.assertIn('const wanted = ["Sideloaded"]', js)
with patch.object(sys, 'argv', ['steam_shortcuts.py', 'configure', '42', 'Game', '', '', '/icon', '', with patch.object(sys, 'argv', ['steam_shortcuts.py', 'configure', '42', 'Game', '', '', '/icon', '',
json.dumps(slots), '{}']), \ json.dumps(slots), '{}']), \
@@ -411,6 +522,36 @@ class SteamContextTests(unittest.TestCase):
self.assertEqual(steam['shortcuts'], []) self.assertEqual(steam['shortcuts'], [])
self.assertEqual(steam['collections'][0]['apps'], [999]) self.assertEqual(steam['collections'][0]['apps'], [999])
@unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node')
def test_fill_only_keeps_customised_name_icon_and_art(self):
custom = {'0': {'data': 'mine-grid', 'ext': 'png'}, '1': {'data': 'mine-hero', 'ext': 'jpg'}}
steam = {'apps': [], 'compat_tools': {}, 'collections': [],
'shortcuts': [{'appid': 42, 'name': 'My Name', 'icon': '/mine.png', 'vr': True, 'artwork': dict(custom)}]}
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,
'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 tempfile.TemporaryDirectory() as home:
grid = Path(home, '.local/share/Steam/userdata/1/config/grid')
grid.mkdir(parents=True)
(grid / '42p.png').write_bytes(b'mine')
(grid / '42_hero.jpg').write_bytes(b'mine')
with patch.dict(os.environ, {'HOME': home, 'USERPROFILE': home}), patch.object(shortcuts, 'evaluate', side_effect=evaluate):
self.assertEqual(shortcuts.custom_art(42), {0, 1})
shortcuts.configure(42, 'Generated', '/exe', '/dir', '/generated.png', False,
{slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS},
{'category': 'Sideloaded', 'fill_only': True})
s = steam['shortcuts'][0]
self.assertEqual((s['name'], s['icon'], s['vr']), ('My Name', '/mine.png', True))
self.assertEqual({k: s['artwork'][k] for k in ('0', '1')}, custom)
self.assertEqual(set(s['artwork']), {'0', '1', '2', '3'})
@unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node') @unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node')
def test_remove_without_collections_or_artwork_api_still_removes(self): 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"', steam = {'apps': [], 'shortcuts': [{'appid': 42, 'name': 'Game', 'exe': '"/home/steamos/devkit-game/G/g"',
+20 -7
View File
@@ -92,21 +92,34 @@ class EntryPoints(unittest.TestCase):
server.titles({'action':'refresh-art', 'id':'G'}) server.titles({'action':'refresh-art', 'id':'G'})
title_refresh.assert_called_with('G') title_refresh.assert_called_with('G')
def test_backfill_applies_missing_art_once_steam_answers(self): def test_backfill_fills_only_entries_pending_since_install(self):
import threading import threading
ran = threading.Event() ran = threading.Event()
with patch.dict(server._backfill, {'running': False, 'last': 0.0}), \ with patch.dict(server._backfill, {'running': False, 'last': 0.0}), \
patch.object(android, 'refresh_art', side_effect=[RuntimeError('odd'), None]) as refresh, \ patch.object(android, 'refresh_art', side_effect=[RuntimeError('odd'), None]) as refresh, \
patch.object(titles, 'refresh_art', side_effect=lambda gid: ran.set()) as title_refresh: patch.object(titles, 'refresh_art', side_effect=lambda gid, **kw: ran.set()) as title_refresh:
apps = [{'package':'org.a.x','art_missing':True}, {'package':'org.b.x','art_missing':True}, apps = [{'package':'org.a.x','art_pending':True}, {'package':'org.b.x','art_pending':True},
{'package':'org.c.x','art_missing':False}] {'package':'org.c.x','art_missing':True}] # legacy: no record of art, but not pending
with contextlib.redirect_stderr(io.StringIO()): with contextlib.redirect_stderr(io.StringIO()):
self.assertTrue(server.backfill_art(apps=apps, titles=[{'id':'G','art_missing':True}])) self.assertTrue(server.backfill_art(apps=apps, titles=[{'id':'G','art_pending':True}]))
self.assertTrue(ran.wait(5)) self.assertTrue(ran.wait(5))
self.assertFalse(server.backfill_art(apps=apps)) # throttled self.assertFalse(server.backfill_art(apps=apps)) # throttled
self.assertEqual([c.args for c in refresh.call_args_list], [('org.a.x',), ('org.b.x',)]) self.assertEqual([c.args for c in refresh.call_args_list], [('org.a.x',), ('org.b.x',)])
title_refresh.assert_called_once_with('G') self.assertTrue(all(c.kwargs == {'fill_only': True} for c in refresh.call_args_list))
self.assertFalse(server.backfill_art(apps=[{'package':'org.c.x','art_missing':False}])) title_refresh.assert_called_once_with('G', fill_only=True)
def test_upgrade_leaves_legacy_customised_titles_alone(self):
# A title installed before art_pending existed, whose art the user has customised in Steam.
legacy = [{'id':'Game','settings':{'compat_tool':'proton-experimental'},'argv':['game.exe'],
'meta':{'name':'Game','target':'game.exe','source':'game.zip'}}]
with patch.object(titles, 'ssh', return_value=json.dumps(legacy)):
listed = titles.list_titles()
self.assertEqual((listed[0]['art_pending'], listed[0]['art_missing']), (False, False))
with patch.dict(server._backfill, {'running': False, 'last': 0.0}), \
patch.object(titles, 'refresh_art') as refresh, patch.object(android, 'refresh_art') as app_refresh:
self.assertFalse(server.backfill_art(apps=[{'package':'org.old.app','library_version':1}], titles=listed))
refresh.assert_not_called(); app_refresh.assert_not_called()
self.api.assert_not_called()
def test_art_missing_flags(self): def test_art_missing_flags(self):
self.assertTrue(android.art_missing({'artwork': {}})) self.assertTrue(android.art_missing({'artwork': {}}))
+1 -1
View File
@@ -234,7 +234,6 @@ class ServerGuards(unittest.TestCase):
self.assertEqual(self.post("/api/nope", {})[0], 404) self.assertEqual(self.post("/api/nope", {})[0], 404)
@unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script")
class ArtworkSettings(unittest.TestCase): class ArtworkSettings(unittest.TestCase):
"""The settings panel's endpoints, with and without the page's X-Frame-UI key.""" """The settings panel's endpoints, with and without the page's X-Frame-UI key."""
@@ -285,6 +284,7 @@ class ArtworkSettings(unittest.TestCase):
self.assertLess(page.index("async function api("), page.index('<script src="/artwork-settings.js">')) self.assertLess(page.index("async function api("), page.index('<script src="/artwork-settings.js">'))
@unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script")
class LocalMode(unittest.TestCase): class LocalMode(unittest.TestCase):
"""FRAME_LOCAL=1, as the iPhone app starts the server on the Frame: its own key """FRAME_LOCAL=1, as the iPhone app starts the server on the Frame: its own key
guards /api/, and ssh goes to ui/local-bin/ssh, which runs commands here.""" guards /api/, and ssh goes to ui/local-bin/ssh, which runs commands here."""
+60 -19
View File
@@ -65,53 +65,94 @@ def image_type(data):
def fetch(url, redirects=3, deadline=None, limit=MAX_IMAGE): def fetch(url, redirects=3, deadline=None, limit=MAX_IMAGE):
"""deadline: time.monotonic() value by which the whole fetch, redirects included, must finish.""" """deadline: time.monotonic() value by which the whole fetch, redirects included, must finish."""
data = get(url, {'Accept': 'image/png,image/jpeg,image/webp,image/gif'}, redirects, deadline, limit)
return data, image_type(data)
def _resolve(host, port, timeout):
# getaddrinfo has no timeout of its own; a thread keeps a slow resolver inside the budget.
found = {}
def run():
try:
found['addresses'] = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except OSError as e:
found['error'] = e
worker = threading.Thread(target=run, daemon=True)
worker.start()
worker.join(timeout)
if 'error' in found:
raise found['error']
if 'addresses' not in found:
raise SourceError('Artwork download took too long')
return found['addresses']
def get(url, headers=None, redirects=3, deadline=None, limit=MAX_IMAGE):
"""GET a public HTTP(S) URL within an overall deadline (default 60 s), redirects included.
A watchdog shuts the socket at the deadline, so a server trickling bytes can't outlast it."""
deadline = time.monotonic() + 60 if deadline is None else deadline
def left():
remaining = deadline - time.monotonic()
if remaining <= 0:
raise SourceError('Artwork download took too long')
return remaining
if not valid_url(url): if not valid_url(url):
raise SourceError('Artwork URL is not allowed') raise SourceError('Artwork URL is not allowed')
def remaining():
if deadline is None:
return 10
left = deadline - time.monotonic()
if left <= 0:
raise SourceError('Artwork download took too long')
return min(10, left)
p = urlsplit(url) p = urlsplit(url)
port = p.port or (443 if p.scheme == 'https' else 80) port = p.port or (443 if p.scheme == 'https' else 80)
addresses = socket.getaddrinfo(p.hostname, port, type=socket.SOCK_STREAM) addresses = _resolve(p.hostname, port, left())
if not addresses or any(not ipaddress.ip_address(a[4][0]).is_global for a in addresses): if not addresses or any(not ipaddress.ip_address(a[4][0]).is_global for a in addresses):
raise SourceError('Private network artwork is not allowed') raise SourceError('Private network artwork is not allowed')
# Connect to the checked IP, never resolve again between validation and use. # Connect to the checked IP, never resolve again between validation and use.
sock = socket.create_connection((addresses[0][4][0], port), timeout=remaining()) live = [socket.create_connection((addresses[0][4][0], port), timeout=min(10, left()))]
def expire():
try:
live[0].shutdown(socket.SHUT_RDWR)
except OSError:
pass
watchdog = threading.Timer(left(), expire)
watchdog.daemon = True
watchdog.start()
conn = http.client.HTTPConnection(p.hostname, port, timeout=10) conn = http.client.HTTPConnection(p.hostname, port, timeout=10)
try: try:
if p.scheme == 'https': if p.scheme == 'https':
sock = ssl.create_default_context().wrap_socket(sock, server_hostname=p.hostname) live[0] = ssl.create_default_context().wrap_socket(live[0], server_hostname=p.hostname)
conn.sock = sock conn.sock = live[0]
path = p.path or '/' path = p.path or '/'
if p.query: if p.query:
path += '?' + p.query path += '?' + p.query
conn.request('GET', path, headers={'User-Agent': 'FrameControl/0.3.1', 'Accept': 'image/png,image/jpeg,image/webp,image/gif'}) conn.request('GET', path, headers={'User-Agent': 'FrameControl/0.3.1', **(headers or {})})
sock.settimeout(remaining()) live[0].settimeout(min(10, left()))
response = conn.getresponse() response = conn.getresponse()
if response.status in (301, 302, 303, 307, 308) and redirects: if response.status in (301, 302, 303, 307, 308) and redirects:
target = urljoin(url, response.getheader('Location', '')) target = urljoin(url, response.getheader('Location', ''))
conn.close() conn.close()
return fetch(target, redirects - 1, deadline, limit) return get(target, headers, redirects - 1, deadline, limit)
if response.status != 200: if response.status != 200:
raise SourceError('Artwork is unavailable') raise SourceError('Artwork is unavailable')
data = b'' data = b''
while len(data) <= limit: while len(data) <= limit:
sock.settimeout(remaining()) live[0].settimeout(min(10, left()))
chunk = response.read(min(65536, limit + 1 - len(data))) chunk = response.read1(min(16384, limit + 1 - len(data))) # one receive at most
if not chunk: if not chunk:
break break
data += chunk data += chunk
if len(data) > limit: if len(data) > limit:
raise SourceError('Artwork is too large') raise SourceError('Artwork is too large')
return data, image_type(data) left()
return data
except (OSError, http.client.HTTPException) as e:
if time.monotonic() >= deadline:
raise SourceError('Artwork download took too long') from e
raise
finally: finally:
watchdog.cancel()
conn.close() conn.close()
sock.close() live[0].close()
def remember(url, data): def remember(url, data):
+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, 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. """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() home = home or ssh('echo $HOME').strip()
d = directory d = directory
ssh(f'mkdir -p {shlex.quote(d)}/artwork') 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') raise FrameError('Steam artwork renderer did not produce every slot')
result = json.loads(shortcut_tool('configure', str(shortcut), label, exe, start_dir, art['icon'], 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), '' 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['warnings'] = rendered.get('warnings', []) + result.get('warnings', [])
result['artwork'] = art result['artwork'] = art
if category == 'Android': if category == 'Android':
@@ -277,8 +279,10 @@ def art_missing(m):
return set((m or {}).get('artwork') or {}) != set(frame_artwork.SLOTS) return set((m or {}).get('artwork') or {}) != set(frame_artwork.SLOTS)
def refresh_art(pkg=None, artwork=None): def refresh_art(pkg=None, artwork=None, fill_only=False):
"""Refresh existing APK library entries without reinstalling or stopping them.""" """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: if pkg is None:
results = [] results = []
for app in list_apps(): 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 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__}) results.append({'package': app['package'], 'label': app.get('label'), 'error': str(e) or type(e).__name__})
return results return results
with _install_lock: with _install_lock: # shared with install and remove: a removed app is never recreated
m = _meta_or_fail(pkg) m = _meta_or_fail(pkg)
d = f'{APPS_DIR}/{pkg}' d = f'{APPS_DIR}/{pkg}'
# Parse the APK on the Frame, transferring only its icon/label, not the APK. # 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 created = True
try: try:
result = apply_library(m['shortcut'], m['label'], d, images, vr=not m.get('flatscreen', True), 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: except Exception:
if created: if created:
try: try:
@@ -323,7 +328,7 @@ def refresh_art(pkg=None, artwork=None):
except FrameError: except FrameError:
pass # keep the render error, not the cleanup's pass # keep the render error, not the cleanup's
raise 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['library_warnings'] = warnings + result.get('warnings', [])
m['artwork_refreshed'] = time.strftime('%Y-%m-%dT%H:%M:%S') m['artwork_refreshed'] = time.strftime('%Y-%m-%dT%H:%M:%S')
_write_meta(d, m) _write_meta(d, m)
@@ -411,6 +416,11 @@ def stop(pkg):
def remove(pkg, keep_data=False): 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) m = _meta_or_fail(pkg)
stop(pkg) stop(pkg)
if m['shortcut']: if m['shortcut']:
+5 -14
View File
@@ -6,7 +6,6 @@ import tempfile
import time import time
import unicodedata import unicodedata
import urllib.parse import urllib.parse
import urllib.request
import frame_host import frame_host
@@ -51,19 +50,11 @@ def save_settings(body):
def _get(path, key, deadline=None): def _get(path, key, deadline=None):
timeout = 12 if deadline is None else min(12, deadline - time.monotonic()) from apk_sources import _images
if timeout <= 0: # No redirects: the credential never goes anywhere but the API. Errors never contain it.
raise ValueError('SteamGridDB lookup took too long') data = _images.get(API + path, {'Authorization': 'Bearer ' + key, 'Accept': 'application/json'}, redirects=0,
request = urllib.request.Request(API + path, headers={'Authorization': 'Bearer ' + key, deadline=time.monotonic() + 12 if deadline is None else min(deadline, time.monotonic() + 12),
'User-Agent': 'FrameControl/1.0'}) limit=MAX_JSON)
# Do not carry the credential to redirects or include it in error messages.
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
with urllib.request.build_opener(NoRedirect()).open(request, timeout=timeout) as response:
data = response.read(MAX_JSON + 1)
if len(data) > MAX_JSON:
raise ValueError('SteamGridDB response too large')
result = json.loads(data) result = json.loads(data)
if not isinstance(result, dict) or not result.get('success') or not isinstance(result.get('data'), list): if not isinstance(result, dict) or not result.get('success') or not isinstance(result.get('data'), list):
raise ValueError('SteamGridDB lookup failed') raise ValueError('SteamGridDB lookup failed')
+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))}', reply = _json_out(ssh(f'{PY}steam-client-create-shortcut --parms {shlex.quote(json.dumps(parms))}',
timeout=90), 'steam-client-create-shortcut') timeout=90), 'steam-client-create-shortcut')
meta = {'id': gid, 'name': plan['name'], 'target': plan['target'], 'runtime': plan['runtime'], 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) 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 registered = True # the files stay: Steam registers them once it's running
if 'error' in reply: 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') 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, result = frame_android.apply_library(shortcut, plan['name'], directory + '/.frame-artwork', images,
category='Sideloaded', details={'source': plan['source']}) 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', [])) library_warnings=warnings + result.get('warnings', []))
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30) ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
library_ready = True library_ready = True
@@ -768,8 +770,10 @@ def _home():
return ssh('echo $HOME', timeout=30).strip() return ssh('echo $HOME', timeout=30).strip()
def refresh_art(gid=None, artwork=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).""" """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: if gid is None:
results = [] results = []
for t in list_titles(): for t in list_titles():
@@ -809,8 +813,9 @@ print(json.dumps({{'artwork': cached, 'icon': icon}}))
if not shortcut: if not shortcut:
raise FrameError(f"Steam hasn't registered {gid} yet; with Steam running on the Frame, refresh again") 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, result = frame_android.apply_library(shortcut, name, directory + '/.frame-artwork', images,
category='Sideloaded', details={'source': meta.get('source')}) category='Sideloaded', details={'source': meta.get('source')},
meta.update(shortcut=shortcut, artwork=result.get('artwork', {}), fill_only=fill_only)
meta.update(shortcut=shortcut, artwork=result.get('artwork', {}), art_pending=False,
library_warnings=warnings + result.get('warnings', []), library_warnings=warnings + result.get('warnings', []),
artwork_refreshed=time.strftime('%Y-%m-%dT%H:%M:%S')) 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) 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'), 'runtime': alias, 'runtime_label': RUNTIMES.get(alias, {}).get('label', alias or 'not set'),
'source': str(meta.get('source') or ''), 'size': meta.get('size'), 'source': str(meta.get('source') or ''), 'size': meta.get('size'),
'installed': meta.get('installed'), 'registered': t.get('settings') is not None, '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 return titles
+6 -5
View File
@@ -153,11 +153,12 @@ _backfill_lock = threading.Lock()
def backfill_art(apps=(), titles=()): 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.""" Only entries marked art_pending at install; it fills empty Steam slots and never replaces art or
pkgs = [a["package"] for a in apps if a.get("art_missing")] names the user may have customised. Runs in the background, at most once every five minutes."""
gids = [t["id"] for t in titles if t.get("art_missing")] 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: with _backfill_lock:
if not (pkgs or gids) or _backfill["running"] or time.time() - _backfill["last"] < 300: if not (pkgs or gids) or _backfill["running"] or time.time() - _backfill["last"] < 300:
return False return False
@@ -169,7 +170,7 @@ def backfill_art(apps=(), titles=()):
for refresh, key in [(frame_android.refresh_art, p) for p in pkgs] + \ for refresh, key in [(frame_android.refresh_art, p) for p in pkgs] + \
[(frame_titles.refresh_art, g) for g in gids]: [(frame_titles.refresh_art, g) for g in gids]:
try: try:
refresh(key) refresh(key, fill_only=True)
except Exception as e: except Exception as e:
print(f"artwork backfill for {key}: {e}", file=sys.stderr) print(f"artwork backfill for {key}: {e}", file=sys.stderr)
except Exception: except Exception: