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

This commit is contained in:
saphid committed 2026-09-28 23:09:14 +10:00
commit 7990553620
18 files changed
+743 -323

No files matched your search

+36 -8
View File
@@ -104,11 +104,20 @@ non-NSFW image in each slot. Provider failures use the next source.
Sources pass `install(apk_path, artwork={...})`: keys are `grid`, `wide`,
`hero`, `logo`, `icon`, `banner`, `feature_graphic`, `screenshot`, or a list
`screenshots`. Values are PNG/JPEG bytes or HTTP(S) URLs (12 MiB/8 million
pixels maximum). Banners and feature graphics supply hero/wide art;
`screenshots`. Values are PNG/JPEG bytes or HTTP(S) URLs (12 MiB and
4096×4096 pixels maximum; any PNG depth or interlace, since the Frame's
Chromium decodes them). URLs must resolve to public addresses, follow at most
three redirects and share one deadline per install. Any source that fails,
for any reason, becomes a warning and generated art. Banners and feature graphics supply hero/wide art;
screenshots are the next fallback. Source images are cached for refresh.
All images are fitted to 600×900 portrait, 920×430 wide, 3840×1240 hero,
1280×480 logo and 256×256 icon. Explicit logos retain transparency.
Photo-based portrait, wide and hero slots are JPEG: Steam takes at most
12 MiB per slot, and on the Frame (2026-09-28) a noise-heavy 3840×1240 hero
came to more than 12 MiB as PNG, 3.7 MB as JPEG (2.7 s to render); a
landscape photo hero 5.6 MB as PNG, 0.76 MB as JPEG (0.75 s). A render that
still fails is retried once with generated art. Steam keeps a slot's `.png`
and `.jpg` side by side, so each slot is cleared before it is set.
Generated art uses the APK icon, a dominant-colour gradient, a blurred
backdrop and large foreground icon with shadow. Steam's Chromium canvas and
@@ -124,10 +133,26 @@ python3 ui/frame_android.py refresh-art org.godotengine.open_saber_plus
python3 ui/frame_android.py refresh-art --all
```
The settings panel offers the same refresh-all action. The API is
`POST /api/android` with `{"action":"refresh-art","all":true}` or a
`package` instead of `all`; it returns a background job. Batch results retain
per-app errors, and the CLI exits nonzero if any failed.
Devkit titles installed by Frame Control have the same command,
`python3 ui/frame_titles.py refresh-art ID|--all`. The settings panel's
refresh covers both. The API is `POST /api/android` with
`{"action":"refresh-art","all":true}` (apps and titles) or a `package`, and
`POST /api/titles` with `{"action":"refresh-art","id":…}`; each returns a
background job. Batch results retain per-item errors, and the CLIs exit
nonzero if any failed. Apps and titles without complete artwork show **Add
artwork**, `list` prints the command, and when Frame Control lists them while
Steam is running it re-applies their art in the background (at most every
five minutes), for example for a title Steam registered after an install made
while it wasn't running.
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
its saved id, or by an executable or start folder inside
`~/devkit-game/<id>/`, read from `appDetailsStore`; never by display name.
That the devkit shortcut's exe/start folder sit inside the title folder is
inferred from `docs/sideloading.md` (`proton waitforexitandrun
"/home/steamos/devkit-game/<id>/<exe>"`), not yet seen in app details.
Devkit titles keep the VR flag Steam gave them.
**Verified on build 20260925.6191901, SteamVR 2.18.1 (2026-09-28):** both
Open Saber Plus and SuperTux were backfilled. Steam's cached portrait, wide,
@@ -148,8 +173,11 @@ supported shortcut description/store-page, developer/publisher, release
metadata or custom achievement API was found; these are not fabricated.
The launcher supervises Lepton and handles TERM/INT/HUP and normal exit by
stopping its own container and child process group. A lock and container check
refuse duplicate launches. Steam Stop uses `TerminateApp` with the exact
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
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
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
fallback. The stable instance ID and compatdata paths remain unchanged.
+6 -3
View File
@@ -30,9 +30,11 @@ fi
exec 9>"$DIR/launch.lock"
flock -n 9 || { echo "Android app is already running" >&2; exit 1; }
CONTAINER="lepton-steamlaunch-$SteamAppId"
# 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.
if [[ "$(podman inspect --format '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" == true ]]; then
echo "Android container is already running" >&2
exit 1
echo "Stopping orphaned $CONTAINER" >&2
podman stop -t 5 "$CONTAINER" >/dev/null 2>&1 || true
fi
export STEAM_COMPAT_INSTALL_PATH="$DIR"
# Must be under ~/.local/share/Steam: only that tree is mounted in the container.
@@ -63,7 +65,8 @@ trap cleanup EXIT
trap 'exit 143' TERM
trap 'exit 130' INT
trap 'exit 129' HUP
setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" &
# 9>&-: the lock is this launcher's alone; Lepton's tree mustn't keep it held.
setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" 9>&- &
child=$!
rc=0
wait "$child" || rc=$?
+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:{}});
+140 -59
View File
@@ -24,7 +24,7 @@ spec.loader.exec_module(shortcuts)
@unittest.skipIf(os.name == 'nt', 'POSIX launcher')
class LauncherTests(unittest.TestCase):
def exercise(self, terminate, sig=signal.SIGTERM, blocked=None):
def exercise(self, terminate, sig=signal.SIGTERM, blocked=None, orphan=False):
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
app = d / 'Applications/Android/org.test.app'
@@ -44,6 +44,7 @@ class LauncherTests(unittest.TestCase):
'assert os.environ["SteamAppId"] == "2800000001"\n'
'assert os.environ["LEPTON_ENV_SteamAppId"] == "3346865537"\n'
'Path(os.environ["HOME"],"started").write_text(str(os.getpid()))\n'
'try:\n os.fstat(9); Path(os.environ["HOME"],"inherited-lock").touch()\nexcept OSError: pass\n'
+ ('time.sleep(30)\n' if terminate else 'raise SystemExit(23)\n'))
script(bin_dir / 'setsid', 'import os,sys\nos.setsid()\nos.execv(sys.argv[2],sys.argv[2:])\n')
script(bin_dir / 'flock', 'import os\nraise SystemExit(1 if os.environ.get("TEST_LOCKED") else 0)\n') # lock semantics belong to Linux; no flock on macOS
@@ -54,6 +55,8 @@ class LauncherTests(unittest.TestCase):
env = {**os.environ, 'HOME': str(d), 'PATH': str(bin_dir) + os.pathsep + os.environ['PATH']}
if blocked:
env['TEST_' + blocked] = '1'
if orphan:
env['TEST_RUNNING'] = '1'
saved = d / '.local/share/Steam/steamapps/compatdata/2800000001/internal/save'
saved.parent.mkdir(parents=True)
saved.write_text('saved game')
@@ -70,12 +73,14 @@ class LauncherTests(unittest.TestCase):
while not (d / 'started').exists() and proc.poll() is None and time.monotonic() < deadline:
time.sleep(.02)
self.assertTrue((d / 'started').exists(), 'launcher did not start Lepton')
self.assertFalse((d / 'inherited-lock').exists(), 'Lepton inherited the launch lock')
if terminate:
self.assertIsNone(proc.poll(), 'Steam-tracked wrapper exited during the session')
proc.send_signal(sig)
_, err = proc.communicate(timeout=5)
calls = (d / 'podman-calls').read_text() if (d / 'podman-calls').exists() else ''
self.assertIn('stop -t 5 lepton-steamlaunch-2800000001', calls, err.decode())
self.assertEqual(calls.count('stop -t 5'), 2 if orphan else 1)
self.assertEqual(proc.returncode, 128 + sig if terminate else 23)
self.assertEqual(saved.read_text(), 'saved game')
self.assertTrue((app / 'app.apk').exists())
@@ -98,9 +103,11 @@ class LauncherTests(unittest.TestCase):
self.exercise(True, sig)
def test_duplicate_launch_leaves_existing_session_alone(self):
for blocked in ('LOCKED', 'RUNNING'):
with self.subTest(blocked=blocked):
self.exercise(False, blocked=blocked)
self.exercise(False, blocked='LOCKED')
def test_orphaned_container_is_stopped_and_play_proceeds(self):
# Container running but the lock free: its launcher was SIGKILLed.
self.exercise(False, orphan=True)
def test_normal_exit_cleans_container_and_keeps_exit_code(self):
self.exercise(False)
@@ -110,29 +117,17 @@ FIXTURES = ROOT / 'tests/fixtures/library'
class ArtworkTests(unittest.TestCase):
def test_icon_roundtrip_and_transparency(self):
w, h, pixels = art.decode((FIXTURES / 'icon.png').read_bytes())
self.assertEqual((w, h), (2, 2))
self.assertEqual(pixels, bytes([255, 0, 0, 255, 0, 255, 0, 255,
0, 0, 255, 128, 0, 0, 0, 0]))
background = bytearray([10, 20, 30, 255] * 4)
art.stamp(background, 2, (w, h, pixels), 0, 0, 2)
self.assertEqual(background[:8], pixels[:8])
self.assertEqual(background[8:12], bytes([4, 9, 142, 255]))
self.assertEqual(background[12:], bytes([10, 20, 30, 255]))
def test_source_inputs_and_url(self):
import io
from apk_sources import _images
data = (FIXTURES / 'icon.png').read_bytes()
response = io.BytesIO(data)
response.geturl = lambda: 'https://example.org/icon.png'
with patch('frame_steamgriddb.lookup', return_value=({}, [])), \
patch.object(art.urllib.request, 'urlopen', return_value=response) as fetch:
patch.object(_images, 'fetch', return_value=(data, 'image/png')) as fetch:
images, warnings = art.prepare('Game', artwork={'banner': data, 'icon': 'https://example.org/icon.png'})
self.assertEqual(images['banner'], ('png', data))
self.assertEqual(images['icon'], ('png', data))
self.assertEqual(warnings, [])
self.assertEqual(fetch.call_count, 1)
self.assertEqual(fetch.call_args.args[0], 'https://example.org/icon.png')
self.assertIsNotNone(fetch.call_args.kwargs['deadline'])
def test_provider_precedence_and_bad_source_fallback(self):
data = (FIXTURES / 'icon.png').read_bytes()
@@ -143,48 +138,63 @@ class ArtworkTests(unittest.TestCase):
self.assertEqual(images['icon'], ('png', data))
self.assertEqual(images['screenshot'], ('png', data))
self.assertNotIn('wide', images)
self.assertEqual(len(warnings), 2)
self.assertEqual(warnings, ['Source wide unavailable; using fallback art']) # one per slot, not per candidate
def test_any_source_failure_falls_back_to_generated_art(self):
import http.client
from apk_sources import _images
data = (FIXTURES / 'icon.png').read_bytes()
for error in (http.client.RemoteDisconnected('gone'), http.client.IncompleteRead(b''), AttributeError('x')):
with self.subTest(error=type(error).__name__), \
patch.object(_images, 'fetch', side_effect=error), \
patch('frame_steamgriddb.lookup', side_effect=error):
images, warnings = art.prepare('Game', data, {'banner': 'https://example.org/b.png'})
self.assertEqual(set(images), {'icon'})
self.assertEqual(len(warnings), 2)
def test_url_fetch_refuses_private_hosts_and_honours_deadline(self):
from apk_sources import _images, SourceError
local = [(2, 1, 6, '', ('127.0.0.1', 443))]
with patch.object(_images.socket, 'getaddrinfo', return_value=local), \
self.assertRaisesRegex(SourceError, 'Private'):
art.fetch('https://example.org/icon.png')
public = [(2, 1, 6, '', ('93.184.216.34', 443))]
with patch.object(_images.socket, 'getaddrinfo', return_value=public), \
patch.object(_images.socket, 'create_connection') as connect, \
self.assertRaisesRegex(SourceError, 'too long'):
art.fetch('https://example.org/icon.png', deadline=time.monotonic() - 1)
connect.assert_not_called()
with self.assertRaises(SourceError):
art.fetch('file:///etc/passwd')
def test_supplied_jpeg(self):
data = (FIXTURES / 'icon.jpg').read_bytes()
self.assertEqual(art.image_type(data), 'jpg')
self.assertEqual(art.image_type(data + b'\0' * 64), 'jpg') # trailing padding after EOI
with self.assertRaises(ValueError):
art.image_type(data[:30])
def test_bad_artwork_and_expansion_limits(self):
import zlib
def test_png_variants_left_to_chromium_and_limits(self):
def png(w, h, depth, color, interlace):
return art.PNG + art.chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, depth, color, 0, 0, interlace)) + \
art.chunk(b'IEND', b'')
self.assertEqual(art.image_type(png(3840, 1240, 16, 6, 0)), 'png')
self.assertEqual(art.image_type(png(3840, 2160, 8, 2, 1)), 'png')
for bad, message in ((png(10000, 10, 8, 6, 0), 'dimensions'), (png(5000, 5000, 8, 6, 0), 'dimensions'),
(png(10, 10, 3, 6, 0), 'encoding'), (png(10, 10, 8, 5, 0), 'encoding')):
with self.subTest(message=message), self.assertRaisesRegex(ValueError, message):
art.image_type(bad)
broken = bytearray(png(10, 10, 8, 6, 0))
broken[20] ^= 1
with self.assertRaisesRegex(ValueError, 'checksum'):
art.image_type(bytes(broken))
with self.assertRaises(ValueError):
art.image_type(art.PNG + b'junk')
def test_bad_artwork_arguments(self):
for value in ({'bad': b'bad'}, ['hero']):
with self.subTest(value=value), self.assertRaises(ValueError):
art.prepare('Game', artwork=value)
data = bytearray((FIXTURES / 'icon.png').read_bytes())
data[45] ^= 1
with self.assertRaisesRegex(ValueError, 'checksum'):
art.decode(bytes(data))
bomb = art.PNG + art.chunk(b'IHDR', struct.pack('>IIBBBBB', 1, 1, 8, 6, 0, 0, 0)) + \
art.chunk(b'IDAT', zlib.compress(b'\0' * 1_000_000)) + art.chunk(b'IEND', b'')
with self.assertRaisesRegex(ValueError, 'pixels'):
art.decode(bomb)
huge = art.PNG + art.chunk(b'IHDR', struct.pack('>IIBBBBB', 10000, 10000, 8, 6, 0, 0, 0)) + art.chunk(b'IEND', b'')
with self.assertRaisesRegex(ValueError, 'dimensions'):
art.decode(huge)
def test_palette_and_filters(self):
import zlib
header = art.chunk(b'IHDR', struct.pack('>IIBBBBB', 2, 1, 1, 3, 0, 0, 0))
data = art.PNG + header + art.chunk(b'PLTE', b'\xff\0\0\0\xff\0') + art.chunk(b'tRNS', b'\xff\x80') + \
art.chunk(b'IDAT', zlib.compress(b'\0\x40')) + art.chunk(b'IEND', b'')
self.assertEqual(art.decode(data)[2], bytes([255, 0, 0, 255, 0, 255, 0, 128]))
for method in range(5):
# Two identical RGBA rows: exercise each predictor with known filtered bytes.
first = bytes([10, 20, 30, 255] * 2)
filtered = bytearray()
for x, value in enumerate(first):
a, b, c = first[x-4] if x >= 4 else 0, first[x], first[x-4] if x >= 4 else 0
predictor = (0, a, b, (a+b)//2, b)[method]
filtered.append((value - predictor) & 255)
data = art.PNG + art.chunk(b'IHDR', struct.pack('>IIBBBBB', 2, 2, 8, 6, 0, 0, 0)) + \
art.chunk(b'IDAT', zlib.compress(b'\0' + first + bytes([method]) + filtered)) + art.chunk(b'IEND', b'')
self.assertEqual(art.decode(data)[2], first * 2)
def test_godot_project_icon(self):
import io
@@ -270,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), \
@@ -302,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)
@@ -312,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']), \
@@ -325,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,
@@ -347,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()
+117 -7
View File
@@ -81,12 +81,37 @@ class EntryPoints(unittest.TestCase):
self.assertEqual(result['kind'], 'apk')
self.assert_art()
def test_refresh_api(self):
def test_refresh_api_covers_android_apps_and_titles(self):
with patch.object(server, 'ensure_master'), \
patch.object(server, 'start_job', side_effect=lambda label, work: work()), \
patch.object(android, 'refresh_art', return_value=[]) as refresh:
self.assertEqual(server.android({'action':'refresh-art', 'all':True}), {'apps':[]})
refresh.assert_called_once_with(None)
patch.object(android, 'refresh_art', return_value=[]) as refresh, \
patch.object(titles, 'refresh_art', return_value=[{'id':'G','error':'x'}]) as title_refresh:
self.assertEqual(server.android({'action':'refresh-art', 'all':True}),
{'apps':[], 'titles':[{'id':'G','error':'x'}]})
refresh.assert_called_once_with(); title_refresh.assert_called_once_with()
server.titles({'action':'refresh-art', 'id':'G'})
title_refresh.assert_called_with('G')
def test_backfill_applies_missing_art_once_steam_answers(self):
import threading
ran = threading.Event()
with patch.dict(server._backfill, {'running': False, 'last': 0.0}), \
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:
apps = [{'package':'org.a.x','art_missing':True}, {'package':'org.b.x','art_missing':True},
{'package':'org.c.x','art_missing':False}]
with contextlib.redirect_stderr(io.StringIO()):
self.assertTrue(server.backfill_art(apps=apps, titles=[{'id':'G','art_missing':True}]))
self.assertTrue(ran.wait(5))
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',)])
title_refresh.assert_called_once_with('G')
self.assertFalse(server.backfill_art(apps=[{'package':'org.c.x','art_missing':False}]))
def test_art_missing_flags(self):
self.assertTrue(android.art_missing({'artwork': {}}))
self.assertTrue(android.art_missing({'artwork': {'grid': 'x'}}))
self.assertFalse(android.art_missing({'artwork': {s: 'x' for s in artwork.SLOTS}}))
def test_source_search_shared_installer_contract(self):
# Source workers hand their download and optional images to this public seam.
@@ -110,11 +135,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:
@@ -140,8 +237,10 @@ class EntryPoints(unittest.TestCase):
stop.assert_not_called(); copy.assert_not_called(); self.assert_art()
def test_all_refresh_reports_partial_failures(self):
import http.client
with patch.object(android, 'list_apps', return_value=[{'package':'org.a.game'},{'package':'org.b.game'}]), \
patch.object(android, '_meta_or_fail', side_effect=android.FrameError('missing')):
patch.object(android, '_meta_or_fail', side_effect=[http.client.RemoteDisconnected('gone'),
AttributeError('odd')]):
result=android.refresh_art()
self.assertEqual(len(result),2)
self.assertTrue(all('error' in a for a in result))
@@ -173,7 +272,7 @@ class SteamGridDB(unittest.TestCase):
def test_exact_match_and_top_votes_per_slot(self):
calls=[]
def get(path,key):
def get(path,key,deadline=None):
calls.append(path)
if 'search' in path: return [{'id':1,'name':'Other Game'},{'id':2,'name':'Game'}]
dims=(600,900) if '600x900' in path else (920,430)
@@ -186,6 +285,17 @@ class SteamGridDB(unittest.TestCase):
self.assertTrue(all(url.endswith('/top.png') for url in images.values()))
self.assertTrue(all('/game/2?' in p for p in calls[1:]))
def test_unicode_titles_match_exactly_and_symbols_never_match_all(self):
self.assertEqual(sgdb._name('ビートセイバー VR!'), 'ビートセイバーvr')
with patch.object(sgdb,'api_key',return_value='test-key'), \
patch.object(sgdb,'_get',return_value=[{'id':1,'name':'Unrelated'},{'id':2,'name':'!!!'}]) as get:
self.assertEqual(sgdb.lookup('★★★'),({},[]))
get.assert_not_called()
self.assertEqual(sgdb.lookup('ビートセイバー'),({},[]))
with patch.object(sgdb,'api_key',return_value='test-key'), \
patch.object(sgdb,'_get',side_effect=AttributeError("'list' object has no attribute 'get'")):
self.assertEqual(sgdb.lookup('Game')[0],{})
def test_wrong_title_and_failed_lookup_fall_back(self):
with patch.object(sgdb,'api_key',return_value='test-key'),patch.object(sgdb,'_get',return_value=[{'id':1,'name':'Unrelated'}]):
self.assertEqual(sgdb.lookup('Game'),({},[]))
+50
View File
@@ -235,6 +235,56 @@ class ServerGuards(unittest.TestCase):
@unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script")
class ArtworkSettings(unittest.TestCase):
"""The settings panel's endpoints, with and without the page's X-Frame-UI key."""
def test_settings_need_and_accept_the_ui_key(self):
with tempfile.TemporaryDirectory() as home:
port = free_port()
env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1",
"HOME": home, "APPDATA": home, "XDG_DATA_HOME": home}
for name in ("STEAMGRIDDB_API_KEY", "FRAME_STEAMGRIDDB_API_KEY", "FRAME_UI_KEY"):
env.pop(name, None)
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(port)],
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
def request(method, path, body=None, headers=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
conn.request(method, path, body=json.dumps(body).encode() if body is not None else None,
headers=headers or {})
r = conn.getresponse()
payload = r.read()
conn.close()
return r.status, payload
for _ in range(100):
try:
if request("GET", "/")[0] == 200:
break
except OSError:
time.sleep(0.05)
key = {"X-Frame-UI": "1", "Content-Type": "application/json"}
self.assertEqual(request("GET", "/api/settings/artwork")[0], 403)
self.assertEqual(request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc"})[0], 403)
status, payload = request("GET", "/api/settings/artwork", headers=key)
self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, False))
status, payload = request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc_1"}, key)
self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, True))
self.assertNotIn(b"abc_1", payload)
status, payload = request("GET", "/api/settings/artwork", headers=key)
self.assertTrue(json.loads(payload)["steamgriddb_configured"])
self.assertEqual(request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "a b"}, key)[0], 400)
finally:
proc.terminate()
proc.wait(timeout=10)
def test_panel_script_uses_the_keyed_api_helper(self):
script = (ROOT / "ui" / "artwork-settings.js").read_text()
self.assertNotIn("fetch(", script)
self.assertIn("api('/api/settings/artwork'", script)
page = (ROOT / "ui" / "index.html").read_text()
self.assertLess(page.index("async function api("), page.index('<script src="/artwork-settings.js">'))
class LocalMode(unittest.TestCase):
"""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."""
+22 -5
View File
@@ -6,6 +6,7 @@ import secrets
import socket
import ssl
import threading
import time
from urllib.parse import urljoin, urlsplit
from apk_sources import SourceError
@@ -62,16 +63,25 @@ def image_type(data):
raise SourceError('Artwork is not a supported image')
def fetch(url, redirects=3):
def fetch(url, redirects=3, deadline=None, limit=MAX_IMAGE):
"""deadline: time.monotonic() value by which the whole fetch, redirects included, must finish."""
if not valid_url(url):
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)
port = p.port or (443 if p.scheme == 'https' else 80)
addresses = socket.getaddrinfo(p.hostname, port, type=socket.SOCK_STREAM)
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')
# Connect to the checked IP, never resolve again between validation and use.
sock = socket.create_connection((addresses[0][4][0], port), timeout=10)
sock = socket.create_connection((addresses[0][4][0], port), timeout=remaining())
conn = http.client.HTTPConnection(p.hostname, port, timeout=10)
try:
if p.scheme == 'https':
@@ -81,15 +91,22 @@ def fetch(url, redirects=3):
if 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'})
sock.settimeout(remaining())
response = conn.getresponse()
if response.status in (301, 302, 303, 307, 308) and redirects:
target = urljoin(url, response.getheader('Location', ''))
conn.close()
return fetch(target, redirects - 1)
return fetch(target, redirects - 1, deadline, limit)
if response.status != 200:
raise SourceError('Artwork is unavailable')
data = response.read(MAX_IMAGE + 1)
if len(data) > MAX_IMAGE:
data = b''
while len(data) <= limit:
sock.settimeout(remaining())
chunk = response.read(min(65536, limit + 1 - len(data)))
if not chunk:
break
data += chunk
if len(data) > limit:
raise SourceError('Artwork is too large')
return data, image_type(data)
finally:
+10 -11
View File
@@ -1,15 +1,12 @@
// Uses index.html's api(), which sends the X-Frame-UI key every /api call needs.
(() => {
const get=id=>document.getElementById(id), status=get('steamGridStatus');
async function request(url,body) {
const r=await fetch(url,body===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const data=await r.json(); if(!r.ok) throw Error(data.error||'Request failed'); return data;
}
function show(data) {
status.textContent=data.environment?'SteamGridDB key is set by an environment variable.':
data.steamgriddb_configured?'SteamGridDB key saved.':'No key configured. Source images and generated art are enabled.';
}
async function save(value) {
try {show(await request('/api/settings/artwork',{steamgriddb_api_key:value}));get('steamGridKey').value='';}
try {show(await api('/api/settings/artwork',{steamgriddb_api_key:value}));get('steamGridKey').value='';}
catch(e) {status.textContent=e.message;}
}
get('saveSteamGridKey').onclick=()=>save(get('steamGridKey').value.trim());
@@ -17,15 +14,17 @@
get('refreshAndroidArt').onclick=async()=>{
const button=get('refreshAndroidArt');button.disabled=true;
try {
const result=await runJob('Refresh Android artwork','android-artwork',()=>request('/api/android',{action:'refresh-art',all:true}));
const result=await runJob('Refresh library artwork','library-artwork',()=>api('/api/android',{action:'refresh-art',all:true}));
if (result) {
const apps=Array.isArray(result.apps)?result.apps:[result.apps];
const failed=apps.filter(a=>a.error);
status.textContent=failed.length?`${failed.length} refresh failed: ${failed.map(a=>a.package+': '+a.error).join('; ')}`:
`Refreshed artwork for ${apps.length} apps.`;
const items=[...(result.apps||[]),...(result.titles||[])];
const failed=items.filter(a=>a.error);
status.textContent=failed.length?`${failed.length} of ${items.length} failed: ${failed.map(a=>(a.package||a.id)+': '+a.error).join('; ')}`:
`Refreshed artwork for ${items.length} apps and titles.`;
if (typeof loadAndroid==='function') loadAndroid();
if (typeof loadTitles==='function') loadTitles();
}
} catch(e) {status.textContent=e.message;}
finally {button.disabled=false;}
};
request('/api/settings/artwork').then(show).catch(e=>{status.textContent=e.message;});
api('/api/settings/artwork').then(show).catch(e=>{status.textContent=e.message;});
})();
+49 -27
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:
@@ -256,8 +284,8 @@ def refresh_art(pkg=None, artwork=None):
for app in list_apps():
try:
results.append(refresh_art(app['package'], artwork))
except (FrameError, OSError, ValueError) as e:
results.append({'package': app['package'], 'error': str(e)})
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:
m = _meta_or_fail(pkg)
@@ -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', [])
@@ -362,6 +377,7 @@ def list_apps():
continue
if m:
m['running'] = f"lepton-steamlaunch-{m['instance']}" in running
m['art_missing'] = art_missing(m)
apps.append(m)
return sorted(apps, key=lambda m: m['label'].lower())
@@ -398,8 +414,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}')
@@ -518,6 +538,8 @@ def main():
r = (backup_data if cmd == 'backup-data' else restore_data)(*args)
elif cmd == 'list':
r = list_apps()
if any(a['art_missing'] for a in r):
print('Some apps have no Steam artwork: python3 ui/frame_android.py refresh-art --all', file=sys.stderr)
elif cmd in ('launch', 'stop', 'probe'):
r = globals()[cmd](args[0])
elif cmd == 'remove':
+47 -133
View File
@@ -1,13 +1,12 @@
"""Bounded artwork inputs for Steam library canvas rendering."""
import struct
import urllib.parse
import urllib.request
import time
import zlib
SLOTS = {'grid': (600, 900), 'wide': (920, 430), 'hero': (3840, 1240),
'logo': (1280, 480), 'icon': (256, 256)}
MAX_IMAGE = 12 * 1024 * 1024
MAX_PIXELS = 8_000_000
MAX_PIXELS = 4096 * 4096 # a 4K screenshot; Chromium decodes it on the Frame
PNG = b'\x89PNG\r\n\x1a\n'
@@ -15,107 +14,28 @@ def chunk(kind, data):
return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data))
def png(width, height, pixels):
stride = width * 4
raw = b''.join(b'\0' + pixels[y * stride:(y + 1) * stride] for y in range(height))
return PNG + chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 6, 0, 0, 0)) + \
chunk(b'IDAT', zlib.compress(raw, 6)) + chunk(b'IEND', b'')
def decode(data):
"""Non-interlaced PNG, including packed palette/grayscale APK icons."""
if not isinstance(data, bytes) or not data.startswith(PNG) or len(data) > MAX_IMAGE:
raise ValueError('expected a PNG image (at most 12 MiB)')
pos, packed, palette, alpha, header = 8, bytearray(), b'', b'', None
while pos + 12 <= len(data):
size = struct.unpack_from('>I', data, pos)[0]
kind, body = data[pos + 4:pos + 8], data[pos + 8:pos + 8 + size]
if pos + size + 12 > len(data):
raise ValueError('truncated PNG')
crc = struct.unpack_from('>I', data, pos + 8 + size)[0]
if zlib.crc32(kind + body) != crc:
raise ValueError('invalid PNG checksum')
if kind == b'IHDR':
if header is not None or size != 13:
raise ValueError('invalid PNG header')
header = struct.unpack('>IIBBBBB', body)
elif kind == b'PLTE':
palette = body
elif kind == b'tRNS':
alpha = body
elif kind == b'IDAT':
packed.extend(body)
elif kind == b'IEND':
break
pos += size + 12
else:
raise ValueError('incomplete PNG')
if header is None:
raise ValueError('missing PNG header')
w, h, depth, color, compression, filtering, interlace = header
channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}.get(color)
def png_size(data):
"""IHDR dimensions of a PNG of any bit depth or interlace; Steam's Chromium decodes the pixels."""
if len(data) < 33 or data[12:16] != b'IHDR' or struct.unpack_from('>I', data, 8)[0] != 13:
raise ValueError('invalid PNG header')
if zlib.crc32(data[12:29]) != struct.unpack_from('>I', data, 29)[0]:
raise ValueError('invalid PNG checksum')
w, h, depth, color = struct.unpack_from('>IIBB', data, 16)
if color not in (0, 2, 3, 4, 6) or depth not in (1, 2, 4, 8, 16):
raise ValueError('unsupported PNG encoding')
if not w or not h or w * h > MAX_PIXELS or w > 8192 or h > 8192:
raise ValueError('PNG dimensions exceed limits')
if not channels or compression or filtering or interlace or depth not in (1, 2, 4, 8) or (depth != 8 and color not in (0, 3)):
raise ValueError('unsupported PNG encoding')
stride, bpp = (w * channels * depth + 7) // 8, max(1, channels * depth // 8)
expected = h * (stride + 1)
decoder = zlib.decompressobj()
raw = decoder.decompress(bytes(packed), expected + 1)
if len(raw) != expected or not decoder.eof:
raise ValueError('invalid PNG pixels')
pixels, prev = bytearray(), bytearray(stride)
for y in range(h):
start = y * (stride + 1)
method, row = raw[start], bytearray(raw[start + 1:start + 1 + stride])
if method > 4:
raise ValueError('invalid PNG filter')
for x in range(stride):
a, b, c = row[x - bpp] if x >= bpp else 0, prev[x], prev[x - bpp] if x >= bpp else 0
if method == 1:
row[x] = (row[x] + a) & 255
elif method == 2:
row[x] = (row[x] + b) & 255
elif method == 3:
row[x] = (row[x] + (a + b) // 2) & 255
elif method == 4:
p = a + b - c
distances = (abs(p - a), abs(p - b), abs(p - c))
row[x] = (row[x] + (a, b, c)[distances.index(min(distances))]) & 255
for x in range(w):
if depth < 8:
value = (row[x * depth // 8] >> (8 - depth - x * depth % 8)) & ((1 << depth) - 1)
values = [value]
else:
values = row[x * channels:(x + 1) * channels]
if color == 3:
i = values[0]
if i * 3 + 3 > len(palette):
raise ValueError('invalid PNG palette')
rgba = palette[i * 3:i * 3 + 3] + bytes([alpha[i] if i < len(alpha) else 255])
elif color in (0, 4):
gray = values[0] * 255 // ((1 << depth) - 1)
opacity = values[1] if color == 4 else (0 if alpha == struct.pack('>H', values[0]) else 255)
rgba = bytes([gray, gray, gray, opacity])
else:
opacity = values[3] if color == 6 else (0 if alpha == struct.pack('>HHH', *values) else 255)
rgba = bytes(values[:3]) + bytes([opacity])
pixels.extend(rgba)
prev = row
return w, h, pixels
return w, h
def image_type(data):
if not isinstance(data, bytes) or len(data) > MAX_IMAGE:
raise ValueError('artwork must be image bytes or an HTTP(S) URL, at most 12 MiB')
if data.startswith(PNG):
try:
decode(data)
except (zlib.error, struct.error) as e:
raise ValueError('invalid PNG image') from e
png_size(data)
return 'png'
if data.startswith(b'\xff\xd8') and data.endswith(b'\xff\xd9'):
# Check JPEG SOF dimensions without depending on an image library.
if data.startswith(b'\xff\xd8'):
# Check JPEG SOF dimensions without depending on an image library; trailing padding is fine.
pos = 2
while pos + 4 <= len(data) and data[pos] == 255:
marker = data[pos + 1]
@@ -126,7 +46,7 @@ def image_type(data):
size = struct.unpack_from('>H', data, pos)[0]
if size < 2 or pos + size > len(data):
break
if marker in (0xc0, 0xc1, 0xc2) and size >= 8:
if 0xc0 <= marker <= 0xcf and marker not in (0xc4, 0xc8, 0xcc) and size >= 8:
h, w = struct.unpack_from('>HH', data, pos + 3)
if w and h and w * h <= MAX_PIXELS and max(w, h) <= 8192:
return 'jpg'
@@ -135,59 +55,53 @@ def image_type(data):
raise ValueError('artwork must be a supported PNG or JPEG')
def stamp(pixels, width, icon, x, y, size):
iw, ih, source = icon
dw, dh = max(1, size * iw // max(iw, ih)), max(1, size * ih // max(iw, ih))
x, y = x + (size - dw) // 2, y + (size - dh) // 2
for yy in range(dh):
for xx in range(dw):
src = ((yy * ih // dh) * iw + xx * iw // dw) * 4
dst = ((y + yy) * width + x + xx) * 4
a = source[src + 3]
for c in range(3):
pixels[dst + c] = (source[src + c] * a + pixels[dst + c] * (255 - a)) // 255
pixels[dst + 3] = a + pixels[dst + 3] * (255 - a) // 255
def fetch(value):
def fetch(value, deadline=None):
if isinstance(value, str):
if urllib.parse.urlsplit(value).scheme not in ('http', 'https'):
raise ValueError('artwork URLs must use HTTP(S)')
request = urllib.request.Request(value, headers={'User-Agent': 'FrameControl/1.0'})
with urllib.request.urlopen(request, timeout=20) as response:
if urllib.parse.urlsplit(response.geturl()).scheme not in ('http', 'https'):
raise ValueError('artwork redirect must use HTTP(S)')
value = response.read(MAX_IMAGE + 1)
from apk_sources import _images
# Public addresses only, at most three redirects, within the overall deadline.
value = _images.fetch(value, deadline=deadline, limit=MAX_IMAGE)[0]
return image_type(value), value
def prepare(label, icon_png=None, artwork=None):
"""Gather inputs; the Frame's Chromium canvas renders every final slot."""
def prepare(label, icon_png=None, artwork=None, budget=90):
"""Gather inputs; the Frame's Chromium canvas renders every final slot.
Every source is optional: any failure falls back to generated art, within budget seconds overall."""
import frame_steamgriddb
artwork = artwork or {}
allowed = set(SLOTS) | {'banner', 'feature_graphic', 'screenshots', 'screenshot'}
if not isinstance(artwork, dict) or set(artwork) - allowed:
raise ValueError('unknown artwork slot')
deadline = time.monotonic() + budget
supplied, warnings = {}, []
def get(value):
try:
return fetch(value, deadline)
except Exception: # an optional source never blocks the install; generated art covers it
return None
for slot, value in artwork.items():
values = value if slot == 'screenshots' and isinstance(value, (list, tuple)) else [value]
for candidate in values[:4]:
try:
supplied['screenshot' if slot == 'screenshots' else slot] = fetch(candidate)
image = get(candidate)
if image:
supplied['screenshot' if slot == 'screenshots' else slot] = image
break
except (ValueError, OSError):
warnings.append('Source ' + slot + ' unavailable; using fallback art')
else:
warnings.append('Source ' + slot + ' unavailable; using fallback art')
if 'icon' not in supplied and icon_png:
try:
supplied['icon'] = fetch(icon_png)
except (ValueError, OSError):
pass
provider, provider_warnings = frame_steamgriddb.lookup(label)
image = get(icon_png)
if image:
supplied['icon'] = image
try:
provider, provider_warnings = frame_steamgriddb.lookup(label, deadline)
except Exception:
provider, provider_warnings = {}, ['SteamGridDB unavailable; using source or generated art']
warnings.extend(provider_warnings)
for slot, value in provider.items():
try:
supplied[slot] = fetch(value)
except (ValueError, OSError):
image = get(value)
if image:
supplied[slot] = image
else:
warnings.append('SteamGridDB ' + slot + ' download failed; using fallback art')
return supplied, warnings
+21 -12
View File
@@ -3,6 +3,8 @@ import json
import os
import re
import tempfile
import time
import unicodedata
import urllib.parse
import urllib.request
@@ -48,36 +50,42 @@ def save_settings(body):
return settings()
def _get(path, key):
def _get(path, key, deadline=None):
timeout = 12 if deadline is None else min(12, deadline - time.monotonic())
if timeout <= 0:
raise ValueError('SteamGridDB lookup took too long')
request = urllib.request.Request(API + path, headers={'Authorization': 'Bearer ' + key,
'User-Agent': 'FrameControl/1.0'})
# 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=12) as response:
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)
if 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')
return result['data']
def _name(value):
return re.sub(r'[^a-z0-9]', '', str(value).casefold())
# Letters and digits of any script, so a CJK title never normalises to ''.
return ''.join(c for c in unicodedata.normalize('NFKC', str(value or '')).casefold() if c.isalnum())
def lookup(name):
def lookup(name, deadline=None):
"""Best-voted art per slot for an exact title match; unrelated games are never guessed."""
key = api_key()
if not key:
return {}, []
try:
matches = _get('/search/autocomplete/' + urllib.parse.quote(name, safe=''), key)
names = {_name(name), _name(re.sub(r'\s+VR$', '', name, flags=re.I))}
game = next((g for g in matches if _name(g.get('name')) in names), None)
names = {_name(name), _name(re.sub(r'\s+VR$', '', name, flags=re.I))} - {''}
if not names:
return {}, []
matches = _get('/search/autocomplete/' + urllib.parse.quote(name, safe=''), key, deadline)
game = next((g for g in matches if isinstance(g, dict) and _name(g.get('name')) in names), None)
if not game:
return {}, []
gid = int(game['id'])
@@ -88,16 +96,17 @@ def lookup(name):
query = {'types': 'static', 'nsfw': 'false', 'humor': 'false', 'mimes': 'image/png,image/jpeg'}
if dimensions:
query['dimensions'] = dimensions
records = _get('/' + kind + '/game/' + str(gid) + '?' + urllib.parse.urlencode(query), key)
records = [r for r in records if r.get('url', '').startswith('https://') and not r.get('nsfw')]
records = _get('/' + kind + '/game/' + str(gid) + '?' + urllib.parse.urlencode(query), key, deadline)
records = [r for r in records if isinstance(r, dict) and str(r.get('url', '')).startswith('https://')
and not r.get('nsfw')]
if dimensions:
w, h = map(int, dimensions.split('x'))
records = [r for r in records if (r.get('width'), r.get('height')) == (w, h)]
records.sort(key=lambda r: (int(r.get('score') or 0), int(r.get('upvotes') or 0)), reverse=True)
if records:
result[slot] = records[0]['url']
except (OSError, ValueError, KeyError, TypeError):
except Exception: # HTTPException, odd JSON: this slot falls back
warnings.append('SteamGridDB ' + slot + ' unavailable; using source or generated art')
return result, warnings
except (OSError, ValueError, KeyError, TypeError):
except Exception:
return {}, ['SteamGridDB unavailable; using source or generated art']
+97 -17
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 20260925.6191901),
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')
@@ -783,7 +852,7 @@ 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)})
'frame_control': bool(meta), 'art_missing': bool(meta) and frame_android.art_missing(meta)})
return titles
@@ -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)
@@ -860,8 +933,15 @@ def main():
progress=lambda text, _: print(text + '…', file=sys.stderr))
elif cmd == 'list':
r = list_titles()
if any(t['art_missing'] for t in r):
print('Some titles have no Steam artwork: python3 ui/frame_titles.py refresh-art --all', file=sys.stderr)
elif cmd in ('launch', 'remove') and args:
r = globals()[cmd](args[0])
elif cmd == 'refresh-art' and args:
r = refresh_art(None if args[0] == '--all' else args[0])
if isinstance(r, list) and any('error' in t for t in r):
print(json.dumps(r, indent=1))
raise SystemExit(1)
else:
sys.exit(__doc__)
except FrameError as e:
+8 -1
View File
@@ -795,7 +795,7 @@
<a href="https://www.steamgriddb.com/profile/preferences/api" target="_blank" rel="noreferrer">Get a free key</a>
</div>
<div id="steamGridStatus" class="hint" role="status"></div>
<button id="refreshAndroidArt" class="small" style="margin-top:12px">Refresh installed Android artwork</button>
<button id="refreshAndroidArt" class="small" style="margin-top:12px">Refresh artwork for installed apps and titles</button>
</section>
<section class="panel" id="power">
<div class="shelf-head"><h2>Remote &amp; power</h2></div>
@@ -1801,6 +1801,7 @@ async function loadTitles() {
<div class="item">
<div class="grow"><div class="t">${esc(t.id)}</div>
<div class="s" title="${esc(t.target)}">${esc(t.runtime_label)}${t.target ? " · " + esc(t.target) : ""}</div></div>
${t.art_missing ? `<button class="small" data-title="refresh-art" data-id="${esc(t.id)}" title="Its Steam library tile has no artwork yet">Add artwork</button>` : ""}
<button class="play small" data-title="launch" data-id="${esc(t.id)}" title="Launch in the headset">▶</button>
<button class="small danger" data-title="remove" data-id="${esc(t.id)}">Remove</button>
</div>`).join("") : `<div class="sub">None yet.</div>`;
@@ -1812,6 +1813,9 @@ $("titleList").onclick = async e => {
if (!confirm(`Remove ${id} and its files from the Frame?`)) return;
await act(`Remove ${id}`, () => api("/api/titles", { action: "remove", id }), b);
loadTitles();
} else if (b.dataset.title === "refresh-art") {
await runJob(`Add Steam artwork for ${id}`, "art-" + id, () => api("/api/titles", { action: "refresh-art", id }));
loadTitles();
} else await act(`Launch ${id}`, () => api("/api/titles", { action: "launch", id }), b);
};
loadTitles();
@@ -2133,6 +2137,7 @@ async function loadAndroid() {
<div class="row and-btns">
${a.running ? `<button class="small" data-and="stop" data-pkg="${esc(a.package)}">Stop</button>`
: `<button class="play small" data-and="launch" data-pkg="${esc(a.package)}">▶</button>`}
${a.art_missing ? `<button class="small" data-and="refresh-art" data-pkg="${esc(a.package)}" data-name="${esc(a.label)}" title="Its Steam library tile has no artwork yet">Add artwork</button>` : ""}
<button class="small" data-and="probe" data-pkg="${esc(a.package)}" title="Launch it and record whether it stays up">Test</button>
<button class="small" data-and="rate" data-pkg="${esc(a.package)}" data-ver="${esc(a.version)}" data-name="${esc(a.label)}" data-src="${esc(a.source || "")}" title="Report how it worked">Report</button>
<button class="small danger" data-and="remove" data-pkg="${esc(a.package)}" data-name="${esc(a.label)}">Remove</button>
@@ -2215,6 +2220,8 @@ document.body.addEventListener("click", async e => {
} else if (action === "remove") {
if (!confirm(`Remove ${b.dataset.name} and its data from the Frame?`)) return;
await act(`Remove ${b.dataset.name}`, () => api("/api/android", { action, package: pkg }), b);
} else if (action === "refresh-art") {
await runJob(`Add Steam artwork for ${b.dataset.name}`, "art-" + pkg, () => api("/api/android", { action, package: pkg }));
} else if (action === "rate") {
openReport({ package: pkg, version: b.dataset.ver, label: b.dataset.name, source: b.dataset.src });
return;
+46 -4
View File
@@ -148,6 +148,39 @@ _jobs_lock = threading.Lock()
_jobs = {} # id -> {"label", "done", "error", "message", "result", "time"}
_backfill = {"running": False, "last": 0.0}
_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.
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")]
with _backfill_lock:
if not (pkgs or gids) or _backfill["running"] or time.time() - _backfill["last"] < 300:
return False
_backfill.update(running=True, last=time.time())
def run():
try:
frame_android.shortcut_tool("list") # Steam isn't up: try again on a later listing
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)
except Exception as e:
print(f"artwork backfill for {key}: {e}", file=sys.stderr)
except Exception:
pass
finally:
with _backfill_lock:
_backfill["running"] = False
threading.Thread(target=run, daemon=True).start()
return True
def start_job(label, work, progress=False):
"""Run work() in the background. It returns a dict with a "message"."""
now = time.time()
@@ -599,8 +632,11 @@ def android(body):
if action == "refresh-art":
if not pkg and not body.get("all"):
raise Failure('choose a package or all apps', 400)
if not body.get('all'):
return start_job('Refresh Steam artwork', lambda: {'apps': [frame_android.refresh_art(pkg)]})
# Everything Frame Control sideloaded: Android apps and devkit titles.
return start_job('Refresh Steam artwork', lambda: {
'apps': frame_android.refresh_art(None if body.get('all') else pkg)})
'apps': frame_android.refresh_art(), 'titles': frame_titles.refresh_art()})
if action in ("launch", "stop"):
m = getattr(frame_android, action)(pkg)
return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"}
@@ -717,12 +753,14 @@ def titles(body):
threading.Thread(target=_run_title_install, daemon=True,
args=(token, entry, opt("name"), opt("exe"), opt("runtime"))).start()
return {"message": f"Installing {entry['plan']['source']}", "job": token}
if action not in ("launch", "remove"):
if action not in ("launch", "remove", "refresh-art"):
raise Failure("unknown action", 400)
gid = str(body.get("id", ""))
if not frame_titles.ID_RE.match(gid):
raise Failure("bad title id", 400)
ensure_master()
if action == "refresh-art":
return start_job(f"Steam artwork for {gid}", lambda: {"titles": [frame_titles.refresh_art(gid)]})
try:
m = getattr(frame_titles, action)(gid)
except frame_android.FrameError as e:
@@ -1446,10 +1484,14 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(apk_versions(url.query))
elif path == "/api/android":
ensure_master()
self.send_json({"apps": frame_android.list_apps()})
apps = frame_android.list_apps()
backfill_art(apps=apps)
self.send_json({"apps": apps})
elif path == "/api/titles":
ensure_master()
self.send_json({"titles": frame_titles.list_titles()})
titles_list = frame_titles.list_titles()
backfill_art(titles=titles_list)
self.send_json({"titles": titles_list})
elif path == "/api/titles/job":
self.send_json(title_job(url.query))
elif path == "/api/job":