Merge vr-library (Steam library art, Play/Stop, refresh-art) into the store

Resolve CLI usage and POST table conflicts. Store installs now hand the
source's own image URLs (icon, banner, screenshots) to frame_android.install
as Steam artwork; before, they passed UI proxy paths (or nothing), so every
store install fell back to generated art.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-28 22:45:31 +10:00
commit 8315c7c3aa
26 files changed
+1813 -34

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
# vr-library implementation notes
Scope: `/tmp/vrapk/p1.md` plus parent updates mandating all-entrypoint artwork,
SteamGridDB, designed fallback art, settings/backfill and two visual iterations.
Worktree `/Users/saphid/projects/steam-frame-vrlib`, branch `vr-library`.
No delegation, push, PR or issue edits. Parent owns independent review and integration.
## Implementation
- Initial commit `c06b328`: shared artwork, collection handling, launcher
supervision, APK icon fallback and tests. Follow-up replaces its bitmap
artwork implementation with Steam Chromium canvas + Motiva Sans.
- `ui/frame_steamgriddb.py`: optional API provider, exact title (or trailing VR)
match, highest-scored returned static/non-NSFW artwork, separate portrait
and wide requests. No key means no requests/warnings. Saved settings are
atomic 0600 on POSIX; API never returns the key; auth redirects disabled.
- Precedence: provider, source slots/banner/feature graphic/screenshots, APK
icon/generated art. All five outputs have fixed Steam sizes. Host Python
stays stdlib-only and Python 3.9 compatible; Frame renders consistently
regardless of host OS. Package filters include both new JS resources.
- `frame/android/library_artwork.js`: dominant-colour gradient, blurred icon
backdrop, large icon/shadow, real font, no title in hero, transparent logo.
Removes only opaque near-black matte connected to icon corners. Native
icons remain original; opaque foreground app tiles have rounded corners.
- `apply_library` is mandatory for APK and native-title installs. CLI, upload,
catalogue, versions, web install and source install seam share it. Native
devkit executable/runtime is preserved. Failed initial art application
removes a newly created shortcut; no success with incomplete art.
- Refresh CLI/API/settings button repairs installed Android entries without
reinstalling/stopping, uses cached source art, queries SGDB again if enabled,
repairs missing shortcuts, and reports batch errors independently.
- Details: name/icon/VR flag, sort-as, Android/Android VR collections or native
Sideloaded, Installation details note preserving other notes. No supported
arbitrary description/store-page/developer/achievement metadata found.
Notes are keyed by sanitized name (Steam limitation: equal-name collisions).
- Device discovery: custom-art type 4 is broken on this Steam client: it logs
Unknown asset type and overwrites wide art with the icon. Use types 0–3 and
SetShortcutIcon separately. Confirmed actual cache after correction.
- Launcher retains its supervising shell under Steam, traps TERM/INT/HUP,
stops only its own container, kills its child group, preserves exit status.
Lock/pre-existing container guards prevent duplicate session cleanup.
- VR attribution discovery: Lepton uses SteamAppId for both stable context and
Android SteamVR identity. New shortcut.id + LEPTON_ENV_SteamAppId separates
them using Lepton's existing passthrough; container/data paths unchanged.
On-device source mounting.sh applies LEPTON_ENV_* after its regular setenv.
## Visual critique and iterations
Evidence `/tmp/vrlib-evidence/design-v1`, `design-v2`, `design-v3` (15 PNGs each).
Open Saber Plus/SuperTux icons came from authorized APKs. AntennaPod is a
preview only using the official F-Droid icon; it was not installed/launched.
1. v1: real typography/gradient already improves over old bitmap output, but
Open Saber has a black square matte, and SuperTux palette looks muddy.
2. v2: removed connected black matte; rounded the opaque AntennaPod foreground.
Inspected all three posters. SuperTux still muted; title line balance weak.
3. v3: lifted sampled saturation and balanced two-line labels. Inspected all
three posters plus final all-slot sheet. Large recognisable artwork, readable
typography, richer colours, textless hero and transparent title logo. Icon
source resolution still limits detail; source/SGDB art remains preferable.
Final sheet `/tmp/vrlib-evidence/artwork-preview-final.png` is an artwork
preview, NOT a Steam UI screenshot. Rows: portrait, wide, hero, logo/icon.
## Device verification (2026-09-28, build 20260925.6191901, SteamVR 2.18.1)
Steam was initially unavailable (old notes/evidence), then recovered. Both
APKs were reinstalled and refreshed successfully with zero library warnings:
- Open Saber Plus: org.godotengine.open_saber_plus, instance 2802929330,
shortcut 3346865537, game ID 14374678025558032384.
- SuperTux: org.supertux.supertux2, instance 2811892472,
shortcut 2883168793, game ID 12383115674816348160.
`steam-cache-final.log`: both actual Steam cache sets are 600x900, 920x430,
3840x1240, 1280x480, with app icon 256x256. `steam-details-targets.json`:
both correct names/sort-as, Android + Android VR, existing Played preserved.
Native managed note readback result 1 (success), saved as steam-notes-final.json.
Open Saber first session: Steam tracked it for 32 seconds; process and
container remained alive until only SteamClient TerminateApp was called.
After identity fix, a second session remained alive at 22 seconds (Android
PID 992), SteamVR identified steam.app.3346865537, and screenshot shows the
actual game scene instead of blank Resume tile. Steam Stop removed tracked
process and container within ~5 seconds. No direct podman-stop fallback was
used for this verification. Same data paths; existing game play count shown
in capture, but no separate save-file sentinel added to the real game.
Evidence: opensaber-identity-session.log, opensaber-identity-headset.png,
opensaber-running.log, opensaber-steam-stop.log, opensaber-headset-running.png.
SuperTux: Steam Play tracked the wrapper for ~17 seconds. SDLClipboardHandler
crashes with NullPointerException on missing ClipboardManager during activity
creation. Lepton and supervisor then cleaned up; it never reached a VR scene.
Steam Stop was issued after the crash, so sustained SuperTux Stop is NOT proven.
Evidence: supertux-running.log, supertux-lepton.log, supertux-shot.json.
Other test activity appeared on the device during checks (other Android
container and Gravitas). Neither was stopped or modified. Final Open Saber
preflight had no Android containers; other desktop overlays later appeared
in its headset capture. No global VR standby/dashboard settings changed.
Direct Steam UI Page.captureScreenshot timed out; no library UI screenshot.
## Automated verification and limits
- Whole suite: `python3 -m unittest discover -s tests`, Python 3.9.6. Final
result: **206 tests OK, 10.294 seconds, exit 0**. Log:
`/tmp/vrlib-evidence/tests-final.log`.
- Offline entrypoint tests exercise real shared install/render/configure flow
with SSH/API mocked: CLI, upload, catalogue, versions, web download, source
seam, native title and refresh. Provider ranking/failure/settings tests.
- Node contract test runs actual renderer against explicitly synthetic canvas,
validates all PNG dimensions and hero/logo text placement. This canvas is
also used by fakeframe; transparent fixture images are not visual evidence.
- Launcher tests assert stable context plus shortcut passthrough, signals,
normal exit, duplicate guard and saved-data sentinel. Earlier real Linux
setsid/flock fixture run: 3 tests OK (linux-launcher-tests.log).
- `bash -n`, Node syntax checks, Python AST feature_version=(3,9), diff checks.
- Docker E2E not run: `docker info --format '{{.ServerVersion}}'` exits 1;
daemon socket /var/run/docker.sock does not exist.
- Authenticated SGDB lookup/download unverified (no key configured).
- Windows/Linux packaged binaries not built/launched; filters tested.
- Native devkit art uses shared tested seam; no additional native title was
installed on device. Sibling source-search endpoint not in this worktree:
only its public installer contract is tested.
- Independent review not spawned: explicit brief forbids delegation and assigns
parent review/integration. No other provider/model participation claimed.
+4 -2
View File
@@ -46,7 +46,8 @@
"to": "ui",
"filter": [
"*.py",
"*.html"
"*.html",
"*.js"
]
},
{
@@ -69,7 +70,8 @@
"to": "frame/android",
"filter": [
"*.sh",
"*.py"
"*.py",
"*.js"
]
},
{
+94
View File
@@ -84,6 +84,100 @@ not being worn, so it did not reach `FOCUSED`).
The loader was never the problem: Wolvic's Quest `libopenxr_loader.so` is a
Khronos-style loader and found SteamVR through `/vendor`.
## In the Steam library
Every successful APK install goes through the same mandatory artwork writer:
CLI (including `scripts/install-apk.sh`), upload, catalogue, version finder,
web download and source modules calling `frame_android.install`. Native
Linux/Windows sideloads also use it, preserving their devkit runtime wiring.
A new shortcut is rolled back if artwork fails; failure is never reported as
an installed app with a blank tile.
Artwork preference is **SteamGridDB → source images → generated fallback**.
Set the optional free key in Frame Control's **Library artwork settings**, or
`STEAMGRIDDB_API_KEY` (`FRAME_STEAMGRIDDB_API_KEY` also works). Environment
settings override the saved key. Without a key there are no provider calls or
warnings. Saved keys stay in host app data, mode 0600 on POSIX, and are never
returned by the settings API or copied to the headset. Exact title matches
(including a trailing “VR” variant) use the highest-scored returned static,
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 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.
Generated art uses the APK icon, a dominant-colour gradient, a blurred
backdrop and large foreground icon with shadow. Steam's Chromium canvas and
Motiva Sans render real text consistently regardless of the host OS; no
Pillow, host font installation or bitmap font is needed. The hero has no
title; the generated logo is a transparent title. APKs with no usable icon
get a typographic monogram. The desktop package includes the renderer.
Backfill installed Android apps without reinstalling or stopping them:
```sh
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.
**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,
hero and logo PNGs have the dimensions above; each shortcut points at its
256×256 icon. This Frame client mishandles custom-art type 4 (documented as
Icon), overwriting the wide capsule; the implementation uses custom types
0–3 and **SetShortcutIcon** separately.
Steam accepts display name, executable/start directory, icon, VR flag and
sort-as name. Android apps join **Android**, immersive apps also **Android
VR**; native sideloads join **Sideloaded**. Existing collection members and
unrelated collections are preserved (both games retained **Played**).
Dynamic/read-only collection conflicts produce warnings. The native notes
API supports a managed **Installation details** note (package, version and
source) while preserving other notes. Notes are keyed by sanitized shortcut
name, so Steam itself cannot distinguish equal-name shortcut notes. No
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
64-bit game ID string. Frame Control's Stop additionally has a direct-container
fallback. The stable instance ID and compatdata paths remain unchanged.
Lepton normally forwards the instance `SteamAppId` to Android, causing
SteamVR to associate the scene with a different, artwork-less app. The
launcher uses Lepton's supported `LEPTON_ENV_SteamAppId` passthrough to send
the actual shortcut ID to Android while retaining the stable container ID.
**Verified:** Open Saber was alive 22 seconds after Steam Play, SteamVR
identified `steam.app.3346865537`, and its scene appeared in the headset
capture without the previous blank Resume tile. Steam Stop then removed its
tracked process and stopped the container. An earlier 32-second session was
also tracked until Steam Stop. No global standby or dashboard overrides were
installed; wear detection and other user-opened overlays still apply.
**SuperTux limitation:** Steam launched and tracked it, but SDL crashed during
activity creation because Lepton lacks `ClipboardManager`. Its container
cleaned up on exit after about 17 seconds. Consequently sustained SuperTux
Play/Stop and its VR scene could not be verified. This is an APK/runtime
compatibility failure, separate from library presentation.
Evidence is under `/tmp/vrlib-evidence/` on the development Mac: final artwork
preview and three design passes, `steam-cache-final.log`,
`steam-details-targets.json`, `opensaber-identity-session.log`,
`opensaber-identity-headset.png`, and `supertux-lepton.log`. The preview is
rendered artwork, not a Steam UI screenshot; CDP screenshot capture timed
out. Authenticated SteamGridDB, Windows/Linux packaged builds and the sibling
source-search endpoint remain unverified (the public install seam is tested).
## Out of scope
- **Meta entitlement.** Apps that call the Oculus Platform SDK
+40 -1
View File
@@ -19,6 +19,21 @@ done
# A number that isn't a real Steam app; it names this app's Lepton context.
export SteamAppId="$(cat "$DIR/instance.id")"
[[ "$SteamAppId" =~ ^[0-9]+$ ]] || { echo "invalid instance.id" >&2; exit 1; }
# Keep the stable Lepton context, but identify the Android VR client as its
# actual Steam shortcut. Lepton applies LEPTON_ENV_* after its own passthrough.
if [[ -f "$DIR/shortcut.id" ]]; then
shortcut="$(cat "$DIR/shortcut.id")"
[[ "$shortcut" =~ ^[0-9]+$ ]] || { echo "invalid shortcut.id" >&2; exit 1; }
export LEPTON_ENV_SteamAppId="$shortcut"
fi
exec 9>"$DIR/launch.lock"
flock -n 9 || { echo "Android app is already running" >&2; exit 1; }
CONTAINER="lepton-steamlaunch-$SteamAppId"
if [[ "$(podman inspect --format '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" == true ]]; then
echo "Android container is already running" >&2
exit 1
fi
export STEAM_COMPAT_INSTALL_PATH="$DIR"
# Must be under ~/.local/share/Steam: only that tree is mounted in the container.
export STEAM_COMPAT_DATA_PATH="$HOME/.local/share/Steam/steamapps/compatdata/$SteamAppId"
@@ -29,4 +44,28 @@ mkdir -p "$STEAM_COMPAT_DATA_PATH" "$STEAM_FOSSILIZE_DUMP_PATH"
# Lepton's setpgid --foreground re-exec needs a terminal that Steam shortcuts
# and SSH don't have; give it its own session instead.
export IS_PARENT=true
exec setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk"
# Keep this shell in Steam's process tree; setsid alone has no container cleanup.
child=""
cleanup() {
trap '' TERM INT HUP
if [[ -n "$child" ]]; then
kill -TERM -- "-$child" 2>/dev/null || true
kill -TERM "$child" 2>/dev/null || true
fi
podman stop -t 5 "$CONTAINER" >/dev/null 2>&1 || true
if [[ -n "$child" ]]; then
kill -KILL -- "-$child" 2>/dev/null || true
kill -KILL "$child" 2>/dev/null || true
wait "$child" 2>/dev/null || true
fi
}
trap cleanup EXIT
trap 'exit 143' TERM
trap 'exit 130' INT
trap 'exit 129' HUP
setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" &
child=$!
rc=0
wait "$child" || rc=$?
child=""
exit "$rc"
+144
View File
@@ -0,0 +1,144 @@
// Runs in Steam's Chromium context: identical fonts/rendering from every host OS.
async function renderLibraryArtwork(input) {
const sizes = {grid:[600,900], wide:[920,430], hero:[3840,1240], logo:[1280,480], icon:[256,256]};
const label = String(input.label || 'Untitled').trim().slice(0,180);
const font = '"Motiva Sans", "Noto Sans", Arial, sans-serif';
await document.fonts.load(`800 120px ${font}`, label);
const images = {}, warnings = [];
for (const [slot, item] of Object.entries(input.images || {})) {
try {
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');
images[slot] = img;
} catch (_) { warnings.push(`${slot} could not be decoded; generated art used`); }
}
let icon = images.icon;
if (icon) {
// Remove only a near-black matte connected to the outside of an opaque icon.
const cut=document.createElement('canvas');cut.width=icon.width;cut.height=icon.height;
const c=cut.getContext('2d');c.drawImage(icon,0,0);
const pixels=c.getImageData(0,0,cut.width,cut.height), d=pixels.data, w=cut.width,h=cut.height;
const corners=[0,w-1,(h-1)*w,h*w-1];
if(corners.every(i=>d[i*4+3]>250 && Math.max(d[i*4],d[i*4+1],d[i*4+2])<24)) {
const seen=new Uint8Array(w*h), queue=corners.slice();
for(let q=0;q<queue.length;q++) {
const i=queue[q];if(seen[i])continue;seen[i]=1;
if(Math.max(d[i*4],d[i*4+1],d[i*4+2])>24)continue;
d[i*4+3]=0;
if(i%w)queue.push(i-1);if(i%w<w-1)queue.push(i+1);
if(i>=w)queue.push(i-w);if(i<w*(h-1))queue.push(i+w);
}
c.putImageData(pixels,0,0);icon=cut;
}
}
// Quantized, saturated dominant colors avoid white/black icon margins.
let colors = [[48,91,137], [24,36,63]];
if (icon) {
const sample = document.createElement('canvas'); sample.width=48; sample.height=48;
const s=sample.getContext('2d'); s.drawImage(icon,0,0,48,48);
const data=s.getImageData(0,0,48,48).data, bins=new Map();
for (let i=0;i<data.length;i+=4) {
const rgb=[data[i],data[i+1],data[i+2]], hi=Math.max(...rgb), lo=Math.min(...rgb);
if (data[i+3]<150 || hi<45 || lo>220 || hi-lo<25) continue;
const key=rgb.map(v=>Math.round(v/32)*32).join(',');
bins.set(key,(bins.get(key)||0)+1);
}
const ranked=[...bins].sort((a,b)=>b[1]-a[1]);
if (ranked.length) {
colors[0]=ranked[0][0].split(',').map(Number);
colors[1]=(ranked.find(([key])=>key.split(',').reduce((n,v,i)=>n+Math.abs(Number(v)-colors[0][i]),0)>170)||ranked[0])[0].split(',').map(Number);
}
}
// Preserve hue while lifting muted icon colors into a richer background palette.
colors=colors.map(c=>{const low=Math.min(...c),range=Math.max(...c)-low||1;
return c.map(v=>45+(v-low)/range*165);});
const rgb=(c,a=1)=>`rgba(${c.map(v=>Math.min(255,Math.round(v))).join(',')},${a})`;
function image(ctx,img,x,y,w,h,cover=false) {
const scale=cover?Math.max(w/img.width,h/img.height):Math.min(w/img.width,h/img.height);
const dw=img.width*scale,dh=img.height*scale;
ctx.save(); ctx.beginPath(); ctx.rect(x,y,w,h); ctx.clip();
ctx.drawImage(img,x+(w-dw)/2,y+(h-dh)/2,dw,dh); ctx.restore();
}
function title(ctx,w,h,top,bottom,maxSize) {
let lines=[],size=maxSize;
const maxWidth=w*.84;
for (;size>=18;size-=2) {
ctx.font=`800 ${size}px ${font}`;
lines=[]; let line='';
for (const word of label.split(/\s+/)) {
const next=line?line+' '+word:word;
if (line && ctx.measureText(next).width>maxWidth) {lines.push(line);line=word;} else line=next;
}
lines.push(line);
if (lines.length*size*1.08<=bottom-top && lines.every(l=>ctx.measureText(l).width<=maxWidth)) break;
}
if(lines.length===2) {
const words=lines[0].split(' ');
if(words.length>1) {
const first=words.slice(0,-1).join(' '), second=words.slice(-1)[0]+' '+lines[1];
if(ctx.measureText(second).width<=maxWidth &&
Math.abs(ctx.measureText(first).width-ctx.measureText(second).width)<
Math.abs(ctx.measureText(lines[0]).width-ctx.measureText(lines[1]).width)) lines=[first,second];
}
}
// A long unbroken label is still fitted, including scripts without spaces.
ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillStyle='#fff';
ctx.shadowColor='rgba(0,0,0,.45)'; ctx.shadowBlur=size*.28; ctx.shadowOffsetY=size*.06;
let y=top+(bottom-top-lines.length*size*1.08)/2+size*.54;
for (const line of lines) {ctx.fillText(line,w/2,y,maxWidth); y+=size*1.08;}
ctx.shadowBlur=0; ctx.shadowOffsetY=0;
}
const result={};
for (const [slot,[w,h]] of Object.entries(sizes)) {
const canvas=document.createElement('canvas'); canvas.width=w; canvas.height=h;
const ctx=canvas.getContext('2d'); ctx.imageSmoothingQuality='high';
const direct=images[slot];
const feature=images.feature_graphic||images.banner;
const scene=direct || ((slot==='hero'||slot==='wide') && (feature||images.screenshot));
if (scene) {
if (slot==='logo'||slot==='icon') image(ctx,scene,0,0,w,h);
else image(ctx,scene,0,0,w,h,true);
} else if (slot==='logo') {
title(ctx,w,h,h*.08,h*.92,150);
} else {
const gradient=ctx.createLinearGradient(0,0,w,h);
gradient.addColorStop(0,rgb(colors[0].map(v=>v*.68)));
gradient.addColorStop(.6,rgb(colors[1].map(v=>v*.32)));
gradient.addColorStop(1,'#080c16'); ctx.fillStyle=gradient;ctx.fillRect(0,0,w,h);
if (icon) {
ctx.save();ctx.globalAlpha=.16;ctx.filter=`blur(${Math.round(w*.055)}px) saturate(1.4)`;
image(ctx,icon,-w*.15,-h*.15,w*1.3,h*1.3,true);ctx.restore();
}
const glow=ctx.createRadialGradient(w*.5,h*.32,0,w*.5,h*.32,w*.8);
glow.addColorStop(0,rgb(colors[0],.27));glow.addColorStop(1,rgb(colors[1],0));
ctx.fillStyle=glow;ctx.fillRect(0,0,w,h);
const vignette=ctx.createLinearGradient(0,h*.25,0,h);
vignette.addColorStop(0,'rgba(0,0,0,0)');vignette.addColorStop(1,'rgba(0,0,0,.56)');
ctx.fillStyle=vignette;ctx.fillRect(0,0,w,h);
const box=slot==='grid'?[w*.12,h*.14,w*.76,w*.76]:
slot==='wide'?[w*.36,h*.06,w*.28,h*.59]:
slot==='hero'?[w*.365,h*.12,w*.27,h*.78]:[w*.08,h*.08,w*.84,h*.84];
if (icon) {
ctx.save();ctx.shadowColor='rgba(0,0,0,.65)';ctx.shadowBlur=Math.min(w,h)*.055;
ctx.shadowOffsetY=Math.min(w,h)*.022;
// Opaque square icons read as deliberate app tiles, not pasted rectangles.
const [x,y,bw,bh]=box, side=Math.min(bw,bh);
if(slot!=='hero' && icon===images.icon) {
ctx.beginPath();ctx.roundRect(x+(bw-side)/2,y+(bh-side)/2,side,side,side*.14);ctx.clip();
}
image(ctx,icon,...box);ctx.restore();
} else if (slot !== 'hero') {
// A typographic monogram when the APK contains no usable image.
ctx.font=`800 ${Math.min(w,h)*.48}px ${font}`;ctx.fillStyle='rgba(255,255,255,.94)';
ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText([...label][0]||'A',w/2,h*.38);
}
if (slot==='grid') title(ctx,w,h,h*.7,h*.93,66);
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];
}
return {images:result,warnings,font};
}
+160 -4
View File
@@ -5,9 +5,11 @@ Python stdlib only; the Mac runs it with `ssh frame python3 - <args> < this`.
steam_shortcuts.py add NAME EXE START_DIR [ICON] -> prints the shortcut app id
steam_shortcuts.py list -> JSON [{appid, name, exe}]
steam_shortcuts.py configure APPID NAME EXE START_DIR ICON VR ARTWORK_JSON
steam_shortcuts.py stop APPID
steam_shortcuts.py remove APPID
"""
import base64, json, os, socket, struct, sys, urllib.request
import base64, json, os, re, socket, struct, sys, urllib.request
DEVTOOLS = 'http://127.0.0.1:8080/json'
@@ -83,6 +85,153 @@ def evaluate(js):
return res.get('result', {}).get('value')
# Steam's ELibraryAssetType (Capsule, Hero, Logo, Header, Icon).
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 [])]
return f'''async function syncCollections() {{
const wanted = {json.dumps(wanted)};
if (typeof collectionStore === "undefined" ||
typeof collectionStore.GetUserCollectionsByName !== "function" ||
typeof collectionStore.NewUnsavedCollection !== "function" ||
typeof collectionStore.SaveCollection !== "function")
return ["Steam collections API unavailable"];
const app = {{appid: {appid}}};
const warnings = [];
for (const name of ["Android", "Android VR", "Sideloaded"]) {{
const matches = collectionStore.GetUserCollectionsByName(name);
let collection = matches.find(c => !c.bIsDynamic && c.bAllowsDragAndDrop);
if (wanted.includes(name)) {{
if (!collection && matches.length) {{
warnings.push(name + " is an existing dynamic or read-only collection");
continue;
}}
if (!collection) {{
collection = collectionStore.NewUnsavedCollection(name, undefined, [app]);
}} else {{
collection.AsDragDropCollection().AddApps([app]);
}}
await collectionStore.SaveCollection(collection);
}} else if (collection) {{
collection.AsDragDropCollection().RemoveApps([app]);
await collectionStore.SaveCollection(collection);
}}
}}
return warnings;
}}'''
def notes_js(name, details):
filename = 'notes_shortcut_' + re.sub(r'[!-/:-@ \[\\\]\^`]', '_', name.strip())
content = '\n'.join(str(details[k]) for k in ('package', 'version', 'source') if details.get(k))
return f'''if (SteamClient.GameNotes && typeof SteamClient.GameNotes.GetNotes === "function" &&
typeof SteamClient.GameNotes.SaveNotes === "function") {{
try {{
const file = {json.dumps(filename)};
const previous = await SteamClient.GameNotes.GetNotes(file, file + "_images/");
if (previous.result !== 1 && previous.result !== 9) throw Error("read " + previous.result);
const data = previous.result === 1 ? JSON.parse(previous.notes) : {{notes: [], shortcut_name: {json.dumps(name)}}};
if (!Array.isArray(data.notes)) throw Error("unexpected notes format");
const id = "frame-control-library", now = Math.floor(Date.now()/1000);
const old = data.notes.find(n => n.id === id);
const note = {{id, shortcut_name: {json.dumps(name)}, title: "Installation details",
content: {json.dumps(content)}, ordinal: old ? old.ordinal : data.notes.length,
time_created: old ? old.time_created : now, time_modified: now}};
data.notes = data.notes.filter(n => n.id !== id).concat([note]);
const result = await SteamClient.GameNotes.SaveNotes(file, JSON.stringify(data));
if (result !== 1) throw Error("save " + result);
}} catch (e) {{ warnings.push("Steam notes: " + String(e)); }}
}}'''
def render(plan):
with open(plan) as f:
source = json.load(f)
images = {}
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:
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}) + ')')
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')
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:
f.write(data)
os.replace(path + '.tmp', path)
paths[slot] = path
return {'paths': paths, 'warnings': result.get('warnings', [])}
def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None):
options = options or {}
if set(artwork) != set(ASSETS):
raise ValueError('all five Steam artwork slots are required')
images = []
for slot, path in artwork.items():
if slot not in ASSETS:
raise ValueError('unknown artwork slot')
ext = os.path.splitext(path)[1][1:]
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:
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()])
return evaluate(f'''(async () => {{
const id = {int(appid)}, warnings = [];
SteamClient.Apps.SetShortcutName(id, {json.dumps(name)});
if ({json.dumps(exe)}) SteamClient.Apps.SetShortcutExe(id, {json.dumps(exe)});
if ({json.dumps(start_dir)}) SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)});
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");
if (typeof SteamClient.Apps.SetCustomArtworkForApp === "function") {{
for (const [type, ext, data] of {json.dumps(images)})
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'))}
try {{ warnings.push(...await syncCollections()); }}
catch (e) {{ warnings.push("Steam collections: " + String(e)); }}
{notes_js(name, options.get('details', {}))}
return {{warnings}};
}})()''')
def remove(appid):
return evaluate(f'''(async () => {{
const id = {int(appid)};
{collections_js(int(appid))}
const warnings = await syncCollections();
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");
SteamClient.Apps.RemoveShortcut(id);
return {{warnings}};
}})()''')
def main():
cmd, args = sys.argv[1], sys.argv[2:]
if cmd == 'add':
@@ -98,11 +247,18 @@ 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})))()'''
.map(a => ({appid: a.appid, name: a.display_name, devkit_gameid: a.devkit_gameid})))()'''
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]),
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)')
print('stopping')
elif cmd == 'remove':
evaluate(f'SteamClient.Apps.RemoveShortcut({int(args[0])})')
print('removed')
print(json.dumps(remove(int(args[0]))))
else:
sys.exit(__doc__)
@@ -25,6 +25,10 @@ containers = {n: c for n, c in fs.read()['lepton'].items() if alive(c)}
if cmd == 'ps':
for name, c in sorted(containers.items()):
print(f"{name} {c['port']}")
elif cmd == 'inspect':
if args[-1] not in containers:
sys.exit(1)
print('true')
elif cmd == 'stop':
name = args[-1]
c = containers.get(name)
@@ -0,0 +1,32 @@
// Synthetic canvas for API-path tests only. It emits transparent PNGs, not visual proof.
const zlib = require('zlib');
function crc(data) {
let c=0xffffffff;
for(const b of data) {c^=b;for(let i=0;i<8;i++)c=(c>>>1)^((c&1)?0xedb88320:0);}
return (c^0xffffffff)>>>0;
}
function chunk(name,data) {
const body=Buffer.concat([Buffer.from(name),data]), n=Buffer.alloc(4), sum=Buffer.alloc(4);
n.writeUInt32BE(data.length);sum.writeUInt32BE(crc(body));return Buffer.concat([n,body,sum]);
}
function png(w,h) {
const header=Buffer.alloc(13);header.writeUInt32BE(w);header.writeUInt32BE(h,4);header[8]=8;header[9]=6;
return Buffer.concat([Buffer.from('89504e470d0a1a0a','hex'),chunk('IHDR',header),
chunk('IDAT',zlib.deflateSync(Buffer.alloc((w*4+1)*h))),chunk('IEND',Buffer.alloc(0))]).toString('base64');
}
function surface() {
const canvases=[];
const document={fonts:{load:async()=>[]},createElement(tag) {
if(tag!=='canvas')throw Error('unexpected element');
const canvas={width:1,height:1,text:[],draws:0};
const ctx={measureText:t=>({width:String(t).length*30}),fillText(t){canvas.text.push(t);},
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);
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');}}
return {document,Image,canvases};
}
module.exports={surface};
@@ -37,7 +37,7 @@ function build(steam) {
},
});
const shortcutOverview = s => ({
appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE,
appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE, devkit_gameid: s.devkit_gameid,
local_per_client_data: { installed: true, display_status: 1, status_percentage: 0 },
});
const allApps = () => [...steam.apps.map(gameOverview), ...steam.shortcuts.map(shortcutOverview)];
@@ -54,7 +54,23 @@ function build(steam) {
}
};
// Library API shapes from SteamTracking / decky-frontend-lib (2026-09-28).
// Not yet verified on this Frame build: Steam was unavailable during testing.
steam.collections ||= [];
const collection = value => ({
...value, displayName: value.name, bIsDynamic: !!value.dynamic, bAllowsDragAndDrop: true,
AsDragDropCollection() { return this; },
AddApps(apps) { value.apps = [...new Set([...value.apps, ...apps.map(a => a.appid)])]; },
RemoveApps(apps) { value.apps = value.apps.filter(id => !apps.some(a => a.appid === id)); },
value,
});
return {
...require('./canvas_stub').surface(),
collectionStore: {
GetUserCollectionsByName(name) { return steam.collections.filter(c => c.name === name).map(collection); },
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); },
},
appStore: {
get allApps() { return allApps(); },
GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; },
@@ -75,6 +91,17 @@ function build(steam) {
SetShortcutStartDir(id, dir) { const s = findShortcut(id); if (s) s.start_dir = String(dir); },
SetShortcutIcon(id, icon) { const s = findShortcut(id); if (s) s.icon = String(icon); },
SetShortcutExe(id, exe) { const s = findShortcut(id); if (s) s.exe = String(exe); },
SetShortcutIsVR(id, vr) { const s = findShortcut(id); if (s) s.vr = vr; },
async SetCustomArtworkForApp(id, data, ext, type) {
const s = findShortcut(id);
if (s) { s.artwork ||= {}; s.artwork[type] = {data, ext}; }
},
async ClearCustomArtworkForApp(id, type) {
const s = findShortcut(id);
if (s?.artwork) delete s.artwork[type];
},
// Container fallback in frame_android.stop performs the simulated stop.
TerminateApp(gameid) { steam.last_terminate = gameid; },
RemoveShortcut(id) {
steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id));
delete steam.compat_tools[String(id)];
+17
View File
@@ -0,0 +1,17 @@
# Library fixtures
`icon.png` is a synthetic 2×2 RGBA fixture with opaque, half-transparent and
transparent pixels. `steam-responses.json` includes the Open Saber Plus
shortcut ID/name and home path read from the Frame's existing metadata on
2026-09-28; the `configure` response is synthetic, matching our helper's
contract. Tests never contact the network.
The existing fakeframe CEF shim models artwork and collection methods from
SteamTracking's `ClientExtracted/steamui/chunk~2dcc5aaf7.js` and
SteamDeckHomebrew/decky-frontend-lib's `src/globals/steam-client/App.ts`, read
2026-09-28. These methods were not captured from this headset: Steam's client
was unavailable. The Node-based test runs the actual generated JavaScript
against that fixture; it is skipped when Node is absent.
`icon.jpg` is the same synthetic icon converted with macOS `sips` to exercise
JPEG SOF parsing. `sips` is not used by the product or tests.
+16
View File
@@ -0,0 +1,16 @@
const fs=require('fs'),vm=require('vm'),assert=require('assert');
const stub=require(process.cwd()+'/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub');
(async()=>{
const surface=stub.surface(),ctx=vm.createContext(surface);
vm.runInContext(fs.readFileSync('frame/android/library_artwork.js','utf8'),ctx);
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]);
}
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:{}});
assert.equal(surface.canvases.slice(before).find(c=>c.width===3840).text.length,0);
console.log('five dimensions, textless hero, title logo: OK');
})().catch(e=>{console.error(e);process.exit(1)});
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 834 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 B

