diff --git a/.claude/NOTES-vr-library.md b/.claude/NOTES-vr-library.md new file mode 100644 index 0000000..655ce7c --- /dev/null +++ b/.claude/NOTES-vr-library.md @@ -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. diff --git a/app/package.json b/app/package.json index fdd1a6f..40cba6c 100644 --- a/app/package.json +++ b/app/package.json @@ -46,7 +46,8 @@ "to": "ui", "filter": [ "*.py", - "*.html" + "*.html", + "*.js" ] }, { @@ -69,7 +70,8 @@ "to": "frame/android", "filter": [ "*.sh", - "*.py" + "*.py", + "*.js" ] }, { diff --git a/docs/vr-apks.md b/docs/vr-apks.md index e57325c..c8b5ff5 100644 --- a/docs/vr-apks.md +++ b/docs/vr-apks.md @@ -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 diff --git a/frame/android/lepton-app.sh b/frame/android/lepton-app.sh index 63b0572..119850b 100644 --- a/frame/android/lepton-app.sh +++ b/frame/android/lepton-app.sh @@ -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" diff --git a/frame/android/library_artwork.js b/frame/android/library_artwork.js new file mode 100644 index 0000000..9680dc5 --- /dev/null +++ b/frame/android/library_artwork.js @@ -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;q24)continue; + d[i*4+3]=0; + if(i%w)queue.push(i-1);if(i%w=w)queue.push(i-w);if(i220 || 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}; +} diff --git a/frame/android/steam_shortcuts.py b/frame/android/steam_shortcuts.py index 6aea8de..79fbb1f 100644 --- a/frame/android/steam_shortcuts.py +++ b/frame/android/steam_shortcuts.py @@ -5,9 +5,11 @@ Python stdlib only; the Mac runs it with `ssh frame python3 - < 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__) diff --git a/tests/fakeframe/rootfs/usr/local/bin/podman b/tests/fakeframe/rootfs/usr/local/bin/podman index a996bf3..9c10c24 100755 --- a/tests/fakeframe/rootfs/usr/local/bin/podman +++ b/tests/fakeframe/rootfs/usr/local/bin/podman @@ -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) diff --git a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub.js b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub.js new file mode 100644 index 0000000..ac21da6 --- /dev/null +++ b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub.js @@ -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}; diff --git a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js index e50f3cf..e0c2a52 100644 --- a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js +++ b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js @@ -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)]; diff --git a/tests/fixtures/library/README.md b/tests/fixtures/library/README.md new file mode 100644 index 0000000..04d4f03 --- /dev/null +++ b/tests/fixtures/library/README.md @@ -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. diff --git a/tests/fixtures/library/check-renderer.js b/tests/fixtures/library/check-renderer.js new file mode 100644 index 0000000..83490f4 --- /dev/null +++ b/tests/fixtures/library/check-renderer.js @@ -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)}); diff --git a/tests/fixtures/library/icon.jpg b/tests/fixtures/library/icon.jpg new file mode 100644 index 0000000..ea4d67c Binary files /dev/null and b/tests/fixtures/library/icon.jpg differ diff --git a/tests/fixtures/library/icon.png b/tests/fixtures/library/icon.png new file mode 100644 index 0000000..2e2af85 Binary files /dev/null and b/tests/fixtures/library/icon.png differ diff --git a/tests/fixtures/library/steam-responses.json b/tests/fixtures/library/steam-responses.json new file mode 100644 index 0000000..8b2c2c7 --- /dev/null +++ b/tests/fixtures/library/steam-responses.json @@ -0,0 +1,12 @@ +{ + "home": "/home/steamos", + "shortcuts": [ + { + "appid": 3346865537, + "name": "Open Saber Plus" + } + ], + "configure": { + "warnings": [] + } +} diff --git a/tests/test_apk_search.py b/tests/test_apk_search.py index cecba67..20cd57e 100644 --- a/tests/test_apk_search.py +++ b/tests/test_apk_search.py @@ -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')]), \ diff --git a/tests/test_frame_android_library.py b/tests/test_frame_android_library.py new file mode 100644 index 0000000..dea31a6 --- /dev/null +++ b/tests/test_frame_android_library.py @@ -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() diff --git a/tests/test_library_entrypoints.py b/tests/test_library_entrypoints.py new file mode 100644 index 0000000..e49104b --- /dev/null +++ b/tests/test_library_entrypoints.py @@ -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() diff --git a/ui/apk_sources/search.py b/ui/apk_sources/search.py index 324fee5..d11ea6d 100644 --- a/ui/apk_sources/search.py +++ b/ui/apk_sources/search.py @@ -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 diff --git a/ui/artwork-settings.js b/ui/artwork-settings.js new file mode 100644 index 0000000..5d815fd --- /dev/null +++ b/ui/artwork-settings.js @@ -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;}); +})(); diff --git a/ui/frame_android.py b/ui/frame_android.py index 2aeec2e..e029644 100644 --- a/ui/frame_android.py +++ b/ui/frame_android.py @@ -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') diff --git a/ui/frame_apk.py b/ui/frame_apk.py index 7b7a9ff..2212803 100644 --- a/ui/frame_apk.py +++ b/ui/frame_apk.py @@ -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__': diff --git a/ui/frame_artwork.py b/ui/frame_artwork.py new file mode 100644 index 0000000..41ed93f --- /dev/null +++ b/ui/frame_artwork.py @@ -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 diff --git a/ui/frame_steamgriddb.py b/ui/frame_steamgriddb.py new file mode 100644 index 0000000..2a580c9 --- /dev/null +++ b/ui/frame_steamgriddb.py @@ -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'] diff --git a/ui/frame_titles.py b/ui/frame_titles.py index bc208a0..d2d3588 100644 --- a/ui/frame_titles.py +++ b/ui/frame_titles.py @@ -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) diff --git a/ui/index.html b/ui/index.html index 2aa0bad..fb9011f 100644 --- a/ui/index.html +++ b/ui/index.html @@ -784,6 +784,19 @@ +
+

Library artwork settings

+

SteamGridDB supplies community artwork for sideloaded games. Without a key, Frame Control uses source images and creates the remaining art.

+ + +
+ + + Get a free key +
+
+ +

Remote & power

@@ -2625,5 +2638,6 @@ setView("headset"); refresh().then(loadShots); // after status, so app names resolve document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); }); + diff --git a/ui/server.py b/ui/server.py index f11655d..8a0fa01 100755 --- a/ui/server.py +++ b/ui/server.py @@ -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,