mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 05:02:50 +02:00
Render complete Steam artwork for every sideload and fix VR shortcut identity
Add optional SteamGridDB settings, source-first fallbacks rendered with Steam canvas, backfill commands and shared APK/native library details. Verify live artwork and Open Saber Steam Play/Stop; document SuperTux's clipboard crash. Co-Authored-By: GPT-6 Astra <noreply@openai.com>
This commit is contained in:
1 parent
c06b3285f2
commit
f695f398de
18 files changed
+1097
-289
No files matched your search
@@ -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;});
|
||||
})();
|
||||
+119
-17
@@ -8,7 +8,7 @@ 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
|
||||
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
|
||||
|
||||
@@ -57,8 +57,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):
|
||||
@@ -161,7 +165,7 @@ def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr
|
||||
|
||||
def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
|
||||
try:
|
||||
images = frame_artwork.prepare(name or info['label'], info.get('icon_png'), artwork)
|
||||
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)
|
||||
@@ -172,35 +176,31 @@ def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
|
||||
ssh(f'mkdir -p {d}')
|
||||
_copy(apk_path, f'{d}/app.apk.part')
|
||||
_copy(LAUNCHER, f'{d}/launch.sh', executable=True, timeout=120)
|
||||
ssh(f'mkdir -p {d}/artwork')
|
||||
paths = {}
|
||||
for slot, (ext, data) in images.items():
|
||||
path = f'{d}/icon.{ext}' if slot == 'icon' else f'{d}/artwork/{slot}.{ext}'
|
||||
ssh(f'cat > {path}.tmp && mv {path}.tmp {path}', input=data)
|
||||
paths[slot] = path
|
||||
icon = paths['icon']
|
||||
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}',
|
||||
f'{home}/{icon}')
|
||||
'')
|
||||
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 = json.loads(shortcut_tool(
|
||||
'configure', str(shortcut), name or info['label'], f'{home}/{d}/launch.sh',
|
||||
f'{home}/{d}', f'{home}/{icon}', '0' if flatscreen else '1',
|
||||
json.dumps({slot: f'{home}/{path}' for slot, path in paths.items()})) or '{}')
|
||||
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),
|
||||
'library_warnings': presentation.get('warnings', [])}
|
||||
'library_warnings': presentation.get('warnings', []),
|
||||
'artwork': presentation.get('artwork', {}), 'library_version': 2}
|
||||
_write_meta(d, meta)
|
||||
ok = True
|
||||
return meta
|
||||
@@ -218,6 +218,101 @@ def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
|
||||
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)
|
||||
@@ -375,6 +470,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')
|
||||
|
||||
+38
-86
@@ -1,8 +1,5 @@
|
||||
"""Small stdlib PNG compositor for Steam's Android library artwork."""
|
||||
import hashlib
|
||||
"""Bounded artwork inputs for Steam library canvas rendering."""
|
||||
import struct
|
||||
import textwrap
|
||||
import unicodedata
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zlib
|
||||
@@ -12,18 +9,6 @@ SLOTS = {'grid': (600, 900), 'wide': (920, 430), 'hero': (3840, 1240),
|
||||
MAX_IMAGE = 12 * 1024 * 1024
|
||||
MAX_PIXELS = 8_000_000
|
||||
PNG = b'\x89PNG\r\n\x1a\n'
|
||||
# Five-column display lettering; labels remain exact in Steam's native text.
|
||||
GLYPHS = dict(zip('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789- .?', (
|
||||
'0E11111F111111', '1E11111E11111E', '0F10101010100F', '1E11111111111E',
|
||||
'1F10101E10101F', '1F10101E101010', '0F10101711110F', '1111111F111111',
|
||||
'0E04040404040E', '0702020212120C', '11121418141211', '1010101010101F',
|
||||
'111B1515111111', '11191513111111', '0E11111111110E', '1E11111E101010',
|
||||
'0E11111115120D', '1E11111E141211', '0F10100E01011E', '1F040404040404',
|
||||
'1111111111110E', '11111111110A04', '11111115151B11', '11110A040A1111',
|
||||
'11110A04040404', '1F01020408101F', '0E11131519110E', '040C040404040E',
|
||||
'0E11010204081F', '1E01010E01011E', '02060A121F0202', '1F10101E01011E',
|
||||
'0E10101E11110E', '1F010204080808', '0E11110E11110E', '0E11110F01010E',
|
||||
'0000001F000000', '00000000000000', '00000000000C0C', '0E110102040004')))
|
||||
|
||||
|
||||
def chunk(kind, data):
|
||||
@@ -150,24 +135,6 @@ def image_type(data):
|
||||
raise ValueError('artwork must be a supported PNG or JPEG')
|
||||
|
||||
|
||||
def rectangle(pixels, width, x, y, w, h, color):
|
||||
row = bytes(color) * w
|
||||
for yy in range(y, y + h):
|
||||
start = (yy * width + x) * 4
|
||||
pixels[start:start + len(row)] = row
|
||||
|
||||
|
||||
def lettering(pixels, width, text, y, scale, color):
|
||||
x = (width - (len(text) * 6 - 1) * scale) // 2
|
||||
for ch in text:
|
||||
glyph = bytes.fromhex(GLYPHS.get(ch, GLYPHS['?']))
|
||||
for yy, bits in enumerate(glyph):
|
||||
for xx in range(5):
|
||||
if bits & (1 << (4 - xx)):
|
||||
rectangle(pixels, width, x + xx * scale, y + yy * scale, scale, scale, color)
|
||||
x += 6 * scale
|
||||
|
||||
|
||||
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))
|
||||
@@ -182,60 +149,45 @@ def stamp(pixels, width, icon, x, y, size):
|
||||
pixels[dst + 3] = a + pixels[dst + 3] * (255 - a) // 255
|
||||
|
||||
|
||||
def fallback(label, icon_png=None):
|
||||
text = unicodedata.normalize('NFKD', label).encode('ascii', 'ignore').decode().upper().strip() or 'ANDROID APP'
|
||||
text = ' '.join(text.split())[:80]
|
||||
seed = hashlib.sha256(label.encode()).digest()
|
||||
accent = tuple(100 + c % 110 for c in seed[:3]) + (255,)
|
||||
try:
|
||||
icon = decode(icon_png)
|
||||
except (ValueError, zlib.error, struct.error):
|
||||
icon = None
|
||||
out = {}
|
||||
for slot, (w, h) in SLOTS.items():
|
||||
pixels = bytearray(w * h * 4)
|
||||
if slot != 'logo':
|
||||
for y in range(h):
|
||||
color = (14 + 12 * y // h, 22 + 16 * y // h, 34 + 22 * y // h, 255)
|
||||
rectangle(pixels, w, 0, y, w, 1, color)
|
||||
rectangle(pixels, w, w // 12, h - max(6, h // 70), w * 5 // 6, max(3, h // 180), accent)
|
||||
size = {'grid': 320, 'wide': 190, 'hero': 640, 'logo': 0, 'icon': 192}[slot]
|
||||
if size:
|
||||
x, y = (w - size) // 2, {'grid': 160, 'wide': 38, 'hero': 200, 'icon': 32}[slot]
|
||||
if icon:
|
||||
stamp(pixels, w, icon, x, y, size)
|
||||
else:
|
||||
scale = size // 9
|
||||
lettering(pixels, w, text[0], y + (size - scale * 7) // 2, scale, accent)
|
||||
if slot != 'icon':
|
||||
scale = {'grid': 5, 'wide': 5, 'hero': 12, 'logo': 10}[slot]
|
||||
limit = (w - w // 6) // (scale * 6)
|
||||
lines = textwrap.wrap(text, limit)[:3 if slot in ('grid', 'logo') else 2]
|
||||
if len(textwrap.wrap(text, limit)) > len(lines):
|
||||
lines[-1] = lines[-1][:-3] + '...'
|
||||
y = {'grid': 580, 'wide': 280, 'hero': 930, 'logo': (h - len(lines) * scale * 9) // 2}[slot]
|
||||
for line in lines:
|
||||
lettering(pixels, w, line, y, scale, (240, 245, 250, 255))
|
||||
y += scale * 9
|
||||
out[slot] = ('png', png(w, h, pixels))
|
||||
return out
|
||||
|
||||
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 {}
|
||||
if not isinstance(artwork, dict) or set(artwork) - set(SLOTS):
|
||||
raise ValueError('artwork slots are grid, wide, hero, logo and icon')
|
||||
supplied = {}
|
||||
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():
|
||||
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)
|
||||
supplied[slot] = (image_type(value), value)
|
||||
result = fallback(label, supplied.get('icon', ('', icon_png))[1])
|
||||
result.update(supplied)
|
||||
return result
|
||||
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)
|
||||
|
||||
@@ -628,6 +628,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">
|
||||
@@ -2305,5 +2318,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
@@ -39,6 +39,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import frame_android # 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
|
||||
@@ -588,6 +589,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']}"}
|
||||
@@ -1227,7 +1233,9 @@ def _sweep_one(prefix, d):
|
||||
pass
|
||||
|
||||
|
||||
POST = {"/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/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}
|
||||
@@ -1331,6 +1339,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