mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 07:00:37 +02:00
Merge branch 'library-fixes' into apk-store-fixes
This commit is contained in:
commit
7990553620
18 files changed
+743
-323
No files matched your search
+36
-8
@@ -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.
|
||||
|
||||
|
||||
@@ -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=$?
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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
@@ -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:{}});
|
||||
|
||||
@@ -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()
|
||||
@@ -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'),({},[]))
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 & 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
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user