mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 02:00:19 +02:00
Merge vr-library (Steam library art, Play/Stop, refresh-art) into the store
Resolve CLI usage and POST table conflicts. Store installs now hand the source's own image URLs (icon, banner, screenshots) to frame_android.install as Steam artwork; before, they passed UI proxy paths (or nothing), so every store install fell back to generated art. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
8315c7c3aa
26 files changed
+1813
-34
No files matched your search
@@ -311,7 +311,11 @@ def install(source_id, entry_id, version_code=None, progress=None):
|
||||
kwargs = {'name': entry.get('name'), 'icon_png': downloaded.get('icon_png') or entry.get('icon_png'),
|
||||
'source': source['name']}
|
||||
if 'artwork' in inspect.signature(frame_android.install).parameters:
|
||||
kwargs['artwork'] = downloaded.get('artwork') or entry.get('artwork')
|
||||
# The source's own image URLs (not the UI's /source-image/ proxy paths) become Steam library art.
|
||||
images = entry.get('images') if isinstance(entry.get('images'), dict) else {}
|
||||
art = {'icon': images.get('icon') or entry.get('icon'), 'banner': images.get('banner'),
|
||||
'screenshots': [u for u in images.get('screenshots') or [] if u][:4]}
|
||||
kwargs['artwork'] = downloaded.get('artwork') or {k: v for k, v in art.items() if v} or None
|
||||
if progress:
|
||||
progress('Installing', None)
|
||||
from apk_sources import _web
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
(() => {
|
||||
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='';}
|
||||
catch(e) {status.textContent=e.message;}
|
||||
}
|
||||
get('saveSteamGridKey').onclick=()=>save(get('steamGridKey').value.trim());
|
||||
get('clearSteamGridKey').onclick=()=>save('');
|
||||
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}));
|
||||
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.`;
|
||||
}
|
||||
} catch(e) {status.textContent=e.message;}
|
||||
finally {button.disabled=false;}
|
||||
};
|
||||
request('/api/settings/artwork').then(show).catch(e=>{status.textContent=e.message;});
|
||||
})();
|
||||
+142
-18
@@ -9,11 +9,12 @@ Lepton Development, which wipes its apps on exit. See docs/apks.md.
|
||||
Python stdlib only. CLI: python3 ui/frame_android.py
|
||||
install APK [--vr|--flat] [--no-xr-compat] | info APK | versions APK-or-PKG
|
||||
install-obb PKG OBB [OBB ...] | backup-data PKG ARCHIVE | restore-data PKG ARCHIVE
|
||||
patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG
|
||||
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 frame_apk
|
||||
import frame_artwork
|
||||
import frame_host
|
||||
import tempfile
|
||||
import zipfile
|
||||
@@ -57,8 +58,12 @@ def ssh(cmd, input=None, timeout=120):
|
||||
|
||||
def shortcut_tool(*args, timeout=60):
|
||||
with open(SHORTCUTS) as f:
|
||||
return ssh('python3 - ' + ' '.join(shlex.quote(a) for a in args), input=f.read(),
|
||||
timeout=timeout).strip()
|
||||
script = f.read()
|
||||
if args and args[0] == 'render':
|
||||
with open(os.path.join(ROOT, 'frame/android/library_artwork.js')) as f:
|
||||
script = 'ART_RENDERER = ' + repr(f.read()) + '\n' + script
|
||||
return ssh('python3 - ' + ' '.join(shlex.quote(a) for a in args), input=script,
|
||||
timeout=timeout).strip()
|
||||
|
||||
|
||||
def instance_id(pkg):
|
||||
@@ -136,7 +141,7 @@ def _write_meta(d, meta):
|
||||
ssh(f'cat > {d}/meta.json.tmp && mv {d}/meta.json.tmp {d}/meta.json', input=json.dumps(meta, indent=1))
|
||||
|
||||
|
||||
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None):
|
||||
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None, artwork=None):
|
||||
info = apk_info(apk_path)
|
||||
if icon_png:
|
||||
info['icon_png'] = icon_png
|
||||
@@ -155,43 +160,57 @@ def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr
|
||||
patched = os.path.join(tmp, 'app.apk')
|
||||
info['patched'] = patch(apk_path, patched, add)['patched']
|
||||
info['launchable'] = True
|
||||
return _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path))
|
||||
return _install(apk_path, info, pkg, flatscreen, name, source)
|
||||
return _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path), artwork)
|
||||
return _install(apk_path, info, pkg, flatscreen, name, source, artwork)
|
||||
|
||||
|
||||
def _install(apk_path, info, pkg, flatscreen, name, source):
|
||||
def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
|
||||
try:
|
||||
images, art_warnings = frame_artwork.prepare(name or info['label'], info.get('icon_png'), artwork)
|
||||
except (ValueError, OSError) as e:
|
||||
raise FrameError(f'could not prepare artwork: {e}') from e
|
||||
iid = instance_id(pkg)
|
||||
d = f'{APPS_DIR}/{pkg}'
|
||||
existing = read_meta(pkg)
|
||||
ok = False
|
||||
ok, created = False, None
|
||||
try:
|
||||
ssh(f'mkdir -p {d}')
|
||||
_copy(apk_path, f'{d}/app.apk.part')
|
||||
_copy(LAUNCHER, f'{d}/launch.sh', executable=True, timeout=120)
|
||||
icon = ''
|
||||
if info['icon_png']:
|
||||
ssh(f'cat > {d}/icon.png', input=info['icon_png'])
|
||||
icon = f'$HOME/{d}/icon.png'
|
||||
marker = f'touch {d}/lepton-show-flatscreen' if flatscreen else f'rm -f {d}/lepton-show-flatscreen'
|
||||
ssh(f'mv {d}/app.apk.part {d}/app.apk && echo {iid} > {d}/instance.id && {marker}')
|
||||
home = ssh('echo $HOME').strip()
|
||||
shortcut = _int((existing or {}).get('shortcut'))
|
||||
if not shortcut or shortcut not in _shortcut_ids():
|
||||
reply = shortcut_tool('add', name or info['label'], f'{home}/{d}/launch.sh', f'{home}/{d}',
|
||||
icon.replace('$HOME', home))
|
||||
'')
|
||||
shortcut = _int(reply.strip().splitlines()[-1] if reply.strip() else None)
|
||||
if not shortcut:
|
||||
raise FrameError(f'Steam did not return a shortcut id (got {reply[:80]!r})')
|
||||
created = shortcut
|
||||
presentation = apply_library(shortcut, name or info['label'], d, images,
|
||||
vr=not flatscreen, home=home,
|
||||
exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}',
|
||||
details={'package': pkg, 'version': info['version'],
|
||||
'source': source or os.path.basename(apk_path)})
|
||||
presentation['warnings'] = art_warnings + presentation.get('warnings', [])
|
||||
meta = {'package': pkg, 'label': name or info['label'], 'version': info['version'],
|
||||
'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut),
|
||||
'vr': info.get('vr', False), 'vr_issues': info.get('vr_issues', []),
|
||||
'launchable': info.get('launchable', False), 'patched': info.get('patched', []),
|
||||
'flatscreen': flatscreen, 'installed': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
'source': source or os.path.basename(apk_path)}
|
||||
'source': source or os.path.basename(apk_path),
|
||||
'library_warnings': presentation.get('warnings', []),
|
||||
'artwork': presentation.get('artwork', {}), 'library_version': 2}
|
||||
_write_meta(d, meta)
|
||||
ok = True
|
||||
return meta
|
||||
finally:
|
||||
if not ok and created:
|
||||
try:
|
||||
shortcut_tool('remove', str(created))
|
||||
except FrameError:
|
||||
pass
|
||||
if not ok and not existing:
|
||||
# A first install that failed part-way: don't leave an orphan folder behind.
|
||||
try:
|
||||
@@ -200,6 +219,101 @@ def _install(apk_path, info, pkg, flatscreen, name, source):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
def apply_library(shortcut, label, directory, images, vr=False, home=None, exe='', start_dir='', details=None,
|
||||
category='Android'):
|
||||
"""Mandatory for every sideload: render all five slots before reporting success."""
|
||||
home = home or ssh('echo $HOME').strip()
|
||||
d = directory
|
||||
ssh(f'mkdir -p {shlex.quote(d)}/artwork')
|
||||
paths = {}
|
||||
for slot, (ext, data) in images.items():
|
||||
path = f'{d}/artwork/source-{slot}.{ext}'
|
||||
ssh(f'cat > {shlex.quote(path)}', input=data)
|
||||
paths[slot] = f'{home}/{path}' if not path.startswith('/') else path
|
||||
manifest = {'label': label, 'images': paths}
|
||||
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))
|
||||
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),
|
||||
json.dumps({'category': category, 'details': details or {}}), timeout=120))
|
||||
result['warnings'] = rendered.get('warnings', []) + result.get('warnings', [])
|
||||
result['artwork'] = art
|
||||
if category == 'Android':
|
||||
ssh(f'cat > {shlex.quote(d)}/shortcut.id', input=str(int(shortcut)))
|
||||
return result
|
||||
|
||||
|
||||
def refresh_art(pkg=None, artwork=None):
|
||||
"""Refresh existing APK library entries without reinstalling or stopping them."""
|
||||
if pkg is None:
|
||||
results = []
|
||||
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)})
|
||||
return results
|
||||
with _install_lock:
|
||||
m = _meta_or_fail(pkg)
|
||||
d = f'{APPS_DIR}/{pkg}'
|
||||
# Parse the APK on the Frame, transferring only its icon/label, not the APK.
|
||||
modules = {}
|
||||
for module in ('frame_apk', 'frame_apk_vr'):
|
||||
with open(os.path.join(ROOT, 'ui', module + '.py')) as f:
|
||||
modules[module] = f.read()
|
||||
script = 'import sys, types, json, base64\n'
|
||||
for module, source in modules.items():
|
||||
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))
|
||||
"""
|
||||
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)
|
||||
home = ssh('echo $HOME').strip()
|
||||
created = False
|
||||
if not m['shortcut'] or m['shortcut'] not in _shortcut_ids():
|
||||
m['shortcut'] = _int(shortcut_tool('add', m['label'], f'{home}/{d}/launch.sh', f'{home}/{d}'))
|
||||
if not m['shortcut']:
|
||||
raise FrameError('Steam did not create the missing shortcut')
|
||||
m['game_id'] = game_id(m['shortcut'])
|
||||
created = True
|
||||
try:
|
||||
result = apply_library(m['shortcut'], m['label'], d, images, vr=not m.get('flatscreen', True),
|
||||
home=home, exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}', details=m)
|
||||
except Exception:
|
||||
if created:
|
||||
shortcut_tool('remove', str(m['shortcut']))
|
||||
raise
|
||||
m.update(artwork=result.get('artwork', {}), library_version=2)
|
||||
m['library_warnings'] = warnings + result.get('warnings', [])
|
||||
m['artwork_refreshed'] = time.strftime('%Y-%m-%dT%H:%M:%S')
|
||||
_write_meta(d, m)
|
||||
return m
|
||||
|
||||
def _int(v):
|
||||
try:
|
||||
n = int(v)
|
||||
@@ -271,6 +385,11 @@ def launch(pkg):
|
||||
|
||||
def stop(pkg):
|
||||
m = _meta_or_fail(pkg)
|
||||
if m['shortcut']:
|
||||
try:
|
||||
shortcut_tool('stop', str(int(m['shortcut'])))
|
||||
except FrameError:
|
||||
pass # Steam unavailable: the container stop also ends its waiting launcher.
|
||||
ssh(f"podman stop -t 5 lepton-steamlaunch-{int(m['instance'])} >/dev/null 2>&1 || true", timeout=60)
|
||||
return m
|
||||
|
||||
@@ -279,10 +398,8 @@ def remove(pkg, keep_data=False):
|
||||
m = _meta_or_fail(pkg)
|
||||
stop(pkg)
|
||||
if m['shortcut']:
|
||||
try:
|
||||
shortcut_tool('remove', str(int(m['shortcut'])))
|
||||
except FrameError:
|
||||
pass # already gone from Steam
|
||||
result = json.loads(shortcut_tool('remove', str(int(m['shortcut']))) or '{}')
|
||||
m['library_warnings'] = result.get('warnings', [])
|
||||
iid = int(m['instance'])
|
||||
extra = '' if keep_data else f' {COMPAT}/{iid} {SHADERS}/{iid}'
|
||||
ssh(f'rm -rf {APPS_DIR}/{pkg}{extra}')
|
||||
@@ -369,6 +486,13 @@ def main():
|
||||
elif cmd == 'install':
|
||||
r = install(args[0], flatscreen=False if '--vr' in args else True if '--flat' in args else None,
|
||||
xr_compat=False if '--no-xr-compat' in args else None)
|
||||
elif cmd == 'refresh-art':
|
||||
if len(args) != 1:
|
||||
raise FrameError('usage: refresh-art PACKAGE or refresh-art --all')
|
||||
r = refresh_art(None if args[0] == '--all' else args[0])
|
||||
if isinstance(r, list) and any('error' in item for item in r):
|
||||
print(json.dumps(r, indent=1))
|
||||
raise SystemExit(1)
|
||||
elif cmd == 'patch':
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description='Patch and v2-sign an APK locally')
|
||||
|
||||
+4
-1
@@ -255,7 +255,10 @@ def _icon_png(z, names, icons):
|
||||
# Adaptive icons are XML; fall back to the largest launcher PNG.
|
||||
pngs = sorted((n for n in names if n.endswith('.png') and 'ic_launcher' in n and 'foreground' not in n),
|
||||
key=lambda n: z.getinfo(n).file_size)
|
||||
return _read(z, pngs[-1], MAX_ICON) if pngs else None
|
||||
if pngs:
|
||||
return _read(z, pngs[-1], MAX_ICON)
|
||||
# Godot exports can keep only an adaptive launcher plus the project icon.
|
||||
return _read(z, 'assets/icon.png', MAX_ICON) if 'assets/icon.png' in names else None
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Bounded artwork inputs for Steam library canvas rendering."""
|
||||
import struct
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
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
|
||||
PNG = b'\x89PNG\r\n\x1a\n'
|
||||
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
return 'png'
|
||||
if data.startswith(b'\xff\xd8') and data.endswith(b'\xff\xd9'):
|
||||
# Check JPEG SOF dimensions without depending on an image library.
|
||||
pos = 2
|
||||
while pos + 4 <= len(data) and data[pos] == 255:
|
||||
marker = data[pos + 1]
|
||||
pos += 2
|
||||
if marker == 255:
|
||||
pos -= 1
|
||||
continue
|
||||
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:
|
||||
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'
|
||||
break
|
||||
pos += size
|
||||
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):
|
||||
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)
|
||||
return image_type(value), value
|
||||
|
||||
|
||||
def prepare(label, icon_png=None, artwork=None):
|
||||
"""Gather inputs; the Frame's Chromium canvas renders every final slot."""
|
||||
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')
|
||||
supplied, warnings = {}, []
|
||||
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)
|
||||
break
|
||||
except (ValueError, OSError):
|
||||
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)
|
||||
warnings.extend(provider_warnings)
|
||||
for slot, value in provider.items():
|
||||
try:
|
||||
supplied[slot] = fetch(value)
|
||||
except (ValueError, OSError):
|
||||
warnings.append('SteamGridDB ' + slot + ' download failed; using fallback art')
|
||||
return supplied, warnings
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Optional SteamGridDB artwork. Credentials stay on the host, never in app metadata."""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
import frame_host
|
||||
|
||||
API = 'https://www.steamgriddb.com/api/v2'
|
||||
MAX_JSON = 2 * 1024 * 1024
|
||||
|
||||
|
||||
def settings_path():
|
||||
return frame_host.data_dir('artwork-settings.json')
|
||||
|
||||
|
||||
def api_key():
|
||||
env = os.environ.get('STEAMGRIDDB_API_KEY') or os.environ.get('FRAME_STEAMGRIDDB_API_KEY')
|
||||
if env:
|
||||
return env.strip()
|
||||
try:
|
||||
return str(json.loads(settings_path().read_text()).get('steamgriddb_api_key') or '')
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return ''
|
||||
|
||||
|
||||
def settings():
|
||||
return {'steamgriddb_configured': bool(api_key()),
|
||||
'environment': bool(os.environ.get('STEAMGRIDDB_API_KEY') or os.environ.get('FRAME_STEAMGRIDDB_API_KEY'))}
|
||||
|
||||
|
||||
def save_settings(body):
|
||||
key = body.get('steamgriddb_api_key')
|
||||
if not isinstance(key, str) or len(key) > 200 or (key and not re.fullmatch(r'[A-Za-z0-9_-]+', key)):
|
||||
raise ValueError('enter a valid SteamGridDB API key, or an empty value to remove it')
|
||||
path = settings_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temp = tempfile.mkstemp(prefix='.artwork-', dir=str(path.parent))
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
json.dump({'steamgriddb_api_key': key}, f)
|
||||
os.replace(temp, path)
|
||||
finally:
|
||||
if os.path.exists(temp):
|
||||
os.remove(temp)
|
||||
return settings()
|
||||
|
||||
|
||||
def _get(path, key):
|
||||
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:
|
||||
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):
|
||||
raise ValueError('SteamGridDB lookup failed')
|
||||
return result['data']
|
||||
|
||||
|
||||
def _name(value):
|
||||
return re.sub(r'[^a-z0-9]', '', str(value).casefold())
|
||||
|
||||
|
||||
def lookup(name):
|
||||
"""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)
|
||||
if not game:
|
||||
return {}, []
|
||||
gid = int(game['id'])
|
||||
result, warnings = {}, []
|
||||
for slot, kind, dimensions in [('grid', 'grids', '600x900'), ('wide', 'grids', '920x430'),
|
||||
('hero', 'heroes', ''), ('logo', 'logos', ''), ('icon', 'icons', '')]:
|
||||
try:
|
||||
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')]
|
||||
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):
|
||||
warnings.append('SteamGridDB ' + slot + ' unavailable; using source or generated art')
|
||||
return result, warnings
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
return {}, ['SteamGridDB unavailable; using source or generated art']
|
||||
+50
-5
@@ -632,7 +632,7 @@ def _rsync():
|
||||
_install_lock = threading.Lock()
|
||||
|
||||
|
||||
def install(path, name=None, exe=None, runtime=None, progress=None):
|
||||
def install(path, name=None, exe=None, runtime=None, progress=None, artwork=None):
|
||||
"""Sideload a .zip, folder or executable as a Devkit Game; returns the title dict.
|
||||
|
||||
name: the Steam name (sanitised to the title id), default from the file name.
|
||||
@@ -643,12 +643,12 @@ def install(path, name=None, exe=None, runtime=None, progress=None):
|
||||
"""
|
||||
plan = inspect(path, name)
|
||||
try:
|
||||
return install_plan(plan, name=name, exe=exe, runtime=runtime, progress=progress)
|
||||
return install_plan(plan, name=name, exe=exe, runtime=runtime, progress=progress, artwork=artwork)
|
||||
finally:
|
||||
discard(plan)
|
||||
|
||||
|
||||
def install_plan(plan, name=None, exe=None, runtime=None, progress=None):
|
||||
def install_plan(plan, name=None, exe=None, runtime=None, progress=None, artwork=None):
|
||||
"""Install an inspect() plan, optionally with another name, target or runtime."""
|
||||
if name:
|
||||
plan['name'], plan['id'] = name, title_id(name)
|
||||
@@ -656,13 +656,21 @@ def install_plan(plan, name=None, exe=None, runtime=None, progress=None):
|
||||
_choose(plan, exe or plan['target'], runtime)
|
||||
step = progress or (lambda *a: None)
|
||||
with _install_lock:
|
||||
return _install(plan, step)
|
||||
return _install(plan, step, artwork)
|
||||
|
||||
|
||||
def _install(plan, step):
|
||||
def _install(plan, step, artwork=None):
|
||||
gid = plan['id']
|
||||
if not NEW_ID_RE.match(gid) or gid.lower() in RESERVED_IDS:
|
||||
raise FrameError(f'bad title id {gid!r}')
|
||||
icon = None
|
||||
for filename in ('icon.png', 'logo.png'):
|
||||
path = os.path.join(plan['root'], filename)
|
||||
if os.path.isfile(path):
|
||||
with open(path, 'rb') as f:
|
||||
icon = f.read(frame_android.frame_artwork.MAX_IMAGE + 1)
|
||||
break
|
||||
images, warnings = frame_android.frame_artwork.prepare(plan['name'], icon, artwork)
|
||||
step("Syncing Valve's devkit tools to the Frame", 0.02)
|
||||
ensure_utils()
|
||||
existed = ssh(f'test -d {GAMES}/{gid} && echo yes || true', timeout=30).strip() == 'yes'
|
||||
@@ -672,6 +680,7 @@ def _install(plan, step):
|
||||
if not DIR_RE.match(directory) or not directory.endswith(f'/{GAMES}/{gid}'):
|
||||
raise FrameError(f'steamos-prepare-upload returned an unexpected folder {directory!r}')
|
||||
registered = False
|
||||
library_ready, shortcut, steam_registered = False, None, False
|
||||
try:
|
||||
step(f"Copying {plan['size'] / 1e6:.0f} MB to the Frame", 0.1)
|
||||
if _rsync():
|
||||
@@ -694,10 +703,26 @@ def _install(plan, step):
|
||||
err = str(reply['error']).strip().rstrip('.')
|
||||
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'])
|
||||
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', []))
|
||||
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
|
||||
library_ready = True
|
||||
step('Done', 1.0)
|
||||
meta.update(runtime_label=RUNTIMES[plan['runtime']]['label'], steam=str(reply.get('success', '')).strip())
|
||||
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)
|
||||
registered = False
|
||||
if not registered and not existed:
|
||||
# A first install that failed part-way: don't leave an orphan folder behind.
|
||||
try:
|
||||
@@ -706,6 +731,23 @@ def _install(plan, step):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
def _library_shortcut(gid, name):
|
||||
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')]
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
if not matches:
|
||||
matches = [s for s in shortcuts if s.get('name') in (gid, name)]
|
||||
if len(matches) > 1:
|
||||
raise FrameError('ambiguous Steam shortcut for ' + gid)
|
||||
return int(matches[0]['appid']) if matches else None
|
||||
|
||||
|
||||
LIST_SCRIPT = r'''
|
||||
import json, os
|
||||
root = os.path.expanduser('~/devkit-game')
|
||||
@@ -772,6 +814,9 @@ def remove(gid):
|
||||
try:
|
||||
gid = _check_id(gid)
|
||||
ensure_utils()
|
||||
shortcut = _library_shortcut(gid, gid)
|
||||
if shortcut:
|
||||
frame_android.shortcut_tool('remove', str(shortcut))
|
||||
# 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)
|
||||
|
||||
@@ -784,6 +784,19 @@
|
||||
</section>
|
||||
|
||||
|
||||
<section class="panel" id="artworkSettings">
|
||||
<div class="shelf-head"><h2>Library artwork settings</h2></div>
|
||||
<p class="sub">SteamGridDB supplies community artwork for sideloaded games. Without a key, Frame Control uses source images and creates the remaining art.</p>
|
||||
<label for="steamGridKey">SteamGridDB API key</label>
|
||||
<input type="password" id="steamGridKey" autocomplete="off" spellcheck="false" placeholder="Optional API key">
|
||||
<div class="row" style="margin-top:8px">
|
||||
<button id="saveSteamGridKey" class="action small">Save key</button>
|
||||
<button id="clearSteamGridKey" class="small">Remove saved key</button>
|
||||
<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>
|
||||
</section>
|
||||
<section class="panel" id="power">
|
||||
<div class="shelf-head"><h2>Remote & power</h2></div>
|
||||
<div class="actions">
|
||||
@@ -2625,5 +2638,6 @@ setView("headset");
|
||||
refresh().then(loadShots); // after status, so app names resolve
|
||||
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
|
||||
</script>
|
||||
<script src="/artwork-settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+13
-1
@@ -40,6 +40,7 @@ import frame_android # noqa: E402
|
||||
from apk_sources import search as apk_search, SourceError # noqa: E402
|
||||
import frame_apk_versions # noqa: E402
|
||||
import frame_catalog # noqa: E402
|
||||
import frame_steamgriddb
|
||||
import frame_host # noqa: E402
|
||||
import frame_store # noqa: E402
|
||||
import frame_titles # noqa: E402
|
||||
@@ -595,6 +596,11 @@ def android(body):
|
||||
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.",
|
||||
"app": m}
|
||||
return start_job(f"Install {pkg}", work)
|
||||
if action == "refresh-art":
|
||||
if not pkg and not body.get("all"):
|
||||
raise Failure('choose a package or all apps', 400)
|
||||
return start_job('Refresh Steam artwork', lambda: {
|
||||
'apps': frame_android.refresh_art(None if body.get('all') else pkg)})
|
||||
if action in ("launch", "stop"):
|
||||
m = getattr(frame_android, action)(pkg)
|
||||
return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"}
|
||||
@@ -1305,7 +1311,9 @@ def source_manage(body):
|
||||
raise Failure(str(e), 400)
|
||||
|
||||
|
||||
POST = {"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
POST = {
|
||||
"/api/settings/artwork": frame_steamgriddb.save_settings,
|
||||
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
|
||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||
"/api/webinstall/cancel": webinstall_cancel}
|
||||
@@ -1409,6 +1417,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
try:
|
||||
if path in ("/", "/index.html"):
|
||||
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
|
||||
elif path == "/api/settings/artwork":
|
||||
self.send_json(frame_steamgriddb.settings())
|
||||
elif path == "/artwork-settings.js":
|
||||
self.send_bytes((HERE / 'artwork-settings.js').read_bytes(), 'text/javascript; charset=utf-8')
|
||||
elif path == "/api/host":
|
||||
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
|
||||
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
||||
|
||||
Reference in new issue
Block a user