+12
View File
@@ -0,0 +1,12 @@
{
"home": "/home/steamos",
"shortcuts": [
{
"appid": 3346865537,
"name": "Open Saber Plus"
}
],
"configure": {
"warnings": []
}
}
+17
View File
@@ -181,6 +181,23 @@ class SearchTests(SettingsTest):
search.add_game_data('org.brush')
mod.download.assert_called_once_with(mod.sources()[0] | {'status': 'not searched'}, 'brush', version_code=1)
def test_install_uses_source_image_urls_as_steam_artwork(self):
mod = fake()
plain = mod.details
mod.details = lambda source, entry_id: dict(plain(source, entry_id), images={
'icon': 'https://img.example/icon.png', 'banner': 'https://img.example/banner.png',
'screenshots': ['https://img.example/1.png', None]})
seen = {}
def install(apk, name=None, icon_png=None, source=None, artwork=None):
seen['artwork'] = artwork
return {'package': 'org.brush'}
with patch.object(search, 'modules', return_value=([mod], [])), \
patch.object(server.frame_android, 'install', install):
search.install('one', 'brush')
self.assertEqual(seen['artwork'], {'icon': 'https://img.example/icon.png',
'banner': 'https://img.example/banner.png',
'screenshots': ['https://img.example/1.png']})
def test_discovery_and_demo_are_opt_in(self):
module = fake()
with patch.object(search.pkgutil, 'iter_modules', return_value=[types.SimpleNamespace(name='example')]), \
+356
View File
@@ -0,0 +1,356 @@
"""Offline artwork, Steam API and launcher supervision regressions."""
import importlib.util
import json
import os
from pathlib import Path
import signal
import struct
import subprocess
import sys
import tempfile
import time
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'ui'))
import frame_android as android
import frame_artwork as art
spec = importlib.util.spec_from_file_location('steam_shortcuts', ROOT / 'frame/android/steam_shortcuts.py')
shortcuts = importlib.util.module_from_spec(spec)
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):
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
app = d / 'Applications/Android/org.test.app'
app.mkdir(parents=True)
(app / 'launch.sh').write_bytes((ROOT / 'frame/android/lepton-app.sh').read_bytes())
(app / 'app.apk').touch()
(app / 'instance.id').write_text('2800000001')
(app / 'shortcut.id').write_text('3346865537')
bin_dir = d / 'bin'
bin_dir.mkdir()
lepton = d / '.local/share/Steam/steamapps/common/Lepton/lepton'
lepton.parent.mkdir(parents=True)
def script(path, body):
path.write_text('#!' + sys.executable + '\n' + body)
path.chmod(0o755)
script(lepton, 'import os,time\nfrom pathlib import Path\n'
'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'
+ ('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
script(bin_dir / 'podman', 'import os,sys\nfrom pathlib import Path\n'
'p=Path(os.environ["HOME"],"podman-calls")\n'
'with p.open("a") as f: f.write(" ".join(sys.argv[1:])+"\\n")\n'
'if sys.argv[1:2]==["inspect"] and os.environ.get("TEST_RUNNING"): print("true")\n')
env = {**os.environ, 'HOME': str(d), 'PATH': str(bin_dir) + os.pathsep + os.environ['PATH']}
if blocked:
env['TEST_' + blocked] = '1'
saved = d / '.local/share/Steam/steamapps/compatdata/2800000001/internal/save'
saved.parent.mkdir(parents=True)
saved.write_text('saved game')
proc = subprocess.Popen(['bash', str(app / 'launch.sh')], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
if blocked:
proc.communicate(timeout=5)
self.assertEqual(proc.returncode, 1)
self.assertFalse((d / 'started').exists())
calls = (d / 'podman-calls').read_text() if (d / 'podman-calls').exists() else ''
self.assertNotIn('stop ', calls)
return
deadline = time.monotonic() + 5
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')
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(proc.returncode, 128 + sig if terminate else 23)
self.assertEqual(saved.read_text(), 'saved game')
self.assertTrue((app / 'app.apk').exists())
finally:
if proc.poll() is None:
proc.kill()
proc.communicate()
if (d / 'started').exists():
try:
os.kill(int((d / 'started').read_text()), signal.SIGKILL)
except ProcessLookupError:
pass
def test_steam_stop_cleans_container(self):
self.exercise(True)
def test_hangup_and_interrupt_cleanup(self):
for sig in (signal.SIGHUP, signal.SIGINT):
with self.subTest(sig=sig):
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)
def test_normal_exit_cleans_container_and_keeps_exit_code(self):
self.exercise(False)
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
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:
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)
def test_provider_precedence_and_bad_source_fallback(self):
data = (FIXTURES / 'icon.png').read_bytes()
jpg = (FIXTURES / 'icon.jpg').read_bytes()
with patch('frame_steamgriddb.lookup', return_value=({'hero': jpg}, [])):
images, warnings = art.prepare('Game', data, {'hero': data, 'wide': b'bad', 'screenshots': [b'bad', data]})
self.assertEqual(images['hero'], ('jpg', jpg))
self.assertEqual(images['icon'], ('png', data))
self.assertEqual(images['screenshot'], ('png', data))
self.assertNotIn('wide', images)
self.assertEqual(len(warnings), 2)
def test_supplied_jpeg(self):
data = (FIXTURES / 'icon.jpg').read_bytes()
self.assertEqual(art.image_type(data), 'jpg')
with self.assertRaises(ValueError):
art.image_type(data[:30])
def test_bad_artwork_and_expansion_limits(self):
import zlib
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
import zipfile
data = (FIXTURES / 'icon.png').read_bytes()
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, 'w') as archive:
archive.writestr('assets/icon.png', data)
with zipfile.ZipFile(buffer) as archive:
self.assertEqual(android.frame_apk._icon_png(archive, set(archive.namelist()), []), data)
class InstallTests(unittest.TestCase):
def setUp(self):
self.responses = json.loads((FIXTURES / 'steam-responses.json').read_text())
self.info = {'package': 'org.test.vr', 'label': 'VR', 'version': '1', 'icon_png': None,
'vr': True, 'launchable': True, 'repairable': False, 'abis': [], 'min_sdk': 24}
self.images = {slot: ('png', b'PNG ' + slot.encode()) for slot in art.SLOTS}
self.existing = {'package': 'org.test.vr', 'instance': 2800000001,
'shortcut': 3346865537, 'label': 'Old name'}
def install(self, existing, tool=None):
def shortcut(*args, **kwargs):
if args[0] == 'add':
return '3346865537'
if args[0] == 'render':
return json.dumps({'paths': {slot: '/home/steamos/Applications/Android/org.test.vr/artwork/' + slot + '.png' for slot in art.SLOTS}})
if args[0] == 'list':
return json.dumps(self.responses['shortcuts'])
return json.dumps(self.responses['configure'])
with patch.object(android.frame_artwork, 'prepare', return_value=(self.images, [])), \
patch.object(android, 'read_meta', return_value=existing), \
patch.object(android, '_copy') as copy, \
patch.object(android, 'ssh', return_value=self.responses['home']) as ssh, \
patch.object(android, 'shortcut_tool', side_effect=tool or shortcut) as api, \
patch.object(android, '_write_meta') as meta:
result = android._install('game.apk', self.info, self.info['package'], False, 'New name', 'test')
return result, ssh, api, meta
def test_existing_shortcut_refreshes_name_vr_and_every_slot(self):
result, ssh, api, meta = self.install(self.existing)
calls = [c.args for c in api.call_args_list]
self.assertNotIn('add', [c[0] for c in calls])
configure = next(c for c in calls if c[0] == 'configure')
self.assertEqual(configure[1:3], ('3346865537', 'New name'))
self.assertEqual(configure[6], '1')
self.assertEqual(set(json.loads(configure[7])), set(art.SLOTS))
self.assertTrue(configure[5].endswith('/org.test.vr/artwork/icon.png'))
self.assertEqual(result['shortcut'], self.existing['shortcut'])
self.assertEqual(result['label'], 'New name')
self.assertEqual(result['library_warnings'], [])
self.assertEqual(len([c for c in ssh.call_args_list if isinstance(c.kwargs.get('input'), bytes)]), 5)
meta.assert_called_once()
def test_first_install_adds_shortcut(self):
_, _, api, _ = self.install(None)
self.assertEqual([c.args[0] for c in api.call_args_list], ['add', 'render', 'configure'])
def test_artwork_forwarded_through_patch(self):
artwork = {'hero': b'provided'}
with patch.object(android, 'apk_info', return_value={**self.info, 'repairable': True}), \
patch.object(android, 'xr_compat_files', return_value={}), \
patch.object(android, 'patch', return_value={'patched': ['launcher']}), \
patch.object(android, '_install', return_value={}) as install:
android.install('x.apk', artwork=artwork)
self.assertIs(install.call_args.args[-1], artwork)
def test_failed_new_install_removes_shortcut(self):
def tool(*args, **kwargs):
if args[0] == 'add':
return '3346865537'
if args[0] == 'render':
return json.dumps({'paths': {slot: '/tmp/' + slot + '.png' for slot in art.SLOTS}})
if args[0] == 'configure':
raise android.FrameError('write failed')
return '{}'
with patch.object(android.frame_artwork, 'prepare', return_value=(self.images, [])), \
patch.object(android, 'read_meta', return_value=None), \
patch.object(android, '_copy'), patch.object(android, 'ssh', return_value='/home/steamos') as ssh, \
patch.object(android, 'shortcut_tool', side_effect=tool) as api:
with self.assertRaisesRegex(android.FrameError, 'write failed'):
android._install('x.apk', self.info, 'org.test.vr', False, None, None)
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)), \
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')
ssh.reset_mock()
with self.assertRaises(android.FrameError):
android.remove('org.test.vr')
ssh.assert_not_called()
def test_stop_requests_steam_and_has_container_fallback(self):
with patch.object(android, '_meta_or_fail', return_value=self.existing), \
patch.object(android, 'shortcut_tool', side_effect=android.FrameError('offline')) as api, \
patch.object(android, 'ssh') as ssh:
android.stop('org.test.vr')
api.assert_called_once_with('stop', '3346865537')
self.assertIn('podman stop -t 5 lepton-steamlaunch-2800000001', ssh.call_args.args[0])
class SteamAPITests(unittest.TestCase):
def test_artwork_api_enums_and_safe_serialization(self):
with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate:
shortcuts.configure(42, 'A "name"\n', '/path', '/start', '/icon', True,
{slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS})
js = evaluate.call_args.args[0]
self.assertIn('SetShortcutIsVR(id, true)', js)
self.assertIn('SetShortcutName(id, "A \\"name\\"\\n")', js)
self.assertIn('SetCustomArtworkForApp(id, data, ext, type)', js)
self.assertEqual(shortcuts.ASSETS, {'grid': 0, 'hero': 1, 'logo': 2, 'wide': 3, 'icon': 4})
self.assertIn('NewUnsavedCollection(name, undefined, [app])', js)
def test_remove_clears_every_slot_before_shortcut(self):
with patch.object(shortcuts, 'evaluate', return_value={}) as evaluate:
shortcuts.remove(42)
js = evaluate.call_args.args[0]
self.assertIn('[0, 1, 2, 3]', js)
self.assertLess(js.index('ClearCustomArtworkForApp(id, type)'), js.index('RemoveShortcut(id)'))
self.assertIn('const wanted = []', js)
def test_stop_uses_exact_64_bit_game_id_string(self):
with patch.object(sys, 'argv', ['steam_shortcuts.py', 'stop', '3346865537']), \
patch.object(shortcuts, 'evaluate') as evaluate, patch('builtins.print'):
shortcuts.main()
self.assertEqual(evaluate.call_args.args[0], 'SteamClient.Apps.TerminateApp("14374678025558032384", false)')
class SteamContextTests(unittest.TestCase):
@unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node')
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):
nonlocal steam
proc = subprocess.run(['node', str(ROOT / 'tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js')],
input=json.dumps({'id': 1, 'expression': expression, 'awaitPromise': True,
'steam': steam}) + '\n',
text=True, capture_output=True, timeout=10, check=True)
reply = json.loads(proc.stdout)
self.assertNotIn('exceptionDetails', reply['result'])
steam = reply['steam']
return reply['result']['result'].get('value')
with patch.object(shortcuts, 'evaluate', side_effect=evaluate):
result = shortcuts.configure(42, 'Game', '/exe', '/dir', '/icon', True,
{slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS})
self.assertEqual(result['warnings'], [])
self.assertTrue(steam['shortcuts'][0]['vr'])
self.assertEqual(set(steam['shortcuts'][0]['artwork']), {'0', '1', '2', '3'})
self.assertEqual(steam['collections'], [{'name': 'Android', 'apps': [999, 42]},
{'name': 'Android VR', 'apps': [42]}])
shortcuts.configure(42, 'Renamed', '/exe', '/dir', '/icon', False,
{slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS})
self.assertEqual(steam['shortcuts'][0]['name'], 'Renamed')
self.assertEqual(steam['collections'][1]['apps'], [])
shortcuts.remove(42)
self.assertEqual(steam['shortcuts'], [])
self.assertEqual(steam['collections'][0]['apps'], [999])
if __name__ == '__main__':
unittest.main()
+208
View File
@@ -0,0 +1,208 @@
"""Every public sideload entry point reaches the mandatory five-slot writer."""
import contextlib
import io
import json
import shutil
import subprocess
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'ui'))
import frame_android as android
import frame_artwork as artwork
import frame_catalog as catalog
import frame_apk_versions as versions
import frame_titles as titles
import frame_steamgriddb as sgdb
import server
import frame_webinstall as webinstall
class EntryPoints(unittest.TestCase):
def setUp(self):
self.stack = contextlib.ExitStack()
self.addCleanup(self.stack.close)
self.info = {'package':'org.example.game', 'label':'Example', 'version':'1', 'version_code':1,
'vr':False, 'repairable':False, 'launchable':True, 'min_sdk':24, 'abis':[], 'icon_png':None}
self.stack.enter_context(patch.object(android, 'apk_info', return_value=self.info))
self.stack.enter_context(patch.object(android, 'read_meta', return_value=None))
self.stack.enter_context(patch.object(android, '_copy'))
self.stack.enter_context(patch.object(android, 'ssh', return_value='/home/steamos'))
self.stack.enter_context(patch.object(artwork, 'prepare', return_value=({}, [])))
self.api = self.stack.enter_context(patch.object(android, 'shortcut_tool', side_effect=self.steam))
def steam(self, *args, **kwargs):
if args[0] == 'add': return '3346865537'
if args[0] == 'render': return json.dumps({'paths': {s:'/tmp/'+s+'.png' for s in artwork.SLOTS}})
if args[0] == 'list': return json.dumps([{'appid':3346865537,'name':'Example','devkit_gameid':'Example'}])
return '{"warnings":[]}'
def assert_art(self):
calls = [c.args for c in self.api.call_args_list]
self.assertEqual(sum(c[0] == 'render' for c in calls), 1)
configs = [c for c in calls if c[0] == 'configure']
self.assertEqual(len(configs), 1)
self.assertEqual(set(json.loads(configs[0][7])), set(artwork.SLOTS))
def test_cli_install(self):
with patch.object(sys, 'argv', ['frame_android.py', 'install', 'game.apk']), patch('builtins.print'):
android.main()
self.assert_art()
def test_file_upload(self):
class Request:
headers = {'X-Filename':'game.apk','X-Mode':'apk','Content-Length':'3'}
rfile = io.BytesIO(b'apk')
with patch.object(server, 'ensure_master'):
result = server.Handler.upload(Request())
self.assertEqual(result['app']['package'], self.info['package'])
self.assert_art()
def test_catalogue_install(self):
with patch.object(catalog, 'app', return_value={'r':'yes','t':False,'n':'Example'}), \
patch.object(catalog, 'fetch_apk', return_value='game.apk'), \
patch.object(catalog, 'fetch_icon', return_value=None):
catalog.install(self.info['package'])
self.assert_art()
def test_version_finder_install(self):
record = {'url':'https://example.org/app.apk','sha256':'fixture','version_code':1,'source':'F-Droid'}
with patch.object(versions, '_versions', return_value=([record], [])), \
patch.object(catalog, 'fetch_apk', return_value='game.apk'):
versions.install(self.info['package'], record['url'])
self.assert_art()
def test_web_download_install(self):
result = webinstall.dispatch('game.apk', source='https://example.org/game.apk')
self.assertEqual(result['kind'], 'apk')
self.assert_art()
def test_refresh_api(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)
def test_source_search_shared_installer_contract(self):
# Source workers hand their download and optional images to this public seam.
android.install('game.apk', name='Example', source='source-search', artwork={'banner': b'fixture'})
self.assert_art()
def test_native_title_install(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 'test -d' in cmd: return ''
if 'steamos-prepare-upload' in cmd: return '{"directory":"/home/steamos/devkit-game/Example"}'
if 'steam-client-create-shortcut' in cmd: return '{"success":"registered"}'
return ''
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):
result = titles._install(plan, lambda *args: None)
self.assertEqual(result['shortcut'], 3346865537)
self.assert_art()
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.
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)
def test_native_failure_removes_new_blank_shortcut(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 ''
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)
self.assertIn(('remove', '3346865537'), [c.args for c in self.api.call_args_list])
def test_refresh_does_not_reinstall_or_stop(self):
meta = {**self.info, 'instance':2800000001, 'shortcut':3346865537, 'flatscreen':False}
with patch.object(android, '_meta_or_fail', return_value=meta), \
patch.object(android, 'ssh', side_effect=lambda cmd, **kw: json.dumps({'icon_png':''}) if cmd == 'python3 -' else '/home/steamos'), \
patch.object(android, 'stop') as stop, patch.object(android, '_copy') as copy:
android.refresh_art(self.info['package'])
stop.assert_not_called(); copy.assert_not_called(); self.assert_art()
def test_all_refresh_reports_partial_failures(self):
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')):
result=android.refresh_art()
self.assertEqual(len(result),2)
self.assertTrue(all('error' in a for a in result))
def test_render_failure_cannot_report_success(self):
self.api.side_effect = lambda *args, **kw: '3346865537' if args[0]=='add' else '{"paths":{}}'
with self.assertRaisesRegex(android.FrameError,'every slot'):
android.install('game.apk')
class Renderer(unittest.TestCase):
@unittest.skipUnless(shutil.which('node'), 'Node is needed for the canvas contract fixture')
def test_canvas_slots_and_title_placement(self):
subprocess.run(['node', str(ROOT / 'tests/fixtures/library/check-renderer.js')],
cwd=str(ROOT), check=True, capture_output=True, timeout=30)
def test_desktop_packages_include_renderer_and_settings(self):
config = json.loads((ROOT / 'app/package.json').read_text())
resources = {r['from']: r['filter'] for r in config['build']['extraResources']}
self.assertIn('*.js', resources['../ui'])
self.assertIn('*.js', resources['../frame/android'])
class SteamGridDB(unittest.TestCase):
def test_no_key_is_silent_and_offline(self):
with patch.object(sgdb,'api_key',return_value=''), patch.object(sgdb,'_get') as get:
self.assertEqual(sgdb.lookup('Game'),({},[]))
get.assert_not_called()
def test_exact_match_and_top_votes_per_slot(self):
calls=[]
def get(path,key):
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)
return [{'url':'https://example.org/low.png','score':2,'width':dims[0],'height':dims[1]},
{'url':'https://example.org/top.png','score':20,'width':dims[0],'height':dims[1]},
{'url':'https://example.org/nsfw.png','score':99,'nsfw':True,'width':dims[0],'height':dims[1]}]
with patch.object(sgdb,'api_key',return_value='test-key'),patch.object(sgdb,'_get',side_effect=get):
images,warnings=sgdb.lookup('Game VR')
self.assertEqual(set(images),set(artwork.SLOTS));self.assertEqual(warnings,[])
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_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'),({},[]))
with patch.object(sgdb,'api_key',return_value='test-key'),patch.object(sgdb,'_get',side_effect=OSError('private-secret')):
result=sgdb.lookup('Game')
self.assertNotIn('private-secret',str(result));self.assertEqual(result[0],{})
def test_settings_never_return_key(self):
with tempfile.TemporaryDirectory() as root, patch.object(sgdb,'settings_path',return_value=Path(root)/'settings.json'), \
patch.dict(sgdb.os.environ,{},clear=True):
result=sgdb.save_settings({'steamgriddb_api_key':'secret-fixture'})
self.assertTrue(result['steamgriddb_configured'])
self.assertNotIn('secret-fixture',json.dumps(result))
self.assertEqual(sgdb.api_key(),'secret-fixture')
if sys.platform!='win32':self.assertEqual((Path(root)/'settings.json').stat().st_mode&0o777,0o600)
sgdb.save_settings({'steamgriddb_api_key':''})
self.assertFalse(sgdb.settings()['steamgriddb_configured'])
if __name__=='__main__':unittest.main()
+5 -1
View File
@@ -311,7 +311,11 @@ def install(source_id, entry_id, version_code=None, progress=None):
kwargs = {'name': entry.get('name'), 'icon_png': downloaded.get('icon_png') or entry.get('icon_png'),
'source': source['name']}
if 'artwork' in inspect.signature(frame_android.install).parameters:
kwargs['artwork'] = downloaded.get('artwork') or entry.get('artwork')
# The source's own image URLs (not the UI's /source-image/ proxy paths) become Steam library art.
images = entry.get('images') if isinstance(entry.get('images'), dict) else {}
art = {'icon': images.get('icon') or entry.get('icon'), 'banner': images.get('banner'),
'screenshots': [u for u in images.get('screenshots') or [] if u][:4]}
kwargs['artwork'] = downloaded.get('artwork') or {k: v for k, v in art.items() if v} or None
if progress:
progress('Installing', None)
from apk_sources import _web
+31
View File
@@ -0,0 +1,31 @@
(() => {
const get=id=>document.getElementById(id), status=get('steamGridStatus');
async function request(url,body) {
const r=await fetch(url,body===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const data=await r.json(); if(!r.ok) throw Error(data.error||'Request failed'); return data;
}
function show(data) {
status.textContent=data.environment?'SteamGridDB key is set by an environment variable.':
data.steamgriddb_configured?'SteamGridDB key saved.':'No key configured. Source images and generated art are enabled.';
}
async function save(value) {
try {show(await request('/api/settings/artwork',{steamgriddb_api_key:value}));get('steamGridKey').value='';}
catch(e) {status.textContent=e.message;}
}
get('saveSteamGridKey').onclick=()=>save(get('steamGridKey').value.trim());
get('clearSteamGridKey').onclick=()=>save('');
get('refreshAndroidArt').onclick=async()=>{
const button=get('refreshAndroidArt');button.disabled=true;
try {
const result=await runJob('Refresh Android artwork','android-artwork',()=>request('/api/android',{action:'refresh-art',all:true}));
if (result) {
const apps=Array.isArray(result.apps)?result.apps:[result.apps];
const failed=apps.filter(a=>a.error);
status.textContent=failed.length?`${failed.length} refresh failed: ${failed.map(a=>a.package+': '+a.error).join('; ')}`:
`Refreshed artwork for ${apps.length} apps.`;
}
} catch(e) {status.textContent=e.message;}
finally {button.disabled=false;}
};
request('/api/settings/artwork').then(show).catch(e=>{status.textContent=e.message;});
})();
+142 -18
View File
@@ -9,11 +9,12 @@ Lepton Development, which wipes its apps on exit. See docs/apks.md.
Python stdlib only. CLI: python3 ui/frame_android.py
install APK [--vr|--flat] [--no-xr-compat] | info APK | versions APK-or-PKG
install-obb PKG OBB [OBB ...] | backup-data PKG ARCHIVE | restore-data PKG ARCHIVE
patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG
refresh-art PKG|--all | patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG
"""
import json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
import frame_apk
import frame_artwork
import frame_host
import tempfile
import zipfile
@@ -57,8 +58,12 @@ def ssh(cmd, input=None, timeout=120):
def shortcut_tool(*args, timeout=60):
with open(SHORTCUTS) as f:
return ssh('python3 - ' + ' '.join(shlex.quote(a) for a in args), input=f.read(),
timeout=timeout).strip()
script = f.read()
if args and args[0] == 'render':
with open(os.path.join(ROOT, 'frame/android/library_artwork.js')) as f:
script = 'ART_RENDERER = ' + repr(f.read()) + '\n' + script
return ssh('python3 - ' + ' '.join(shlex.quote(a) for a in args), input=script,
timeout=timeout).strip()
def instance_id(pkg):
@@ -136,7 +141,7 @@ def _write_meta(d, meta):
ssh(f'cat > {d}/meta.json.tmp && mv {d}/meta.json.tmp {d}/meta.json', input=json.dumps(meta, indent=1))
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None):
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None, artwork=None):
info = apk_info(apk_path)
if icon_png:
info['icon_png'] = icon_png
@@ -155,43 +160,57 @@ def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr
patched = os.path.join(tmp, 'app.apk')
info['patched'] = patch(apk_path, patched, add)['patched']
info['launchable'] = True
return _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path))
return _install(apk_path, info, pkg, flatscreen, name, source)
return _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path), artwork)
return _install(apk_path, info, pkg, flatscreen, name, source, artwork)
def _install(apk_path, info, pkg, flatscreen, name, source):
def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
try:
images, art_warnings = frame_artwork.prepare(name or info['label'], info.get('icon_png'), artwork)
except (ValueError, OSError) as e:
raise FrameError(f'could not prepare artwork: {e}') from e
iid = instance_id(pkg)
d = f'{APPS_DIR}/{pkg}'
existing = read_meta(pkg)
ok = False
ok, created = False, None
try:
ssh(f'mkdir -p {d}')
_copy(apk_path, f'{d}/app.apk.part')
_copy(LAUNCHER, f'{d}/launch.sh', executable=True, timeout=120)
icon = ''
if info['icon_png']:
ssh(f'cat > {d}/icon.png', input=info['icon_png'])
icon = f'$HOME/{d}/icon.png'
marker = f'touch {d}/lepton-show-flatscreen' if flatscreen else f'rm -f {d}/lepton-show-flatscreen'
ssh(f'mv {d}/app.apk.part {d}/app.apk && echo {iid} > {d}/instance.id && {marker}')
home = ssh('echo $HOME').strip()
shortcut = _int((existing or {}).get('shortcut'))
if not shortcut or shortcut not in _shortcut_ids():
reply = shortcut_tool('add', name or info['label'], f'{home}/{d}/launch.sh', f'{home}/{d}',
icon.replace('$HOME', home))
'')
shortcut = _int(reply.strip().splitlines()[-1] if reply.strip() else None)
if not shortcut:
raise FrameError(f'Steam did not return a shortcut id (got {reply[:80]!r})')
created = shortcut
presentation = apply_library(shortcut, name or info['label'], d, images,
vr=not flatscreen, home=home,
exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}',
details={'package': pkg, 'version': info['version'],
'source': source or os.path.basename(apk_path)})
presentation['warnings'] = art_warnings + presentation.get('warnings', [])
meta = {'package': pkg, 'label': name or info['label'], 'version': info['version'],
'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut),
'vr': info.get('vr', False), 'vr_issues': info.get('vr_issues', []),
'launchable': info.get('launchable', False), 'patched': info.get('patched', []),
'flatscreen': flatscreen, 'installed': time.strftime('%Y-%m-%dT%H:%M:%S'),
'source': source or os.path.basename(apk_path)}
'source': source or os.path.basename(apk_path),
'library_warnings': presentation.get('warnings', []),
'artwork': presentation.get('artwork', {}), 'library_version': 2}
_write_meta(d, meta)
ok = True
return meta
finally:
if not ok and created:
try:
shortcut_tool('remove', str(created))
except FrameError:
pass
if not ok and not existing:
# A first install that failed part-way: don't leave an orphan folder behind.
try:
@@ -200,6 +219,101 @@ def _install(apk_path, info, pkg, flatscreen, name, source):
pass
def apply_library(shortcut, label, directory, images, vr=False, home=None, exe='', start_dir='', details=None,
category='Android'):
"""Mandatory for every sideload: render all five slots before reporting success."""
home = home or ssh('echo $HOME').strip()
d = directory
ssh(f'mkdir -p {shlex.quote(d)}/artwork')
paths = {}
for slot, (ext, data) in images.items():
path = f'{d}/artwork/source-{slot}.{ext}'
ssh(f'cat > {shlex.quote(path)}', input=data)
paths[slot] = f'{home}/{path}' if not path.startswith('/') else path
manifest = {'label': label, 'images': paths}
plan = f'{d}/artwork/input.json'
ssh(f'cat > {shlex.quote(plan)}', input=json.dumps(manifest))
absolute = f'{home}/{plan}' if not plan.startswith('/') else plan
rendered = json.loads(shortcut_tool('render', absolute, timeout=120))
art = rendered['paths']
if set(art) != set(frame_artwork.SLOTS):
raise FrameError('Steam artwork renderer did not produce every slot')
result = json.loads(shortcut_tool('configure', str(shortcut), label, exe, start_dir, art['icon'],
'1' if vr else '0', json.dumps(art),
json.dumps({'category': category, 'details': details or {}}), timeout=120))
result['warnings'] = rendered.get('warnings', []) + result.get('warnings', [])
result['artwork'] = art
if category == 'Android':
ssh(f'cat > {shlex.quote(d)}/shortcut.id', input=str(int(shortcut)))
return result
def refresh_art(pkg=None, artwork=None):
"""Refresh existing APK library entries without reinstalling or stopping them."""
if pkg is None:
results = []
for app in list_apps():
try:
results.append(refresh_art(app['package'], artwork))
except (FrameError, OSError, ValueError) as e:
results.append({'package': app['package'], 'error': str(e)})
return results
with _install_lock:
m = _meta_or_fail(pkg)
d = f'{APPS_DIR}/{pkg}'
# Parse the APK on the Frame, transferring only its icon/label, not the APK.
modules = {}
for module in ('frame_apk', 'frame_apk_vr'):
with open(os.path.join(ROOT, 'ui', module + '.py')) as f:
modules[module] = f.read()
script = 'import sys, types, json, base64\n'
for module, source in modules.items():
script += f'm = types.ModuleType({module!r}); sys.modules[{module!r}] = m; exec({source!r}, m.__dict__)\n'
script += f"info = sys.modules['frame_apk'].apk_info({(d + '/app.apk')!r})\n"
script += "info['icon_png'] = base64.b64encode(info.get('icon_png') or b'').decode()\n"
script += "import os\ninfo['artwork'] = {}\n"
script += f"directory = os.path.realpath({d!r})\n"
script += """try:
with open(os.path.join(directory, 'artwork/input.json')) as f:
cached = json.load(f)
for slot, path in cached.get('images', {}).items():
if os.path.commonpath([os.path.realpath(path), directory]) != directory:
continue
with open(path, 'rb') as f:
data = f.read(12 * 1024 * 1024 + 1)
if len(data) <= 12 * 1024 * 1024:
info['artwork'][slot] = base64.b64encode(data).decode()
except (OSError, ValueError, TypeError):
pass
print(json.dumps(info))
"""
info = json.loads(ssh('python3 -', input=script))
import base64
icon = base64.b64decode(info['icon_png'])
cached = {k: base64.b64decode(v) for k, v in info.get('artwork', {}).items()}
images, warnings = frame_artwork.prepare(m['label'], icon, artwork if artwork is not None else cached)
home = ssh('echo $HOME').strip()
created = False
if not m['shortcut'] or m['shortcut'] not in _shortcut_ids():
m['shortcut'] = _int(shortcut_tool('add', m['label'], f'{home}/{d}/launch.sh', f'{home}/{d}'))
if not m['shortcut']:
raise FrameError('Steam did not create the missing shortcut')
m['game_id'] = game_id(m['shortcut'])
created = True
try:
result = apply_library(m['shortcut'], m['label'], d, images, vr=not m.get('flatscreen', True),
home=home, exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}', details=m)
except Exception:
if created:
shortcut_tool('remove', str(m['shortcut']))
raise
m.update(artwork=result.get('artwork', {}), library_version=2)
m['library_warnings'] = warnings + result.get('warnings', [])
m['artwork_refreshed'] = time.strftime('%Y-%m-%dT%H:%M:%S')
_write_meta(d, m)
return m
def _int(v):
try:
n = int(v)
@@ -271,6 +385,11 @@ def launch(pkg):
def stop(pkg):
m = _meta_or_fail(pkg)
if m['shortcut']:
try:
shortcut_tool('stop', str(int(m['shortcut'])))
except FrameError:
pass # Steam unavailable: the container stop also ends its waiting launcher.
ssh(f"podman stop -t 5 lepton-steamlaunch-{int(m['instance'])} >/dev/null 2>&1 || true", timeout=60)
return m
@@ -279,10 +398,8 @@ def remove(pkg, keep_data=False):
m = _meta_or_fail(pkg)
stop(pkg)
if m['shortcut']:
try:
shortcut_tool('remove', str(int(m['shortcut'])))
except FrameError:
pass # already gone from Steam
result = json.loads(shortcut_tool('remove', str(int(m['shortcut']))) or '{}')
m['library_warnings'] = result.get('warnings', [])
iid = int(m['instance'])
extra = '' if keep_data else f' {COMPAT}/{iid} {SHADERS}/{iid}'
ssh(f'rm -rf {APPS_DIR}/{pkg}{extra}')
@@ -369,6 +486,13 @@ def main():
elif cmd == 'install':
r = install(args[0], flatscreen=False if '--vr' in args else True if '--flat' in args else None,
xr_compat=False if '--no-xr-compat' in args else None)
elif cmd == 'refresh-art':
if len(args) != 1:
raise FrameError('usage: refresh-art PACKAGE or refresh-art --all')
r = refresh_art(None if args[0] == '--all' else args[0])
if isinstance(r, list) and any('error' in item for item in r):
print(json.dumps(r, indent=1))
raise SystemExit(1)
elif cmd == 'patch':
import argparse
parser = argparse.ArgumentParser(description='Patch and v2-sign an APK locally')
+4 -1
View File
@@ -255,7 +255,10 @@ def _icon_png(z, names, icons):
# Adaptive icons are XML; fall back to the largest launcher PNG.
pngs = sorted((n for n in names if n.endswith('.png') and 'ic_launcher' in n and 'foreground' not in n),
key=lambda n: z.getinfo(n).file_size)
return _read(z, pngs[-1], MAX_ICON) if pngs else None
if pngs:
return _read(z, pngs[-1], MAX_ICON)
# Godot exports can keep only an adaptive launcher plus the project icon.
return _read(z, 'assets/icon.png', MAX_ICON) if 'assets/icon.png' in names else None
if __name__ == '__main__':
+193
View File
@@ -0,0 +1,193 @@
"""Bounded artwork inputs for Steam library canvas rendering."""
import struct
import urllib.parse
import urllib.request
import zlib
SLOTS = {'grid': (600, 900), 'wide': (920, 430), 'hero': (3840, 1240),
'logo': (1280, 480), 'icon': (256, 256)}
MAX_IMAGE = 12 * 1024 * 1024
MAX_PIXELS = 8_000_000
PNG = b'\x89PNG\r\n\x1a\n'
def chunk(kind, data):
return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data))
def png(width, height, pixels):
stride = width * 4
raw = b''.join(b'\0' + pixels[y * stride:(y + 1) * stride] for y in range(height))
return PNG + chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 6, 0, 0, 0)) + \
chunk(b'IDAT', zlib.compress(raw, 6)) + chunk(b'IEND', b'')
def decode(data):
"""Non-interlaced PNG, including packed palette/grayscale APK icons."""
if not isinstance(data, bytes) or not data.startswith(PNG) or len(data) > MAX_IMAGE:
raise ValueError('expected a PNG image (at most 12 MiB)')
pos, packed, palette, alpha, header = 8, bytearray(), b'', b'', None
while pos + 12 <= len(data):
size = struct.unpack_from('>I', data, pos)[0]
kind, body = data[pos + 4:pos + 8], data[pos + 8:pos + 8 + size]
if pos + size + 12 > len(data):
raise ValueError('truncated PNG')
crc = struct.unpack_from('>I', data, pos + 8 + size)[0]
if zlib.crc32(kind + body) != crc:
raise ValueError('invalid PNG checksum')
if kind == b'IHDR':
if header is not None or size != 13:
raise ValueError('invalid PNG header')
header = struct.unpack('>IIBBBBB', body)
elif kind == b'PLTE':
palette = body
elif kind == b'tRNS':
alpha = body
elif kind == b'IDAT':
packed.extend(body)
elif kind == b'IEND':
break
pos += size + 12
else:
raise ValueError('incomplete PNG')
if header is None:
raise ValueError('missing PNG header')
w, h, depth, color, compression, filtering, interlace = header
channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}.get(color)
if not w or not h or w * h > MAX_PIXELS or w > 8192 or h > 8192:
raise ValueError('PNG dimensions exceed limits')
if not channels or compression or filtering or interlace or depth not in (1, 2, 4, 8) or (depth != 8 and color not in (0, 3)):
raise ValueError('unsupported PNG encoding')
stride, bpp = (w * channels * depth + 7) // 8, max(1, channels * depth // 8)
expected = h * (stride + 1)
decoder = zlib.decompressobj()
raw = decoder.decompress(bytes(packed), expected + 1)
if len(raw) != expected or not decoder.eof:
raise ValueError('invalid PNG pixels')
pixels, prev = bytearray(), bytearray(stride)
for y in range(h):
start = y * (stride + 1)
method, row = raw[start], bytearray(raw[start + 1:start + 1 + stride])
if method > 4:
raise ValueError('invalid PNG filter')
for x in range(stride):
a, b, c = row[x - bpp] if x >= bpp else 0, prev[x], prev[x - bpp] if x >= bpp else 0
if method == 1:
row[x] = (row[x] + a) & 255
elif method == 2:
row[x] = (row[x] + b) & 255
elif method == 3:
row[x] = (row[x] + (a + b) // 2) & 255
elif method == 4:
p = a + b - c
distances = (abs(p - a), abs(p - b), abs(p - c))
row[x] = (row[x] + (a, b, c)[distances.index(min(distances))]) & 255
for x in range(w):
if depth < 8:
value = (row[x * depth // 8] >> (8 - depth - x * depth % 8)) & ((1 << depth) - 1)
values = [value]
else:
values = row[x * channels:(x + 1) * channels]
if color == 3:
i = values[0]
if i * 3 + 3 > len(palette):
raise ValueError('invalid PNG palette')
rgba = palette[i * 3:i * 3 + 3] + bytes([alpha[i] if i < len(alpha) else 255])
elif color in (0, 4):
gray = values[0] * 255 // ((1 << depth) - 1)
opacity = values[1] if color == 4 else (0 if alpha == struct.pack('>H', values[0]) else 255)
rgba = bytes([gray, gray, gray, opacity])
else:
opacity = values[3] if color == 6 else (0 if alpha == struct.pack('>HHH', *values) else 255)
rgba = bytes(values[:3]) + bytes([opacity])
pixels.extend(rgba)
prev = row
return w, h, pixels
def image_type(data):
if not isinstance(data, bytes) or len(data) > MAX_IMAGE:
raise ValueError('artwork must be image bytes or an HTTP(S) URL, at most 12 MiB')
if data.startswith(PNG):
try:
decode(data)
except (zlib.error, struct.error) as e:
raise ValueError('invalid PNG image') from e
return 'png'
if data.startswith(b'\xff\xd8') and data.endswith(b'\xff\xd9'):
# Check JPEG SOF dimensions without depending on an image library.
pos = 2
while pos + 4 <= len(data) and data[pos] == 255:
marker = data[pos + 1]
pos += 2
if marker == 255:
pos -= 1
continue
size = struct.unpack_from('>H', data, pos)[0]
if size < 2 or pos + size > len(data):
break
if marker in (0xc0, 0xc1, 0xc2) and size >= 8:
h, w = struct.unpack_from('>HH', data, pos + 3)
if w and h and w * h <= MAX_PIXELS and max(w, h) <= 8192:
return 'jpg'
break
pos += size
raise ValueError('artwork must be a supported PNG or JPEG')
def stamp(pixels, width, icon, x, y, size):
iw, ih, source = icon
dw, dh = max(1, size * iw // max(iw, ih)), max(1, size * ih // max(iw, ih))
x, y = x + (size - dw) // 2, y + (size - dh) // 2
for yy in range(dh):
for xx in range(dw):
src = ((yy * ih // dh) * iw + xx * iw // dw) * 4
dst = ((y + yy) * width + x + xx) * 4
a = source[src + 3]
for c in range(3):
pixels[dst + c] = (source[src + c] * a + pixels[dst + c] * (255 - a)) // 255
pixels[dst + 3] = a + pixels[dst + 3] * (255 - a) // 255
def fetch(value):
if isinstance(value, str):
if urllib.parse.urlsplit(value).scheme not in ('http', 'https'):
raise ValueError('artwork URLs must use HTTP(S)')
request = urllib.request.Request(value, headers={'User-Agent': 'FrameControl/1.0'})
with urllib.request.urlopen(request, timeout=20) as response:
if urllib.parse.urlsplit(response.geturl()).scheme not in ('http', 'https'):
raise ValueError('artwork redirect must use HTTP(S)')
value = response.read(MAX_IMAGE + 1)
return image_type(value), value
def prepare(label, icon_png=None, artwork=None):
"""Gather inputs; the Frame's Chromium canvas renders every final slot."""
import frame_steamgriddb
artwork = artwork or {}
allowed = set(SLOTS) | {'banner', 'feature_graphic', 'screenshots', 'screenshot'}
if not isinstance(artwork, dict) or set(artwork) - allowed:
raise ValueError('unknown artwork slot')
supplied, warnings = {}, []
for slot, value in artwork.items():
values = value if slot == 'screenshots' and isinstance(value, (list, tuple)) else [value]
for candidate in values[:4]:
try:
supplied['screenshot' if slot == 'screenshots' else slot] = fetch(candidate)
break
except (ValueError, OSError):
warnings.append('Source ' + slot + ' unavailable; using fallback art')
if 'icon' not in supplied and icon_png:
try:
supplied['icon'] = fetch(icon_png)
except (ValueError, OSError):
pass
provider, provider_warnings = frame_steamgriddb.lookup(label)
warnings.extend(provider_warnings)
for slot, value in provider.items():
try:
supplied[slot] = fetch(value)
except (ValueError, OSError):
warnings.append('SteamGridDB ' + slot + ' download failed; using fallback art')
return supplied, warnings
+103
View File
@@ -0,0 +1,103 @@
"""Optional SteamGridDB artwork. Credentials stay on the host, never in app metadata."""
import json
import os
import re
import tempfile
import urllib.parse
import urllib.request
import frame_host
API = 'https://www.steamgriddb.com/api/v2'
MAX_JSON = 2 * 1024 * 1024
def settings_path():
return frame_host.data_dir('artwork-settings.json')
def api_key():
env = os.environ.get('STEAMGRIDDB_API_KEY') or os.environ.get('FRAME_STEAMGRIDDB_API_KEY')
if env:
return env.strip()
try:
return str(json.loads(settings_path().read_text()).get('steamgriddb_api_key') or '')
except (OSError, ValueError, AttributeError):
return ''
def settings():
return {'steamgriddb_configured': bool(api_key()),
'environment': bool(os.environ.get('STEAMGRIDDB_API_KEY') or os.environ.get('FRAME_STEAMGRIDDB_API_KEY'))}
def save_settings(body):
key = body.get('steamgriddb_api_key')
if not isinstance(key, str) or len(key) > 200 or (key and not re.fullmatch(r'[A-Za-z0-9_-]+', key)):
raise ValueError('enter a valid SteamGridDB API key, or an empty value to remove it')
path = settings_path()
path.parent.mkdir(parents=True, exist_ok=True)
fd, temp = tempfile.mkstemp(prefix='.artwork-', dir=str(path.parent))
try:
with os.fdopen(fd, 'w') as f:
json.dump({'steamgriddb_api_key': key}, f)
os.replace(temp, path)
finally:
if os.path.exists(temp):
os.remove(temp)
return settings()
def _get(path, key):
request = urllib.request.Request(API + path, headers={'Authorization': 'Bearer ' + key,
'User-Agent': 'FrameControl/1.0'})
# Do not carry the credential to redirects or include it in error messages.
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
with urllib.request.build_opener(NoRedirect()).open(request, timeout=12) as response:
data = response.read(MAX_JSON + 1)
if len(data) > MAX_JSON:
raise ValueError('SteamGridDB response too large')
result = json.loads(data)
if not result.get('success') or not isinstance(result.get('data'), list):
raise ValueError('SteamGridDB lookup failed')
return result['data']
def _name(value):
return re.sub(r'[^a-z0-9]', '', str(value).casefold())
def lookup(name):
"""Best-voted art per slot for an exact title match; unrelated games are never guessed."""
key = api_key()
if not key:
return {}, []
try:
matches = _get('/search/autocomplete/' + urllib.parse.quote(name, safe=''), key)
names = {_name(name), _name(re.sub(r'\s+VR$', '', name, flags=re.I))}
game = next((g for g in matches if _name(g.get('name')) in names), None)
if not game:
return {}, []
gid = int(game['id'])
result, warnings = {}, []
for slot, kind, dimensions in [('grid', 'grids', '600x900'), ('wide', 'grids', '920x430'),
('hero', 'heroes', ''), ('logo', 'logos', ''), ('icon', 'icons', '')]:
try:
query = {'types': 'static', 'nsfw': 'false', 'humor': 'false', 'mimes': 'image/png,image/jpeg'}
if dimensions:
query['dimensions'] = dimensions
records = _get('/' + kind + '/game/' + str(gid) + '?' + urllib.parse.urlencode(query), key)
records = [r for r in records if r.get('url', '').startswith('https://') and not r.get('nsfw')]
if dimensions:
w, h = map(int, dimensions.split('x'))
records = [r for r in records if (r.get('width'), r.get('height')) == (w, h)]
records.sort(key=lambda r: (int(r.get('score') or 0), int(r.get('upvotes') or 0)), reverse=True)
if records:
result[slot] = records[0]['url']
except (OSError, ValueError, KeyError, TypeError):
warnings.append('SteamGridDB ' + slot + ' unavailable; using source or generated art')
return result, warnings
except (OSError, ValueError, KeyError, TypeError):
return {}, ['SteamGridDB unavailable; using source or generated art']
+50 -5
View File
@@ -632,7 +632,7 @@ def _rsync():
_install_lock = threading.Lock()
def install(path, name=None, exe=None, runtime=None, progress=None):
def install(path, name=None, exe=None, runtime=None, progress=None, artwork=None):
"""Sideload a .zip, folder or executable as a Devkit Game; returns the title dict.
name: the Steam name (sanitised to the title id), default from the file name.
@@ -643,12 +643,12 @@ def install(path, name=None, exe=None, runtime=None, progress=None):
"""
plan = inspect(path, name)
try:
return install_plan(plan, name=name, exe=exe, runtime=runtime, progress=progress)
return install_plan(plan, name=name, exe=exe, runtime=runtime, progress=progress, artwork=artwork)
finally:
discard(plan)
def install_plan(plan, name=None, exe=None, runtime=None, progress=None):
def install_plan(plan, name=None, exe=None, runtime=None, progress=None, artwork=None):
"""Install an inspect() plan, optionally with another name, target or runtime."""
if name:
plan['name'], plan['id'] = name, title_id(name)
@@ -656,13 +656,21 @@ def install_plan(plan, name=None, exe=None, runtime=None, progress=None):
_choose(plan, exe or plan['target'], runtime)
step = progress or (lambda *a: None)
with _install_lock:
return _install(plan, step)
return _install(plan, step, artwork)
def _install(plan, step):
def _install(plan, step, artwork=None):
gid = plan['id']
if not NEW_ID_RE.match(gid) or gid.lower() in RESERVED_IDS:
raise FrameError(f'bad title id {gid!r}')
icon = None
for filename in ('icon.png', 'logo.png'):
path = os.path.join(plan['root'], filename)
if os.path.isfile(path):
with open(path, 'rb') as f:
icon = f.read(frame_android.frame_artwork.MAX_IMAGE + 1)
break
images, warnings = frame_android.frame_artwork.prepare(plan['name'], icon, artwork)
step("Syncing Valve's devkit tools to the Frame", 0.02)
ensure_utils()
existed = ssh(f'test -d {GAMES}/{gid} && echo yes || true', timeout=30).strip() == 'yes'
@@ -672,6 +680,7 @@ def _install(plan, step):
if not DIR_RE.match(directory) or not directory.endswith(f'/{GAMES}/{gid}'):
raise FrameError(f'steamos-prepare-upload returned an unexpected folder {directory!r}')
registered = False
library_ready, shortcut, steam_registered = False, None, False
try:
step(f"Copying {plan['size'] / 1e6:.0f} MB to the Frame", 0.1)
if _rsync():
@@ -694,10 +703,26 @@ def _install(plan, step):
err = str(reply['error']).strip().rstrip('.')
hint = ' With Steam running on the Frame, install it again.' if 'not running' in err else ''
raise FrameError(f"Uploaded, but Steam didn't register it: {err}.{hint}")
steam_registered = True
shortcut = _library_shortcut(gid, plan['name'])
if not shortcut:
raise FrameError('Steam registered the title but its shortcut is not available for mandatory artwork; retry install')
result = frame_android.apply_library(shortcut, plan['name'], directory + '/.frame-artwork', images,
category='Sideloaded', details={'source': plan['source']})
meta.update(shortcut=shortcut, library_warnings=warnings + result.get('warnings', []))
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
library_ready = True
step('Done', 1.0)
meta.update(runtime_label=RUNTIMES[plan['runtime']]['label'], steam=str(reply.get('success', '')).strip())
return meta
finally:
if steam_registered and not library_ready and not existed:
# A newly registered title must not remain as a blank library tile.
if shortcut:
frame_android.shortcut_tool('remove', str(shortcut))
else:
ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120)
registered = False
if not registered and not existed:
# A first install that failed part-way: don't leave an orphan folder behind.
try:
@@ -706,6 +731,23 @@ def _install(plan, step):
pass
def _library_shortcut(gid, name):
shortcuts = json.loads(frame_android.shortcut_tool('list'))
matches = [s for s in shortcuts if s.get('devkit_gameid') == gid]
if not matches:
try:
meta = json.loads(ssh(f'cat {GAMES}/{gid}-framecontrol.json 2>/dev/null || true'))
matches = [s for s in shortcuts if s.get('appid') == meta.get('shortcut')]
except (ValueError, AttributeError):
pass
if not matches:
matches = [s for s in shortcuts if s.get('name') in (gid, name)]
if len(matches) > 1:
raise FrameError('ambiguous Steam shortcut for ' + gid)
return int(matches[0]['appid']) if matches else None
LIST_SCRIPT = r'''
import json, os
root = os.path.expanduser('~/devkit-game')
@@ -772,6 +814,9 @@ def remove(gid):
try:
gid = _check_id(gid)
ensure_utils()
shortcut = _library_shortcut(gid, gid)
if shortcut:
frame_android.shortcut_tool('remove', str(shortcut))
# steamos-delete removes the folder and syncs Steam's shortcuts; its json files stay, so clear them too.
ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120)
ssh(f'rm -f {_json_files(gid)}', timeout=30)
+14
View File
@@ -784,6 +784,19 @@
</section>
<section class="panel" id="artworkSettings">
<div class="shelf-head"><h2>Library artwork settings</h2></div>
<p class="sub">SteamGridDB supplies community artwork for sideloaded games. Without a key, Frame Control uses source images and creates the remaining art.</p>
<label for="steamGridKey">SteamGridDB API key</label>
<input type="password" id="steamGridKey" autocomplete="off" spellcheck="false" placeholder="Optional API key">
<div class="row" style="margin-top:8px">
<button id="saveSteamGridKey" class="action small">Save key</button>
<button id="clearSteamGridKey" class="small">Remove saved key</button>
<a href="https://www.steamgriddb.com/profile/preferences/api" target="_blank" rel="noreferrer">Get a free key</a>
</div>
<div id="steamGridStatus" class="hint" role="status"></div>
<button id="refreshAndroidArt" class="small" style="margin-top:12px">Refresh installed Android artwork</button>
</section>
<section class="panel" id="power">
<div class="shelf-head"><h2>Remote &amp; power</h2></div>
<div class="actions">
@@ -2625,5 +2638,6 @@ setView("headset");
refresh().then(loadShots); // after status, so app names resolve
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
</script>
<script src="/artwork-settings.js"></script>
</body>
</html>
+13 -1
View File
@@ -40,6 +40,7 @@ import frame_android # noqa: E402
from apk_sources import search as apk_search, SourceError # noqa: E402
import frame_apk_versions # noqa: E402
import frame_catalog # noqa: E402
import frame_steamgriddb
import frame_host # noqa: E402
import frame_store # noqa: E402
import frame_titles # noqa: E402
@@ -595,6 +596,11 @@ def android(body):
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.",
"app": m}
return start_job(f"Install {pkg}", work)
if action == "refresh-art":
if not pkg and not body.get("all"):
raise Failure('choose a package or all apps', 400)
return start_job('Refresh Steam artwork', lambda: {
'apps': frame_android.refresh_art(None if body.get('all') else pkg)})
if action in ("launch", "stop"):
m = getattr(frame_android, action)(pkg)
return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"}
@@ -1305,7 +1311,9 @@ def source_manage(body):
raise Failure(str(e), 400)
POST = {"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
POST = {
"/api/settings/artwork": frame_steamgriddb.save_settings,
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
"/api/webinstall/cancel": webinstall_cancel}
@@ -1409,6 +1417,10 @@ class Handler(BaseHTTPRequestHandler):
try:
if path in ("/", "/index.html"):
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/api/settings/artwork":
self.send_json(frame_steamgriddb.settings())
elif path == "/artwork-settings.js":
self.send_bytes((HERE / 'artwork-settings.js').read_bytes(), 'text/javascript; charset=utf-8')
elif path == "/api/host":
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,