mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +02:00
Merge vr-library (Steam library art, Play/Stop, refresh-art) into the store
Resolve CLI usage and POST table conflicts. Store installs now hand the source's own image URLs (icon, banner, screenshots) to frame_android.install as Steam artwork; before, they passed UI proxy paths (or nothing), so every store install fell back to generated art. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
8315c7c3aa
26 files changed
+1813
-34
No files matched your search
@@ -0,0 +1,126 @@
|
||||
# vr-library implementation notes
|
||||
|
||||
Scope: `/tmp/vrapk/p1.md` plus parent updates mandating all-entrypoint artwork,
|
||||
SteamGridDB, designed fallback art, settings/backfill and two visual iterations.
|
||||
Worktree `/Users/saphid/projects/steam-frame-vrlib`, branch `vr-library`.
|
||||
No delegation, push, PR or issue edits. Parent owns independent review and integration.
|
||||
|
||||
## Implementation
|
||||
|
||||
- Initial commit `c06b328`: shared artwork, collection handling, launcher
|
||||
supervision, APK icon fallback and tests. Follow-up replaces its bitmap
|
||||
artwork implementation with Steam Chromium canvas + Motiva Sans.
|
||||
- `ui/frame_steamgriddb.py`: optional API provider, exact title (or trailing VR)
|
||||
match, highest-scored returned static/non-NSFW artwork, separate portrait
|
||||
and wide requests. No key means no requests/warnings. Saved settings are
|
||||
atomic 0600 on POSIX; API never returns the key; auth redirects disabled.
|
||||
- Precedence: provider, source slots/banner/feature graphic/screenshots, APK
|
||||
icon/generated art. All five outputs have fixed Steam sizes. Host Python
|
||||
stays stdlib-only and Python 3.9 compatible; Frame renders consistently
|
||||
regardless of host OS. Package filters include both new JS resources.
|
||||
- `frame/android/library_artwork.js`: dominant-colour gradient, blurred icon
|
||||
backdrop, large icon/shadow, real font, no title in hero, transparent logo.
|
||||
Removes only opaque near-black matte connected to icon corners. Native
|
||||
icons remain original; opaque foreground app tiles have rounded corners.
|
||||
- `apply_library` is mandatory for APK and native-title installs. CLI, upload,
|
||||
catalogue, versions, web install and source install seam share it. Native
|
||||
devkit executable/runtime is preserved. Failed initial art application
|
||||
removes a newly created shortcut; no success with incomplete art.
|
||||
- Refresh CLI/API/settings button repairs installed Android entries without
|
||||
reinstalling/stopping, uses cached source art, queries SGDB again if enabled,
|
||||
repairs missing shortcuts, and reports batch errors independently.
|
||||
- Details: name/icon/VR flag, sort-as, Android/Android VR collections or native
|
||||
Sideloaded, Installation details note preserving other notes. No supported
|
||||
arbitrary description/store-page/developer/achievement metadata found.
|
||||
Notes are keyed by sanitized name (Steam limitation: equal-name collisions).
|
||||
- Device discovery: custom-art type 4 is broken on this Steam client: it logs
|
||||
Unknown asset type and overwrites wide art with the icon. Use types 0–3 and
|
||||
SetShortcutIcon separately. Confirmed actual cache after correction.
|
||||
- Launcher retains its supervising shell under Steam, traps TERM/INT/HUP,
|
||||
stops only its own container, kills its child group, preserves exit status.
|
||||
Lock/pre-existing container guards prevent duplicate session cleanup.
|
||||
- VR attribution discovery: Lepton uses SteamAppId for both stable context and
|
||||
Android SteamVR identity. New shortcut.id + LEPTON_ENV_SteamAppId separates
|
||||
them using Lepton's existing passthrough; container/data paths unchanged.
|
||||
On-device source mounting.sh applies LEPTON_ENV_* after its regular setenv.
|
||||
|
||||
## Visual critique and iterations
|
||||
|
||||
Evidence `/tmp/vrlib-evidence/design-v1`, `design-v2`, `design-v3` (15 PNGs each).
|
||||
Open Saber Plus/SuperTux icons came from authorized APKs. AntennaPod is a
|
||||
preview only using the official F-Droid icon; it was not installed/launched.
|
||||
|
||||
1. v1: real typography/gradient already improves over old bitmap output, but
|
||||
Open Saber has a black square matte, and SuperTux palette looks muddy.
|
||||
2. v2: removed connected black matte; rounded the opaque AntennaPod foreground.
|
||||
Inspected all three posters. SuperTux still muted; title line balance weak.
|
||||
3. v3: lifted sampled saturation and balanced two-line labels. Inspected all
|
||||
three posters plus final all-slot sheet. Large recognisable artwork, readable
|
||||
typography, richer colours, textless hero and transparent title logo. Icon
|
||||
source resolution still limits detail; source/SGDB art remains preferable.
|
||||
|
||||
Final sheet `/tmp/vrlib-evidence/artwork-preview-final.png` is an artwork
|
||||
preview, NOT a Steam UI screenshot. Rows: portrait, wide, hero, logo/icon.
|
||||
|
||||
## Device verification (2026-09-28, build 20260925.6191901, SteamVR 2.18.1)
|
||||
|
||||
Steam was initially unavailable (old notes/evidence), then recovered. Both
|
||||
APKs were reinstalled and refreshed successfully with zero library warnings:
|
||||
|
||||
- Open Saber Plus: org.godotengine.open_saber_plus, instance 2802929330,
|
||||
shortcut 3346865537, game ID 14374678025558032384.
|
||||
- SuperTux: org.supertux.supertux2, instance 2811892472,
|
||||
shortcut 2883168793, game ID 12383115674816348160.
|
||||
|
||||
`steam-cache-final.log`: both actual Steam cache sets are 600x900, 920x430,
|
||||
3840x1240, 1280x480, with app icon 256x256. `steam-details-targets.json`:
|
||||
both correct names/sort-as, Android + Android VR, existing Played preserved.
|
||||
Native managed note readback result 1 (success), saved as steam-notes-final.json.
|
||||
|
||||
Open Saber first session: Steam tracked it for 32 seconds; process and
|
||||
container remained alive until only SteamClient TerminateApp was called.
|
||||
After identity fix, a second session remained alive at 22 seconds (Android
|
||||
PID 992), SteamVR identified steam.app.3346865537, and screenshot shows the
|
||||
actual game scene instead of blank Resume tile. Steam Stop removed tracked
|
||||
process and container within ~5 seconds. No direct podman-stop fallback was
|
||||
used for this verification. Same data paths; existing game play count shown
|
||||
in capture, but no separate save-file sentinel added to the real game.
|
||||
Evidence: opensaber-identity-session.log, opensaber-identity-headset.png,
|
||||
opensaber-running.log, opensaber-steam-stop.log, opensaber-headset-running.png.
|
||||
|
||||
SuperTux: Steam Play tracked the wrapper for ~17 seconds. SDLClipboardHandler
|
||||
crashes with NullPointerException on missing ClipboardManager during activity
|
||||
creation. Lepton and supervisor then cleaned up; it never reached a VR scene.
|
||||
Steam Stop was issued after the crash, so sustained SuperTux Stop is NOT proven.
|
||||
Evidence: supertux-running.log, supertux-lepton.log, supertux-shot.json.
|
||||
|
||||
Other test activity appeared on the device during checks (other Android
|
||||
container and Gravitas). Neither was stopped or modified. Final Open Saber
|
||||
preflight had no Android containers; other desktop overlays later appeared
|
||||
in its headset capture. No global VR standby/dashboard settings changed.
|
||||
Direct Steam UI Page.captureScreenshot timed out; no library UI screenshot.
|
||||
|
||||
## Automated verification and limits
|
||||
|
||||
- Whole suite: `python3 -m unittest discover -s tests`, Python 3.9.6. Final
|
||||
result: **206 tests OK, 10.294 seconds, exit 0**. Log:
|
||||
`/tmp/vrlib-evidence/tests-final.log`.
|
||||
- Offline entrypoint tests exercise real shared install/render/configure flow
|
||||
with SSH/API mocked: CLI, upload, catalogue, versions, web download, source
|
||||
seam, native title and refresh. Provider ranking/failure/settings tests.
|
||||
- Node contract test runs actual renderer against explicitly synthetic canvas,
|
||||
validates all PNG dimensions and hero/logo text placement. This canvas is
|
||||
also used by fakeframe; transparent fixture images are not visual evidence.
|
||||
- Launcher tests assert stable context plus shortcut passthrough, signals,
|
||||
normal exit, duplicate guard and saved-data sentinel. Earlier real Linux
|
||||
setsid/flock fixture run: 3 tests OK (linux-launcher-tests.log).
|
||||
- `bash -n`, Node syntax checks, Python AST feature_version=(3,9), diff checks.
|
||||
- Docker E2E not run: `docker info --format '{{.ServerVersion}}'` exits 1;
|
||||
daemon socket /var/run/docker.sock does not exist.
|
||||
- Authenticated SGDB lookup/download unverified (no key configured).
|
||||
- Windows/Linux packaged binaries not built/launched; filters tested.
|
||||
- Native devkit art uses shared tested seam; no additional native title was
|
||||
installed on device. Sibling source-search endpoint not in this worktree:
|
||||
only its public installer contract is tested.
|
||||
- Independent review not spawned: explicit brief forbids delegation and assigns
|
||||
parent review/integration. No other provider/model participation claimed.
|
||||
+4
-2
@@ -46,7 +46,8 @@
|
||||
"to": "ui",
|
||||
"filter": [
|
||||
"*.py",
|
||||
"*.html"
|
||||
"*.html",
|
||||
"*.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -69,7 +70,8 @@
|
||||
"to": "frame/android",
|
||||
"filter": [
|
||||
"*.sh",
|
||||
"*.py"
|
||||
"*.py",
|
||||
"*.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,144 @@
|
||||
// Runs in Steam's Chromium context: identical fonts/rendering from every host OS.
|
||||
async function renderLibraryArtwork(input) {
|
||||
const sizes = {grid:[600,900], wide:[920,430], hero:[3840,1240], logo:[1280,480], icon:[256,256]};
|
||||
const label = String(input.label || 'Untitled').trim().slice(0,180);
|
||||
const font = '"Motiva Sans", "Noto Sans", Arial, sans-serif';
|
||||
await document.fonts.load(`800 120px ${font}`, label);
|
||||
const images = {}, warnings = [];
|
||||
for (const [slot, item] of Object.entries(input.images || {})) {
|
||||
try {
|
||||
const img = new Image();
|
||||
img.src = `data:image/${item[0]};base64,${item[1]}`;
|
||||
await img.decode();
|
||||
if (!img.width || !img.height || img.width*img.height > 8000000) throw Error('dimensions');
|
||||
images[slot] = img;
|
||||
} catch (_) { warnings.push(`${slot} could not be decoded; generated art used`); }
|
||||
}
|
||||
let icon = images.icon;
|
||||
if (icon) {
|
||||
// Remove only a near-black matte connected to the outside of an opaque icon.
|
||||
const cut=document.createElement('canvas');cut.width=icon.width;cut.height=icon.height;
|
||||
const c=cut.getContext('2d');c.drawImage(icon,0,0);
|
||||
const pixels=c.getImageData(0,0,cut.width,cut.height), d=pixels.data, w=cut.width,h=cut.height;
|
||||
const corners=[0,w-1,(h-1)*w,h*w-1];
|
||||
if(corners.every(i=>d[i*4+3]>250 && Math.max(d[i*4],d[i*4+1],d[i*4+2])<24)) {
|
||||
const seen=new Uint8Array(w*h), queue=corners.slice();
|
||||
for(let q=0;q<queue.length;q++) {
|
||||
const i=queue[q];if(seen[i])continue;seen[i]=1;
|
||||
if(Math.max(d[i*4],d[i*4+1],d[i*4+2])>24)continue;
|
||||
d[i*4+3]=0;
|
||||
if(i%w)queue.push(i-1);if(i%w<w-1)queue.push(i+1);
|
||||
if(i>=w)queue.push(i-w);if(i<w*(h-1))queue.push(i+w);
|
||||
}
|
||||
c.putImageData(pixels,0,0);icon=cut;
|
||||
}
|
||||
}
|
||||
// Quantized, saturated dominant colors avoid white/black icon margins.
|
||||
let colors = [[48,91,137], [24,36,63]];
|
||||
if (icon) {
|
||||
const sample = document.createElement('canvas'); sample.width=48; sample.height=48;
|
||||
const s=sample.getContext('2d'); s.drawImage(icon,0,0,48,48);
|
||||
const data=s.getImageData(0,0,48,48).data, bins=new Map();
|
||||
for (let i=0;i<data.length;i+=4) {
|
||||
const rgb=[data[i],data[i+1],data[i+2]], hi=Math.max(...rgb), lo=Math.min(...rgb);
|
||||
if (data[i+3]<150 || hi<45 || lo>220 || hi-lo<25) continue;
|
||||
const key=rgb.map(v=>Math.round(v/32)*32).join(',');
|
||||
bins.set(key,(bins.get(key)||0)+1);
|
||||
}
|
||||
const ranked=[...bins].sort((a,b)=>b[1]-a[1]);
|
||||
if (ranked.length) {
|
||||
colors[0]=ranked[0][0].split(',').map(Number);
|
||||
colors[1]=(ranked.find(([key])=>key.split(',').reduce((n,v,i)=>n+Math.abs(Number(v)-colors[0][i]),0)>170)||ranked[0])[0].split(',').map(Number);
|
||||
}
|
||||
}
|
||||
// Preserve hue while lifting muted icon colors into a richer background palette.
|
||||
colors=colors.map(c=>{const low=Math.min(...c),range=Math.max(...c)-low||1;
|
||||
return c.map(v=>45+(v-low)/range*165);});
|
||||
const rgb=(c,a=1)=>`rgba(${c.map(v=>Math.min(255,Math.round(v))).join(',')},${a})`;
|
||||
function image(ctx,img,x,y,w,h,cover=false) {
|
||||
const scale=cover?Math.max(w/img.width,h/img.height):Math.min(w/img.width,h/img.height);
|
||||
const dw=img.width*scale,dh=img.height*scale;
|
||||
ctx.save(); ctx.beginPath(); ctx.rect(x,y,w,h); ctx.clip();
|
||||
ctx.drawImage(img,x+(w-dw)/2,y+(h-dh)/2,dw,dh); ctx.restore();
|
||||
}
|
||||
function title(ctx,w,h,top,bottom,maxSize) {
|
||||
let lines=[],size=maxSize;
|
||||
const maxWidth=w*.84;
|
||||
for (;size>=18;size-=2) {
|
||||
ctx.font=`800 ${size}px ${font}`;
|
||||
lines=[]; let line='';
|
||||
for (const word of label.split(/\s+/)) {
|
||||
const next=line?line+' '+word:word;
|
||||
if (line && ctx.measureText(next).width>maxWidth) {lines.push(line);line=word;} else line=next;
|
||||
}
|
||||
lines.push(line);
|
||||
if (lines.length*size*1.08<=bottom-top && lines.every(l=>ctx.measureText(l).width<=maxWidth)) break;
|
||||
}
|
||||
if(lines.length===2) {
|
||||
const words=lines[0].split(' ');
|
||||
if(words.length>1) {
|
||||
const first=words.slice(0,-1).join(' '), second=words.slice(-1)[0]+' '+lines[1];
|
||||
if(ctx.measureText(second).width<=maxWidth &&
|
||||
Math.abs(ctx.measureText(first).width-ctx.measureText(second).width)<
|
||||
Math.abs(ctx.measureText(lines[0]).width-ctx.measureText(lines[1]).width)) lines=[first,second];
|
||||
}
|
||||
}
|
||||
// A long unbroken label is still fitted, including scripts without spaces.
|
||||
ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillStyle='#fff';
|
||||
ctx.shadowColor='rgba(0,0,0,.45)'; ctx.shadowBlur=size*.28; ctx.shadowOffsetY=size*.06;
|
||||
let y=top+(bottom-top-lines.length*size*1.08)/2+size*.54;
|
||||
for (const line of lines) {ctx.fillText(line,w/2,y,maxWidth); y+=size*1.08;}
|
||||
ctx.shadowBlur=0; ctx.shadowOffsetY=0;
|
||||
}
|
||||
const result={};
|
||||
for (const [slot,[w,h]] of Object.entries(sizes)) {
|
||||
const canvas=document.createElement('canvas'); canvas.width=w; canvas.height=h;
|
||||
const ctx=canvas.getContext('2d'); ctx.imageSmoothingQuality='high';
|
||||
const direct=images[slot];
|
||||
const feature=images.feature_graphic||images.banner;
|
||||
const scene=direct || ((slot==='hero'||slot==='wide') && (feature||images.screenshot));
|
||||
if (scene) {
|
||||
if (slot==='logo'||slot==='icon') image(ctx,scene,0,0,w,h);
|
||||
else image(ctx,scene,0,0,w,h,true);
|
||||
} else if (slot==='logo') {
|
||||
title(ctx,w,h,h*.08,h*.92,150);
|
||||
} else {
|
||||
const gradient=ctx.createLinearGradient(0,0,w,h);
|
||||
gradient.addColorStop(0,rgb(colors[0].map(v=>v*.68)));
|
||||
gradient.addColorStop(.6,rgb(colors[1].map(v=>v*.32)));
|
||||
gradient.addColorStop(1,'#080c16'); ctx.fillStyle=gradient;ctx.fillRect(0,0,w,h);
|
||||
if (icon) {
|
||||
ctx.save();ctx.globalAlpha=.16;ctx.filter=`blur(${Math.round(w*.055)}px) saturate(1.4)`;
|
||||
image(ctx,icon,-w*.15,-h*.15,w*1.3,h*1.3,true);ctx.restore();
|
||||
}
|
||||
const glow=ctx.createRadialGradient(w*.5,h*.32,0,w*.5,h*.32,w*.8);
|
||||
glow.addColorStop(0,rgb(colors[0],.27));glow.addColorStop(1,rgb(colors[1],0));
|
||||
ctx.fillStyle=glow;ctx.fillRect(0,0,w,h);
|
||||
const vignette=ctx.createLinearGradient(0,h*.25,0,h);
|
||||
vignette.addColorStop(0,'rgba(0,0,0,0)');vignette.addColorStop(1,'rgba(0,0,0,.56)');
|
||||
ctx.fillStyle=vignette;ctx.fillRect(0,0,w,h);
|
||||
const box=slot==='grid'?[w*.12,h*.14,w*.76,w*.76]:
|
||||
slot==='wide'?[w*.36,h*.06,w*.28,h*.59]:
|
||||
slot==='hero'?[w*.365,h*.12,w*.27,h*.78]:[w*.08,h*.08,w*.84,h*.84];
|
||||
if (icon) {
|
||||
ctx.save();ctx.shadowColor='rgba(0,0,0,.65)';ctx.shadowBlur=Math.min(w,h)*.055;
|
||||
ctx.shadowOffsetY=Math.min(w,h)*.022;
|
||||
// Opaque square icons read as deliberate app tiles, not pasted rectangles.
|
||||
const [x,y,bw,bh]=box, side=Math.min(bw,bh);
|
||||
if(slot!=='hero' && icon===images.icon) {
|
||||
ctx.beginPath();ctx.roundRect(x+(bw-side)/2,y+(bh-side)/2,side,side,side*.14);ctx.clip();
|
||||
}
|
||||
image(ctx,icon,...box);ctx.restore();
|
||||
} else if (slot !== 'hero') {
|
||||
// A typographic monogram when the APK contains no usable image.
|
||||
ctx.font=`800 ${Math.min(w,h)*.48}px ${font}`;ctx.fillStyle='rgba(255,255,255,.94)';
|
||||
ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText([...label][0]||'A',w/2,h*.38);
|
||||
}
|
||||
if (slot==='grid') title(ctx,w,h,h*.7,h*.93,66);
|
||||
if (slot==='wide') title(ctx,w,h,h*.69,h*.92,52);
|
||||
// Hero intentionally has no title: Steam overlays the transparent logo.
|
||||
}
|
||||
result[slot]=canvas.toDataURL('image/png').split(',')[1];
|
||||
}
|
||||
return {images:result,warnings,font};
|
||||
}
|
||||
@@ -5,9 +5,11 @@ Python stdlib only; the Mac runs it with `ssh frame python3 - <args> < this`.
|
||||
|
||||
steam_shortcuts.py add NAME EXE START_DIR [ICON] -> prints the shortcut app id
|
||||
steam_shortcuts.py list -> JSON [{appid, name, exe}]
|
||||
steam_shortcuts.py configure APPID NAME EXE START_DIR ICON VR ARTWORK_JSON
|
||||
steam_shortcuts.py stop APPID
|
||||
steam_shortcuts.py remove APPID
|
||||
"""
|
||||
import base64, json, os, socket, struct, sys, urllib.request
|
||||
import base64, json, os, re, socket, struct, sys, urllib.request
|
||||
|
||||
DEVTOOLS = 'http://127.0.0.1:8080/json'
|
||||
|
||||
@@ -83,6 +85,153 @@ def evaluate(js):
|
||||
return res.get('result', {}).get('value')
|
||||
|
||||
|
||||
# Steam's ELibraryAssetType (Capsule, Hero, Logo, Header, Icon).
|
||||
ASSETS = {'grid': 0, 'hero': 1, 'logo': 2, 'wide': 3, 'icon': 4}
|
||||
|
||||
|
||||
def collections_js(appid, vr=None, category='Android'):
|
||||
wanted = [] if vr is None else [category, *(['Android VR'] if vr and category == 'Android' else [])]
|
||||
return f'''async function syncCollections() {{
|
||||
const wanted = {json.dumps(wanted)};
|
||||
if (typeof collectionStore === "undefined" ||
|
||||
typeof collectionStore.GetUserCollectionsByName !== "function" ||
|
||||
typeof collectionStore.NewUnsavedCollection !== "function" ||
|
||||
typeof collectionStore.SaveCollection !== "function")
|
||||
return ["Steam collections API unavailable"];
|
||||
const app = {{appid: {appid}}};
|
||||
const warnings = [];
|
||||
for (const name of ["Android", "Android VR", "Sideloaded"]) {{
|
||||
const matches = collectionStore.GetUserCollectionsByName(name);
|
||||
let collection = matches.find(c => !c.bIsDynamic && c.bAllowsDragAndDrop);
|
||||
if (wanted.includes(name)) {{
|
||||
if (!collection && matches.length) {{
|
||||
warnings.push(name + " is an existing dynamic or read-only collection");
|
||||
continue;
|
||||
}}
|
||||
if (!collection) {{
|
||||
collection = collectionStore.NewUnsavedCollection(name, undefined, [app]);
|
||||
}} else {{
|
||||
collection.AsDragDropCollection().AddApps([app]);
|
||||
}}
|
||||
await collectionStore.SaveCollection(collection);
|
||||
}} else if (collection) {{
|
||||
collection.AsDragDropCollection().RemoveApps([app]);
|
||||
await collectionStore.SaveCollection(collection);
|
||||
}}
|
||||
}}
|
||||
return warnings;
|
||||
}}'''
|
||||
|
||||
|
||||
|
||||
def notes_js(name, details):
|
||||
filename = 'notes_shortcut_' + re.sub(r'[!-/:-@ \[\\\]\^`]', '_', name.strip())
|
||||
content = '\n'.join(str(details[k]) for k in ('package', 'version', 'source') if details.get(k))
|
||||
return f'''if (SteamClient.GameNotes && typeof SteamClient.GameNotes.GetNotes === "function" &&
|
||||
typeof SteamClient.GameNotes.SaveNotes === "function") {{
|
||||
try {{
|
||||
const file = {json.dumps(filename)};
|
||||
const previous = await SteamClient.GameNotes.GetNotes(file, file + "_images/");
|
||||
if (previous.result !== 1 && previous.result !== 9) throw Error("read " + previous.result);
|
||||
const data = previous.result === 1 ? JSON.parse(previous.notes) : {{notes: [], shortcut_name: {json.dumps(name)}}};
|
||||
if (!Array.isArray(data.notes)) throw Error("unexpected notes format");
|
||||
const id = "frame-control-library", now = Math.floor(Date.now()/1000);
|
||||
const old = data.notes.find(n => n.id === id);
|
||||
const note = {{id, shortcut_name: {json.dumps(name)}, title: "Installation details",
|
||||
content: {json.dumps(content)}, ordinal: old ? old.ordinal : data.notes.length,
|
||||
time_created: old ? old.time_created : now, time_modified: now}};
|
||||
data.notes = data.notes.filter(n => n.id !== id).concat([note]);
|
||||
const result = await SteamClient.GameNotes.SaveNotes(file, JSON.stringify(data));
|
||||
if (result !== 1) throw Error("save " + result);
|
||||
}} catch (e) {{ warnings.push("Steam notes: " + String(e)); }}
|
||||
}}'''
|
||||
|
||||
|
||||
def render(plan):
|
||||
with open(plan) as f:
|
||||
source = json.load(f)
|
||||
images = {}
|
||||
for slot, path in source['images'].items():
|
||||
ext = os.path.splitext(path)[1][1:]
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read(12 * 1024 * 1024 + 1)
|
||||
if len(data) > 12 * 1024 * 1024:
|
||||
raise ValueError('source artwork too large')
|
||||
images[slot] = [ext, base64.b64encode(data).decode()]
|
||||
renderer = globals().get('ART_RENDERER')
|
||||
if renderer is None:
|
||||
with open(os.path.join(os.path.dirname(__file__), 'library_artwork.js')) as f:
|
||||
renderer = f.read()
|
||||
result = evaluate(renderer + '\nrenderLibraryArtwork(' + json.dumps({'label': source['label'], 'images': images}) + ')')
|
||||
if not isinstance(result, dict) or set(result.get('images', {})) != set(ASSETS):
|
||||
raise ValueError('incomplete artwork render')
|
||||
paths = {}
|
||||
for slot, encoded in result['images'].items():
|
||||
path = os.path.join(os.path.dirname(plan), slot + '.png')
|
||||
data = base64.b64decode(encoded, validate=True)
|
||||
if not data.startswith(b'\x89PNG\r\n\x1a\n') or len(data) > 12 * 1024 * 1024:
|
||||
raise ValueError('invalid rendered image')
|
||||
with open(path + '.tmp', 'wb') as f:
|
||||
f.write(data)
|
||||
os.replace(path + '.tmp', path)
|
||||
paths[slot] = path
|
||||
return {'paths': paths, 'warnings': result.get('warnings', [])}
|
||||
|
||||
|
||||
def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None):
|
||||
options = options or {}
|
||||
if set(artwork) != set(ASSETS):
|
||||
raise ValueError('all five Steam artwork slots are required')
|
||||
images = []
|
||||
for slot, path in artwork.items():
|
||||
if slot not in ASSETS:
|
||||
raise ValueError('unknown artwork slot')
|
||||
ext = os.path.splitext(path)[1][1:]
|
||||
if ext not in ('png', 'jpg'):
|
||||
raise ValueError('artwork must be PNG or JPEG')
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read(12 * 1024 * 1024 + 1)
|
||||
if len(data) > 12 * 1024 * 1024:
|
||||
raise ValueError('artwork is too large')
|
||||
if slot != 'icon': # Frame's custom-art API maps type 4 to Header; use SetShortcutIcon.
|
||||
images.append([ASSETS[slot], ext, base64.b64encode(data).decode()])
|
||||
return evaluate(f'''(async () => {{
|
||||
const id = {int(appid)}, warnings = [];
|
||||
SteamClient.Apps.SetShortcutName(id, {json.dumps(name)});
|
||||
if ({json.dumps(exe)}) SteamClient.Apps.SetShortcutExe(id, {json.dumps(exe)});
|
||||
if ({json.dumps(start_dir)}) SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)});
|
||||
if (typeof SteamClient.Apps.SetShortcutSortAs === "function")
|
||||
SteamClient.Apps.SetShortcutSortAs(id, {json.dumps(name)});
|
||||
SteamClient.Apps.SetShortcutIcon(id, {json.dumps(icon)});
|
||||
if (typeof SteamClient.Apps.SetShortcutIsVR === "function")
|
||||
SteamClient.Apps.SetShortcutIsVR(id, {json.dumps(vr)});
|
||||
else warnings.push("Steam VR shortcut flag API unavailable");
|
||||
if (typeof SteamClient.Apps.SetCustomArtworkForApp === "function") {{
|
||||
for (const [type, ext, data] of {json.dumps(images)})
|
||||
await SteamClient.Apps.SetCustomArtworkForApp(id, data, ext, type);
|
||||
}} else throw new Error("Steam artwork API unavailable; installation is incomplete");
|
||||
{collections_js(int(appid), vr, options.get('category', 'Android'))}
|
||||
try {{ warnings.push(...await syncCollections()); }}
|
||||
catch (e) {{ warnings.push("Steam collections: " + String(e)); }}
|
||||
{notes_js(name, options.get('details', {}))}
|
||||
return {{warnings}};
|
||||
}})()''')
|
||||
|
||||
|
||||
def remove(appid):
|
||||
return evaluate(f'''(async () => {{
|
||||
const id = {int(appid)};
|
||||
{collections_js(int(appid))}
|
||||
const warnings = await syncCollections();
|
||||
if (typeof SteamClient.Apps.ClearCustomArtworkForApp === "function") {{
|
||||
for (const type of [0, 1, 2, 3])
|
||||
await SteamClient.Apps.ClearCustomArtworkForApp(id, type);
|
||||
}} else throw new Error("Steam artwork removal API unavailable");
|
||||
SteamClient.Apps.RemoveShortcut(id);
|
||||
return {{warnings}};
|
||||
}})()''')
|
||||
|
||||
|
||||
def main():
|
||||
cmd, args = sys.argv[1], sys.argv[2:]
|
||||
if cmd == 'add':
|
||||
@@ -98,11 +247,18 @@ def main():
|
||||
print(evaluate(js))
|
||||
elif cmd == 'list':
|
||||
js = '''(() => appStore.allApps.filter(a => a.app_type === 1073741824)
|
||||
.map(a => ({appid: a.appid, name: a.display_name})))()'''
|
||||
.map(a => ({appid: a.appid, name: a.display_name, devkit_gameid: a.devkit_gameid})))()'''
|
||||
print(json.dumps(evaluate(js)))
|
||||
elif cmd == 'render':
|
||||
print(json.dumps(render(args[0])))
|
||||
elif cmd == 'configure':
|
||||
print(json.dumps(configure(int(args[0]), *args[1:5], args[5] == '1', json.loads(args[6]),
|
||||
json.loads(args[7]) if len(args) > 7 else None)))
|
||||
elif cmd == 'stop':
|
||||
evaluate(f'SteamClient.Apps.TerminateApp({json.dumps(str((int(args[0]) << 32) | 0x02000000))}, false)')
|
||||
print('stopping')
|
||||
elif cmd == 'remove':
|
||||
evaluate(f'SteamClient.Apps.RemoveShortcut({int(args[0])})')
|
||||
print('removed')
|
||||
print(json.dumps(remove(int(args[0]))))
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ containers = {n: c for n, c in fs.read()['lepton'].items() if alive(c)}
|
||||
if cmd == 'ps':
|
||||
for name, c in sorted(containers.items()):
|
||||
print(f"{name} {c['port']}")
|
||||
elif cmd == 'inspect':
|
||||
if args[-1] not in containers:
|
||||
sys.exit(1)
|
||||
print('true')
|
||||
elif cmd == 'stop':
|
||||
name = args[-1]
|
||||
c = containers.get(name)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Synthetic canvas for API-path tests only. It emits transparent PNGs, not visual proof.
|
||||
const zlib = require('zlib');
|
||||
function crc(data) {
|
||||
let c=0xffffffff;
|
||||
for(const b of data) {c^=b;for(let i=0;i<8;i++)c=(c>>>1)^((c&1)?0xedb88320:0);}
|
||||
return (c^0xffffffff)>>>0;
|
||||
}
|
||||
function chunk(name,data) {
|
||||
const body=Buffer.concat([Buffer.from(name),data]), n=Buffer.alloc(4), sum=Buffer.alloc(4);
|
||||
n.writeUInt32BE(data.length);sum.writeUInt32BE(crc(body));return Buffer.concat([n,body,sum]);
|
||||
}
|
||||
function png(w,h) {
|
||||
const header=Buffer.alloc(13);header.writeUInt32BE(w);header.writeUInt32BE(h,4);header[8]=8;header[9]=6;
|
||||
return Buffer.concat([Buffer.from('89504e470d0a1a0a','hex'),chunk('IHDR',header),
|
||||
chunk('IDAT',zlib.deflateSync(Buffer.alloc((w*4+1)*h))),chunk('IEND',Buffer.alloc(0))]).toString('base64');
|
||||
}
|
||||
function surface() {
|
||||
const canvases=[];
|
||||
const document={fonts:{load:async()=>[]},createElement(tag) {
|
||||
if(tag!=='canvas')throw Error('unexpected element');
|
||||
const canvas={width:1,height:1,text:[],draws:0};
|
||||
const ctx={measureText:t=>({width:String(t).length*30}),fillText(t){canvas.text.push(t);},
|
||||
drawImage(){canvas.draws++;},getImageData:()=>({data:new Uint8ClampedArray(canvas.width*canvas.height*4)}),
|
||||
createLinearGradient:()=>({addColorStop(){}}),createRadialGradient:()=>({addColorStop(){}})};
|
||||
for(const method of ['save','restore','beginPath','rect','roundRect','clip','fillRect','putImageData','arc','fill','stroke'])ctx[method]=()=>{};
|
||||
canvas.getContext=()=>ctx;canvas.toDataURL=()=> 'data:image/png;base64,'+png(canvas.width,canvas.height);
|
||||
canvases.push(canvas);return canvas;
|
||||
}};
|
||||
class Image {constructor(){this.width=2;this.height=2;} async decode(){if(this.src.includes('YmFk'))throw Error('bad image');}}
|
||||
return {document,Image,canvases};
|
||||
}
|
||||
module.exports={surface};
|
||||
@@ -37,7 +37,7 @@ function build(steam) {
|
||||
},
|
||||
});
|
||||
const shortcutOverview = s => ({
|
||||
appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE,
|
||||
appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE, devkit_gameid: s.devkit_gameid,
|
||||
local_per_client_data: { installed: true, display_status: 1, status_percentage: 0 },
|
||||
});
|
||||
const allApps = () => [...steam.apps.map(gameOverview), ...steam.shortcuts.map(shortcutOverview)];
|
||||
@@ -54,7 +54,23 @@ function build(steam) {
|
||||
}
|
||||
};
|
||||
|
||||
// Library API shapes from SteamTracking / decky-frontend-lib (2026-09-28).
|
||||
// Not yet verified on this Frame build: Steam was unavailable during testing.
|
||||
steam.collections ||= [];
|
||||
const collection = value => ({
|
||||
...value, displayName: value.name, bIsDynamic: !!value.dynamic, bAllowsDragAndDrop: true,
|
||||
AsDragDropCollection() { return this; },
|
||||
AddApps(apps) { value.apps = [...new Set([...value.apps, ...apps.map(a => a.appid)])]; },
|
||||
RemoveApps(apps) { value.apps = value.apps.filter(id => !apps.some(a => a.appid === id)); },
|
||||
value,
|
||||
});
|
||||
return {
|
||||
...require('./canvas_stub').surface(),
|
||||
collectionStore: {
|
||||
GetUserCollectionsByName(name) { return steam.collections.filter(c => c.name === name).map(collection); },
|
||||
NewUnsavedCollection(name, filter, apps) { return collection({name, apps: apps.map(a => a.appid)}); },
|
||||
async SaveCollection(c) { if (!steam.collections.includes(c.value)) steam.collections.push(c.value); },
|
||||
},
|
||||
appStore: {
|
||||
get allApps() { return allApps(); },
|
||||
GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; },
|
||||
@@ -75,6 +91,17 @@ function build(steam) {
|
||||
SetShortcutStartDir(id, dir) { const s = findShortcut(id); if (s) s.start_dir = String(dir); },
|
||||
SetShortcutIcon(id, icon) { const s = findShortcut(id); if (s) s.icon = String(icon); },
|
||||
SetShortcutExe(id, exe) { const s = findShortcut(id); if (s) s.exe = String(exe); },
|
||||
SetShortcutIsVR(id, vr) { const s = findShortcut(id); if (s) s.vr = vr; },
|
||||
async SetCustomArtworkForApp(id, data, ext, type) {
|
||||
const s = findShortcut(id);
|
||||
if (s) { s.artwork ||= {}; s.artwork[type] = {data, ext}; }
|
||||
},
|
||||
async ClearCustomArtworkForApp(id, type) {
|
||||
const s = findShortcut(id);
|
||||
if (s?.artwork) delete s.artwork[type];
|
||||
},
|
||||
// Container fallback in frame_android.stop performs the simulated stop.
|
||||
TerminateApp(gameid) { steam.last_terminate = gameid; },
|
||||
RemoveShortcut(id) {
|
||||
steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id));
|
||||
delete steam.compat_tools[String(id)];
|
||||
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
# Library fixtures
|
||||
|
||||
`icon.png` is a synthetic 2×2 RGBA fixture with opaque, half-transparent and
|
||||
transparent pixels. `steam-responses.json` includes the Open Saber Plus
|
||||
shortcut ID/name and home path read from the Frame's existing metadata on
|
||||
2026-09-28; the `configure` response is synthetic, matching our helper's
|
||||
contract. Tests never contact the network.
|
||||
|
||||
The existing fakeframe CEF shim models artwork and collection methods from
|
||||
SteamTracking's `ClientExtracted/steamui/chunk~2dcc5aaf7.js` and
|
||||
SteamDeckHomebrew/decky-frontend-lib's `src/globals/steam-client/App.ts`, read
|
||||
2026-09-28. These methods were not captured from this headset: Steam's client
|
||||
was unavailable. The Node-based test runs the actual generated JavaScript
|
||||
against that fixture; it is skipped when Node is absent.
|
||||
|
||||
`icon.jpg` is the same synthetic icon converted with macOS `sips` to exercise
|
||||
JPEG SOF parsing. `sips` is not used by the product or tests.
|
||||
+16
@@ -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)});
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 834 B |
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 77 B |
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"home": "/home/steamos",
|
||||
"shortcuts": [
|
||||
{
|
||||
"appid": 3346865537,
|
||||
"name": "Open Saber Plus"
|
||||
}
|
||||
],
|
||||
"configure": {
|
||||
"warnings": []
|
||||
}
|
||||
}
|
||||
@@ -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')]), \
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -311,7 +311,11 @@ def install(source_id, entry_id, version_code=None, progress=None):
|
||||
kwargs = {'name': entry.get('name'), 'icon_png': downloaded.get('icon_png') or entry.get('icon_png'),
|
||||
'source': source['name']}
|
||||
if 'artwork' in inspect.signature(frame_android.install).parameters:
|
||||
kwargs['artwork'] = downloaded.get('artwork') or entry.get('artwork')
|
||||
# The source's own image URLs (not the UI's /source-image/ proxy paths) become Steam library art.
|
||||
images = entry.get('images') if isinstance(entry.get('images'), dict) else {}
|
||||
art = {'icon': images.get('icon') or entry.get('icon'), 'banner': images.get('banner'),
|
||||
'screenshots': [u for u in images.get('screenshots') or [] if u][:4]}
|
||||
kwargs['artwork'] = downloaded.get('artwork') or {k: v for k, v in art.items() if v} or None
|
||||
if progress:
|
||||
progress('Installing', None)
|
||||
from apk_sources import _web
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
(() => {
|
||||
const get=id=>document.getElementById(id), status=get('steamGridStatus');
|
||||
async function request(url,body) {
|
||||
const r=await fetch(url,body===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||
const data=await r.json(); if(!r.ok) throw Error(data.error||'Request failed'); return data;
|
||||
}
|
||||
function show(data) {
|
||||
status.textContent=data.environment?'SteamGridDB key is set by an environment variable.':
|
||||
data.steamgriddb_configured?'SteamGridDB key saved.':'No key configured. Source images and generated art are enabled.';
|
||||
}
|
||||
async function save(value) {
|
||||
try {show(await request('/api/settings/artwork',{steamgriddb_api_key:value}));get('steamGridKey').value='';}
|
||||
catch(e) {status.textContent=e.message;}
|
||||
}
|
||||
get('saveSteamGridKey').onclick=()=>save(get('steamGridKey').value.trim());
|
||||
get('clearSteamGridKey').onclick=()=>save('');
|
||||
get('refreshAndroidArt').onclick=async()=>{
|
||||
const button=get('refreshAndroidArt');button.disabled=true;
|
||||
try {
|
||||
const result=await runJob('Refresh Android artwork','android-artwork',()=>request('/api/android',{action:'refresh-art',all:true}));
|
||||
if (result) {
|
||||
const apps=Array.isArray(result.apps)?result.apps:[result.apps];
|
||||
const failed=apps.filter(a=>a.error);
|
||||
status.textContent=failed.length?`${failed.length} refresh failed: ${failed.map(a=>a.package+': '+a.error).join('; ')}`:
|
||||
`Refreshed artwork for ${apps.length} apps.`;
|
||||
}
|
||||
} catch(e) {status.textContent=e.message;}
|
||||
finally {button.disabled=false;}
|
||||
};
|
||||
request('/api/settings/artwork').then(show).catch(e=>{status.textContent=e.message;});
|
||||
})();
|
||||
+142
-18
@@ -9,11 +9,12 @@ Lepton Development, which wipes its apps on exit. See docs/apks.md.
|
||||
Python stdlib only. CLI: python3 ui/frame_android.py
|
||||
install APK [--vr|--flat] [--no-xr-compat] | info APK | versions APK-or-PKG
|
||||
install-obb PKG OBB [OBB ...] | backup-data PKG ARCHIVE | restore-data PKG ARCHIVE
|
||||
patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG
|
||||
refresh-art PKG|--all | patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG
|
||||
"""
|
||||
import json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
|
||||
|
||||
import frame_apk
|
||||
import frame_artwork
|
||||
import frame_host
|
||||
import tempfile
|
||||
import zipfile
|
||||
@@ -57,8 +58,12 @@ def ssh(cmd, input=None, timeout=120):
|
||||
|
||||
def shortcut_tool(*args, timeout=60):
|
||||
with open(SHORTCUTS) as f:
|
||||
return ssh('python3 - ' + ' '.join(shlex.quote(a) for a in args), input=f.read(),
|
||||
timeout=timeout).strip()
|
||||
script = f.read()
|
||||
if args and args[0] == 'render':
|
||||
with open(os.path.join(ROOT, 'frame/android/library_artwork.js')) as f:
|
||||
script = 'ART_RENDERER = ' + repr(f.read()) + '\n' + script
|
||||
return ssh('python3 - ' + ' '.join(shlex.quote(a) for a in args), input=script,
|
||||
timeout=timeout).strip()
|
||||
|
||||
|
||||
def instance_id(pkg):
|
||||
@@ -136,7 +141,7 @@ def _write_meta(d, meta):
|
||||
ssh(f'cat > {d}/meta.json.tmp && mv {d}/meta.json.tmp {d}/meta.json', input=json.dumps(meta, indent=1))
|
||||
|
||||
|
||||
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None):
|
||||
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None, artwork=None):
|
||||
info = apk_info(apk_path)
|
||||
if icon_png:
|
||||
info['icon_png'] = icon_png
|
||||
@@ -155,43 +160,57 @@ def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr
|
||||
patched = os.path.join(tmp, 'app.apk')
|
||||
info['patched'] = patch(apk_path, patched, add)['patched']
|
||||
info['launchable'] = True
|
||||
return _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path))
|
||||
return _install(apk_path, info, pkg, flatscreen, name, source)
|
||||
return _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path), artwork)
|
||||
return _install(apk_path, info, pkg, flatscreen, name, source, artwork)
|
||||
|
||||
|
||||
def _install(apk_path, info, pkg, flatscreen, name, source):
|
||||
def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
|
||||
try:
|
||||
images, art_warnings = frame_artwork.prepare(name or info['label'], info.get('icon_png'), artwork)
|
||||
except (ValueError, OSError) as e:
|
||||
raise FrameError(f'could not prepare artwork: {e}') from e
|
||||
iid = instance_id(pkg)
|
||||
d = f'{APPS_DIR}/{pkg}'
|
||||
existing = read_meta(pkg)
|
||||
ok = False
|
||||
ok, created = False, None
|
||||
try:
|
||||
ssh(f'mkdir -p {d}')
|
||||
_copy(apk_path, f'{d}/app.apk.part')
|
||||
_copy(LAUNCHER, f'{d}/launch.sh', executable=True, timeout=120)
|
||||
icon = ''
|
||||
if info['icon_png']:
|
||||
ssh(f'cat > {d}/icon.png', input=info['icon_png'])
|
||||
icon = f'$HOME/{d}/icon.png'
|
||||
marker = f'touch {d}/lepton-show-flatscreen' if flatscreen else f'rm -f {d}/lepton-show-flatscreen'
|
||||
ssh(f'mv {d}/app.apk.part {d}/app.apk && echo {iid} > {d}/instance.id && {marker}')
|
||||
home = ssh('echo $HOME').strip()
|
||||
shortcut = _int((existing or {}).get('shortcut'))
|
||||
if not shortcut or shortcut not in _shortcut_ids():
|
||||
reply = shortcut_tool('add', name or info['label'], f'{home}/{d}/launch.sh', f'{home}/{d}',
|
||||
icon.replace('$HOME', home))
|
||||
'')
|
||||
shortcut = _int(reply.strip().splitlines()[-1] if reply.strip() else None)
|
||||
if not shortcut:
|
||||
raise FrameError(f'Steam did not return a shortcut id (got {reply[:80]!r})')
|
||||
created = shortcut
|
||||
presentation = apply_library(shortcut, name or info['label'], d, images,
|
||||
vr=not flatscreen, home=home,
|
||||
exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}',
|
||||
details={'package': pkg, 'version': info['version'],
|
||||
'source': source or os.path.basename(apk_path)})
|
||||
presentation['warnings'] = art_warnings + presentation.get('warnings', [])
|
||||
meta = {'package': pkg, 'label': name or info['label'], 'version': info['version'],
|
||||
'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut),
|
||||
'vr': info.get('vr', False), 'vr_issues': info.get('vr_issues', []),
|
||||
'launchable': info.get('launchable', False), 'patched': info.get('patched', []),
|
||||
'flatscreen': flatscreen, 'installed': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
'source': source or os.path.basename(apk_path)}
|
||||
'source': source or os.path.basename(apk_path),
|
||||
'library_warnings': presentation.get('warnings', []),
|
||||
'artwork': presentation.get('artwork', {}), 'library_version': 2}
|
||||
_write_meta(d, meta)
|
||||
ok = True
|
||||
return meta
|
||||
finally:
|
||||
if not ok and created:
|
||||
try:
|
||||
shortcut_tool('remove', str(created))
|
||||
except FrameError:
|
||||
pass
|
||||
if not ok and not existing:
|
||||
# A first install that failed part-way: don't leave an orphan folder behind.
|
||||
try:
|
||||
@@ -200,6 +219,101 @@ def _install(apk_path, info, pkg, flatscreen, name, source):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
def apply_library(shortcut, label, directory, images, vr=False, home=None, exe='', start_dir='', details=None,
|
||||
category='Android'):
|
||||
"""Mandatory for every sideload: render all five slots before reporting success."""
|
||||
home = home or ssh('echo $HOME').strip()
|
||||
d = directory
|
||||
ssh(f'mkdir -p {shlex.quote(d)}/artwork')
|
||||
paths = {}
|
||||
for slot, (ext, data) in images.items():
|
||||
path = f'{d}/artwork/source-{slot}.{ext}'
|
||||
ssh(f'cat > {shlex.quote(path)}', input=data)
|
||||
paths[slot] = f'{home}/{path}' if not path.startswith('/') else path
|
||||
manifest = {'label': label, 'images': paths}
|
||||
plan = f'{d}/artwork/input.json'
|
||||
ssh(f'cat > {shlex.quote(plan)}', input=json.dumps(manifest))
|
||||
absolute = f'{home}/{plan}' if not plan.startswith('/') else plan
|
||||
rendered = json.loads(shortcut_tool('render', absolute, timeout=120))
|
||||
art = rendered['paths']
|
||||
if set(art) != set(frame_artwork.SLOTS):
|
||||
raise FrameError('Steam artwork renderer did not produce every slot')
|
||||
result = json.loads(shortcut_tool('configure', str(shortcut), label, exe, start_dir, art['icon'],
|
||||
'1' if vr else '0', json.dumps(art),
|
||||
json.dumps({'category': category, 'details': details or {}}), timeout=120))
|
||||
result['warnings'] = rendered.get('warnings', []) + result.get('warnings', [])
|
||||
result['artwork'] = art
|
||||
if category == 'Android':
|
||||
ssh(f'cat > {shlex.quote(d)}/shortcut.id', input=str(int(shortcut)))
|
||||
return result
|
||||
|
||||
|
||||
def refresh_art(pkg=None, artwork=None):
|
||||
"""Refresh existing APK library entries without reinstalling or stopping them."""
|
||||
if pkg is None:
|
||||
results = []
|
||||
for app in list_apps():
|
||||
try:
|
||||
results.append(refresh_art(app['package'], artwork))
|
||||
except (FrameError, OSError, ValueError) as e:
|
||||
results.append({'package': app['package'], 'error': str(e)})
|
||||
return results
|
||||
with _install_lock:
|
||||
m = _meta_or_fail(pkg)
|
||||
d = f'{APPS_DIR}/{pkg}'
|
||||
# Parse the APK on the Frame, transferring only its icon/label, not the APK.
|
||||
modules = {}
|
||||
for module in ('frame_apk', 'frame_apk_vr'):
|
||||
with open(os.path.join(ROOT, 'ui', module + '.py')) as f:
|
||||
modules[module] = f.read()
|
||||
script = 'import sys, types, json, base64\n'
|
||||
for module, source in modules.items():
|
||||
script += f'm = types.ModuleType({module!r}); sys.modules[{module!r}] = m; exec({source!r}, m.__dict__)\n'
|
||||
script += f"info = sys.modules['frame_apk'].apk_info({(d + '/app.apk')!r})\n"
|
||||
script += "info['icon_png'] = base64.b64encode(info.get('icon_png') or b'').decode()\n"
|
||||
script += "import os\ninfo['artwork'] = {}\n"
|
||||
script += f"directory = os.path.realpath({d!r})\n"
|
||||
script += """try:
|
||||
with open(os.path.join(directory, 'artwork/input.json')) as f:
|
||||
cached = json.load(f)
|
||||
for slot, path in cached.get('images', {}).items():
|
||||
if os.path.commonpath([os.path.realpath(path), directory]) != directory:
|
||||
continue
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read(12 * 1024 * 1024 + 1)
|
||||
if len(data) <= 12 * 1024 * 1024:
|
||||
info['artwork'][slot] = base64.b64encode(data).decode()
|
||||
except (OSError, ValueError, TypeError):
|
||||
pass
|
||||
print(json.dumps(info))
|
||||
"""
|
||||
info = json.loads(ssh('python3 -', input=script))
|
||||
import base64
|
||||
icon = base64.b64decode(info['icon_png'])
|
||||
cached = {k: base64.b64decode(v) for k, v in info.get('artwork', {}).items()}
|
||||
images, warnings = frame_artwork.prepare(m['label'], icon, artwork if artwork is not None else cached)
|
||||
home = ssh('echo $HOME').strip()
|
||||
created = False
|
||||
if not m['shortcut'] or m['shortcut'] not in _shortcut_ids():
|
||||
m['shortcut'] = _int(shortcut_tool('add', m['label'], f'{home}/{d}/launch.sh', f'{home}/{d}'))
|
||||
if not m['shortcut']:
|
||||
raise FrameError('Steam did not create the missing shortcut')
|
||||
m['game_id'] = game_id(m['shortcut'])
|
||||
created = True
|
||||
try:
|
||||
result = apply_library(m['shortcut'], m['label'], d, images, vr=not m.get('flatscreen', True),
|
||||
home=home, exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}', details=m)
|
||||
except Exception:
|
||||
if created:
|
||||
shortcut_tool('remove', str(m['shortcut']))
|
||||
raise
|
||||
m.update(artwork=result.get('artwork', {}), library_version=2)
|
||||
m['library_warnings'] = warnings + result.get('warnings', [])
|
||||
m['artwork_refreshed'] = time.strftime('%Y-%m-%dT%H:%M:%S')
|
||||
_write_meta(d, m)
|
||||
return m
|
||||
|
||||
def _int(v):
|
||||
try:
|
||||
n = int(v)
|
||||
@@ -271,6 +385,11 @@ def launch(pkg):
|
||||
|
||||
def stop(pkg):
|
||||
m = _meta_or_fail(pkg)
|
||||
if m['shortcut']:
|
||||
try:
|
||||
shortcut_tool('stop', str(int(m['shortcut'])))
|
||||
except FrameError:
|
||||
pass # Steam unavailable: the container stop also ends its waiting launcher.
|
||||
ssh(f"podman stop -t 5 lepton-steamlaunch-{int(m['instance'])} >/dev/null 2>&1 || true", timeout=60)
|
||||
return m
|
||||
|
||||
@@ -279,10 +398,8 @@ def remove(pkg, keep_data=False):
|
||||
m = _meta_or_fail(pkg)
|
||||
stop(pkg)
|
||||
if m['shortcut']:
|
||||
try:
|
||||
shortcut_tool('remove', str(int(m['shortcut'])))
|
||||
except FrameError:
|
||||
pass # already gone from Steam
|
||||
result = json.loads(shortcut_tool('remove', str(int(m['shortcut']))) or '{}')
|
||||
m['library_warnings'] = result.get('warnings', [])
|
||||
iid = int(m['instance'])
|
||||
extra = '' if keep_data else f' {COMPAT}/{iid} {SHADERS}/{iid}'
|
||||
ssh(f'rm -rf {APPS_DIR}/{pkg}{extra}')
|
||||
@@ -369,6 +486,13 @@ def main():
|
||||
elif cmd == 'install':
|
||||
r = install(args[0], flatscreen=False if '--vr' in args else True if '--flat' in args else None,
|
||||
xr_compat=False if '--no-xr-compat' in args else None)
|
||||
elif cmd == 'refresh-art':
|
||||
if len(args) != 1:
|
||||
raise FrameError('usage: refresh-art PACKAGE or refresh-art --all')
|
||||
r = refresh_art(None if args[0] == '--all' else args[0])
|
||||
if isinstance(r, list) and any('error' in item for item in r):
|
||||
print(json.dumps(r, indent=1))
|
||||
raise SystemExit(1)
|
||||
elif cmd == 'patch':
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description='Patch and v2-sign an APK locally')
|
||||
|
||||
+4
-1
@@ -255,7 +255,10 @@ def _icon_png(z, names, icons):
|
||||
# Adaptive icons are XML; fall back to the largest launcher PNG.
|
||||
pngs = sorted((n for n in names if n.endswith('.png') and 'ic_launcher' in n and 'foreground' not in n),
|
||||
key=lambda n: z.getinfo(n).file_size)
|
||||
return _read(z, pngs[-1], MAX_ICON) if pngs else None
|
||||
if pngs:
|
||||
return _read(z, pngs[-1], MAX_ICON)
|
||||
# Godot exports can keep only an adaptive launcher plus the project icon.
|
||||
return _read(z, 'assets/icon.png', MAX_ICON) if 'assets/icon.png' in names else None
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Bounded artwork inputs for Steam library canvas rendering."""
|
||||
import struct
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zlib
|
||||
|
||||
SLOTS = {'grid': (600, 900), 'wide': (920, 430), 'hero': (3840, 1240),
|
||||
'logo': (1280, 480), 'icon': (256, 256)}
|
||||
MAX_IMAGE = 12 * 1024 * 1024
|
||||
MAX_PIXELS = 8_000_000
|
||||
PNG = b'\x89PNG\r\n\x1a\n'
|
||||
|
||||
|
||||
def chunk(kind, data):
|
||||
return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data))
|
||||
|
||||
|
||||
def png(width, height, pixels):
|
||||
stride = width * 4
|
||||
raw = b''.join(b'\0' + pixels[y * stride:(y + 1) * stride] for y in range(height))
|
||||
return PNG + chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 6, 0, 0, 0)) + \
|
||||
chunk(b'IDAT', zlib.compress(raw, 6)) + chunk(b'IEND', b'')
|
||||
|
||||
|
||||
def decode(data):
|
||||
"""Non-interlaced PNG, including packed palette/grayscale APK icons."""
|
||||
if not isinstance(data, bytes) or not data.startswith(PNG) or len(data) > MAX_IMAGE:
|
||||
raise ValueError('expected a PNG image (at most 12 MiB)')
|
||||
pos, packed, palette, alpha, header = 8, bytearray(), b'', b'', None
|
||||
while pos + 12 <= len(data):
|
||||
size = struct.unpack_from('>I', data, pos)[0]
|
||||
kind, body = data[pos + 4:pos + 8], data[pos + 8:pos + 8 + size]
|
||||
if pos + size + 12 > len(data):
|
||||
raise ValueError('truncated PNG')
|
||||
crc = struct.unpack_from('>I', data, pos + 8 + size)[0]
|
||||
if zlib.crc32(kind + body) != crc:
|
||||
raise ValueError('invalid PNG checksum')
|
||||
if kind == b'IHDR':
|
||||
if header is not None or size != 13:
|
||||
raise ValueError('invalid PNG header')
|
||||
header = struct.unpack('>IIBBBBB', body)
|
||||
elif kind == b'PLTE':
|
||||
palette = body
|
||||
elif kind == b'tRNS':
|
||||
alpha = body
|
||||
elif kind == b'IDAT':
|
||||
packed.extend(body)
|
||||
elif kind == b'IEND':
|
||||
break
|
||||
pos += size + 12
|
||||
else:
|
||||
raise ValueError('incomplete PNG')
|
||||
if header is None:
|
||||
raise ValueError('missing PNG header')
|
||||
w, h, depth, color, compression, filtering, interlace = header
|
||||
channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}.get(color)
|
||||
if not w or not h or w * h > MAX_PIXELS or w > 8192 or h > 8192:
|
||||
raise ValueError('PNG dimensions exceed limits')
|
||||
if not channels or compression or filtering or interlace or depth not in (1, 2, 4, 8) or (depth != 8 and color not in (0, 3)):
|
||||
raise ValueError('unsupported PNG encoding')
|
||||
stride, bpp = (w * channels * depth + 7) // 8, max(1, channels * depth // 8)
|
||||
expected = h * (stride + 1)
|
||||
decoder = zlib.decompressobj()
|
||||
raw = decoder.decompress(bytes(packed), expected + 1)
|
||||
if len(raw) != expected or not decoder.eof:
|
||||
raise ValueError('invalid PNG pixels')
|
||||
pixels, prev = bytearray(), bytearray(stride)
|
||||
for y in range(h):
|
||||
start = y * (stride + 1)
|
||||
method, row = raw[start], bytearray(raw[start + 1:start + 1 + stride])
|
||||
if method > 4:
|
||||
raise ValueError('invalid PNG filter')
|
||||
for x in range(stride):
|
||||
a, b, c = row[x - bpp] if x >= bpp else 0, prev[x], prev[x - bpp] if x >= bpp else 0
|
||||
if method == 1:
|
||||
row[x] = (row[x] + a) & 255
|
||||
elif method == 2:
|
||||
row[x] = (row[x] + b) & 255
|
||||
elif method == 3:
|
||||
row[x] = (row[x] + (a + b) // 2) & 255
|
||||
elif method == 4:
|
||||
p = a + b - c
|
||||
distances = (abs(p - a), abs(p - b), abs(p - c))
|
||||
row[x] = (row[x] + (a, b, c)[distances.index(min(distances))]) & 255
|
||||
for x in range(w):
|
||||
if depth < 8:
|
||||
value = (row[x * depth // 8] >> (8 - depth - x * depth % 8)) & ((1 << depth) - 1)
|
||||
values = [value]
|
||||
else:
|
||||
values = row[x * channels:(x + 1) * channels]
|
||||
if color == 3:
|
||||
i = values[0]
|
||||
if i * 3 + 3 > len(palette):
|
||||
raise ValueError('invalid PNG palette')
|
||||
rgba = palette[i * 3:i * 3 + 3] + bytes([alpha[i] if i < len(alpha) else 255])
|
||||
elif color in (0, 4):
|
||||
gray = values[0] * 255 // ((1 << depth) - 1)
|
||||
opacity = values[1] if color == 4 else (0 if alpha == struct.pack('>H', values[0]) else 255)
|
||||
rgba = bytes([gray, gray, gray, opacity])
|
||||
else:
|
||||
opacity = values[3] if color == 6 else (0 if alpha == struct.pack('>HHH', *values) else 255)
|
||||
rgba = bytes(values[:3]) + bytes([opacity])
|
||||
pixels.extend(rgba)
|
||||
prev = row
|
||||
return w, h, pixels
|
||||
|
||||
|
||||
def image_type(data):
|
||||
if not isinstance(data, bytes) or len(data) > MAX_IMAGE:
|
||||
raise ValueError('artwork must be image bytes or an HTTP(S) URL, at most 12 MiB')
|
||||
if data.startswith(PNG):
|
||||
try:
|
||||
decode(data)
|
||||
except (zlib.error, struct.error) as e:
|
||||
raise ValueError('invalid PNG image') from e
|
||||
return 'png'
|
||||
if data.startswith(b'\xff\xd8') and data.endswith(b'\xff\xd9'):
|
||||
# Check JPEG SOF dimensions without depending on an image library.
|
||||
pos = 2
|
||||
while pos + 4 <= len(data) and data[pos] == 255:
|
||||
marker = data[pos + 1]
|
||||
pos += 2
|
||||
if marker == 255:
|
||||
pos -= 1
|
||||
continue
|
||||
size = struct.unpack_from('>H', data, pos)[0]
|
||||
if size < 2 or pos + size > len(data):
|
||||
break
|
||||
if marker in (0xc0, 0xc1, 0xc2) and size >= 8:
|
||||
h, w = struct.unpack_from('>HH', data, pos + 3)
|
||||
if w and h and w * h <= MAX_PIXELS and max(w, h) <= 8192:
|
||||
return 'jpg'
|
||||
break
|
||||
pos += size
|
||||
raise ValueError('artwork must be a supported PNG or JPEG')
|
||||
|
||||
|
||||
def stamp(pixels, width, icon, x, y, size):
|
||||
iw, ih, source = icon
|
||||
dw, dh = max(1, size * iw // max(iw, ih)), max(1, size * ih // max(iw, ih))
|
||||
x, y = x + (size - dw) // 2, y + (size - dh) // 2
|
||||
for yy in range(dh):
|
||||
for xx in range(dw):
|
||||
src = ((yy * ih // dh) * iw + xx * iw // dw) * 4
|
||||
dst = ((y + yy) * width + x + xx) * 4
|
||||
a = source[src + 3]
|
||||
for c in range(3):
|
||||
pixels[dst + c] = (source[src + c] * a + pixels[dst + c] * (255 - a)) // 255
|
||||
pixels[dst + 3] = a + pixels[dst + 3] * (255 - a) // 255
|
||||
|
||||
|
||||
|
||||
def fetch(value):
|
||||
if isinstance(value, str):
|
||||
if urllib.parse.urlsplit(value).scheme not in ('http', 'https'):
|
||||
raise ValueError('artwork URLs must use HTTP(S)')
|
||||
request = urllib.request.Request(value, headers={'User-Agent': 'FrameControl/1.0'})
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
if urllib.parse.urlsplit(response.geturl()).scheme not in ('http', 'https'):
|
||||
raise ValueError('artwork redirect must use HTTP(S)')
|
||||
value = response.read(MAX_IMAGE + 1)
|
||||
return image_type(value), value
|
||||
|
||||
|
||||
def prepare(label, icon_png=None, artwork=None):
|
||||
"""Gather inputs; the Frame's Chromium canvas renders every final slot."""
|
||||
import frame_steamgriddb
|
||||
artwork = artwork or {}
|
||||
allowed = set(SLOTS) | {'banner', 'feature_graphic', 'screenshots', 'screenshot'}
|
||||
if not isinstance(artwork, dict) or set(artwork) - allowed:
|
||||
raise ValueError('unknown artwork slot')
|
||||
supplied, warnings = {}, []
|
||||
for slot, value in artwork.items():
|
||||
values = value if slot == 'screenshots' and isinstance(value, (list, tuple)) else [value]
|
||||
for candidate in values[:4]:
|
||||
try:
|
||||
supplied['screenshot' if slot == 'screenshots' else slot] = fetch(candidate)
|
||||
break
|
||||
except (ValueError, OSError):
|
||||
warnings.append('Source ' + slot + ' unavailable; using fallback art')
|
||||
if 'icon' not in supplied and icon_png:
|
||||
try:
|
||||
supplied['icon'] = fetch(icon_png)
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
provider, provider_warnings = frame_steamgriddb.lookup(label)
|
||||
warnings.extend(provider_warnings)
|
||||
for slot, value in provider.items():
|
||||
try:
|
||||
supplied[slot] = fetch(value)
|
||||
except (ValueError, OSError):
|
||||
warnings.append('SteamGridDB ' + slot + ' download failed; using fallback art')
|
||||
return supplied, warnings
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Optional SteamGridDB artwork. Credentials stay on the host, never in app metadata."""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
import frame_host
|
||||
|
||||
API = 'https://www.steamgriddb.com/api/v2'
|
||||
MAX_JSON = 2 * 1024 * 1024
|
||||
|
||||
|
||||
def settings_path():
|
||||
return frame_host.data_dir('artwork-settings.json')
|
||||
|
||||
|
||||
def api_key():
|
||||
env = os.environ.get('STEAMGRIDDB_API_KEY') or os.environ.get('FRAME_STEAMGRIDDB_API_KEY')
|
||||
if env:
|
||||
return env.strip()
|
||||
try:
|
||||
return str(json.loads(settings_path().read_text()).get('steamgriddb_api_key') or '')
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return ''
|
||||
|
||||
|
||||
def settings():
|
||||
return {'steamgriddb_configured': bool(api_key()),
|
||||
'environment': bool(os.environ.get('STEAMGRIDDB_API_KEY') or os.environ.get('FRAME_STEAMGRIDDB_API_KEY'))}
|
||||
|
||||
|
||||
def save_settings(body):
|
||||
key = body.get('steamgriddb_api_key')
|
||||
if not isinstance(key, str) or len(key) > 200 or (key and not re.fullmatch(r'[A-Za-z0-9_-]+', key)):
|
||||
raise ValueError('enter a valid SteamGridDB API key, or an empty value to remove it')
|
||||
path = settings_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temp = tempfile.mkstemp(prefix='.artwork-', dir=str(path.parent))
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
json.dump({'steamgriddb_api_key': key}, f)
|
||||
os.replace(temp, path)
|
||||
finally:
|
||||
if os.path.exists(temp):
|
||||
os.remove(temp)
|
||||
return settings()
|
||||
|
||||
|
||||
def _get(path, key):
|
||||
request = urllib.request.Request(API + path, headers={'Authorization': 'Bearer ' + key,
|
||||
'User-Agent': 'FrameControl/1.0'})
|
||||
# Do not carry the credential to redirects or include it in error messages.
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
with urllib.request.build_opener(NoRedirect()).open(request, timeout=12) as response:
|
||||
data = response.read(MAX_JSON + 1)
|
||||
if len(data) > MAX_JSON:
|
||||
raise ValueError('SteamGridDB response too large')
|
||||
result = json.loads(data)
|
||||
if not result.get('success') or not isinstance(result.get('data'), list):
|
||||
raise ValueError('SteamGridDB lookup failed')
|
||||
return result['data']
|
||||
|
||||
|
||||
def _name(value):
|
||||
return re.sub(r'[^a-z0-9]', '', str(value).casefold())
|
||||
|
||||
|
||||
def lookup(name):
|
||||
"""Best-voted art per slot for an exact title match; unrelated games are never guessed."""
|
||||
key = api_key()
|
||||
if not key:
|
||||
return {}, []
|
||||
try:
|
||||
matches = _get('/search/autocomplete/' + urllib.parse.quote(name, safe=''), key)
|
||||
names = {_name(name), _name(re.sub(r'\s+VR$', '', name, flags=re.I))}
|
||||
game = next((g for g in matches if _name(g.get('name')) in names), None)
|
||||
if not game:
|
||||
return {}, []
|
||||
gid = int(game['id'])
|
||||
result, warnings = {}, []
|
||||
for slot, kind, dimensions in [('grid', 'grids', '600x900'), ('wide', 'grids', '920x430'),
|
||||
('hero', 'heroes', ''), ('logo', 'logos', ''), ('icon', 'icons', '')]:
|
||||
try:
|
||||
query = {'types': 'static', 'nsfw': 'false', 'humor': 'false', 'mimes': 'image/png,image/jpeg'}
|
||||
if dimensions:
|
||||
query['dimensions'] = dimensions
|
||||
records = _get('/' + kind + '/game/' + str(gid) + '?' + urllib.parse.urlencode(query), key)
|
||||
records = [r for r in records if r.get('url', '').startswith('https://') and not r.get('nsfw')]
|
||||
if dimensions:
|
||||
w, h = map(int, dimensions.split('x'))
|
||||
records = [r for r in records if (r.get('width'), r.get('height')) == (w, h)]
|
||||
records.sort(key=lambda r: (int(r.get('score') or 0), int(r.get('upvotes') or 0)), reverse=True)
|
||||
if records:
|
||||
result[slot] = records[0]['url']
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
warnings.append('SteamGridDB ' + slot + ' unavailable; using source or generated art')
|
||||
return result, warnings
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
return {}, ['SteamGridDB unavailable; using source or generated art']
|
||||
+50
-5
@@ -632,7 +632,7 @@ def _rsync():
|
||||
_install_lock = threading.Lock()
|
||||
|
||||
|
||||
def install(path, name=None, exe=None, runtime=None, progress=None):
|
||||
def install(path, name=None, exe=None, runtime=None, progress=None, artwork=None):
|
||||
"""Sideload a .zip, folder or executable as a Devkit Game; returns the title dict.
|
||||
|
||||
name: the Steam name (sanitised to the title id), default from the file name.
|
||||
@@ -643,12 +643,12 @@ def install(path, name=None, exe=None, runtime=None, progress=None):
|
||||
"""
|
||||
plan = inspect(path, name)
|
||||
try:
|
||||
return install_plan(plan, name=name, exe=exe, runtime=runtime, progress=progress)
|
||||
return install_plan(plan, name=name, exe=exe, runtime=runtime, progress=progress, artwork=artwork)
|
||||
finally:
|
||||
discard(plan)
|
||||
|
||||
|
||||
def install_plan(plan, name=None, exe=None, runtime=None, progress=None):
|
||||
def install_plan(plan, name=None, exe=None, runtime=None, progress=None, artwork=None):
|
||||
"""Install an inspect() plan, optionally with another name, target or runtime."""
|
||||
if name:
|
||||
plan['name'], plan['id'] = name, title_id(name)
|
||||
@@ -656,13 +656,21 @@ def install_plan(plan, name=None, exe=None, runtime=None, progress=None):
|
||||
_choose(plan, exe or plan['target'], runtime)
|
||||
step = progress or (lambda *a: None)
|
||||
with _install_lock:
|
||||
return _install(plan, step)
|
||||
return _install(plan, step, artwork)
|
||||
|
||||
|
||||
def _install(plan, step):
|
||||
def _install(plan, step, artwork=None):
|
||||
gid = plan['id']
|
||||
if not NEW_ID_RE.match(gid) or gid.lower() in RESERVED_IDS:
|
||||
raise FrameError(f'bad title id {gid!r}')
|
||||
icon = None
|
||||
for filename in ('icon.png', 'logo.png'):
|
||||
path = os.path.join(plan['root'], filename)
|
||||
if os.path.isfile(path):
|
||||
with open(path, 'rb') as f:
|
||||
icon = f.read(frame_android.frame_artwork.MAX_IMAGE + 1)
|
||||
break
|
||||
images, warnings = frame_android.frame_artwork.prepare(plan['name'], icon, artwork)
|
||||
step("Syncing Valve's devkit tools to the Frame", 0.02)
|
||||
ensure_utils()
|
||||
existed = ssh(f'test -d {GAMES}/{gid} && echo yes || true', timeout=30).strip() == 'yes'
|
||||
@@ -672,6 +680,7 @@ def _install(plan, step):
|
||||
if not DIR_RE.match(directory) or not directory.endswith(f'/{GAMES}/{gid}'):
|
||||
raise FrameError(f'steamos-prepare-upload returned an unexpected folder {directory!r}')
|
||||
registered = False
|
||||
library_ready, shortcut, steam_registered = False, None, False
|
||||
try:
|
||||
step(f"Copying {plan['size'] / 1e6:.0f} MB to the Frame", 0.1)
|
||||
if _rsync():
|
||||
@@ -694,10 +703,26 @@ def _install(plan, step):
|
||||
err = str(reply['error']).strip().rstrip('.')
|
||||
hint = ' With Steam running on the Frame, install it again.' if 'not running' in err else ''
|
||||
raise FrameError(f"Uploaded, but Steam didn't register it: {err}.{hint}")
|
||||
steam_registered = True
|
||||
shortcut = _library_shortcut(gid, plan['name'])
|
||||
if not shortcut:
|
||||
raise FrameError('Steam registered the title but its shortcut is not available for mandatory artwork; retry install')
|
||||
result = frame_android.apply_library(shortcut, plan['name'], directory + '/.frame-artwork', images,
|
||||
category='Sideloaded', details={'source': plan['source']})
|
||||
meta.update(shortcut=shortcut, library_warnings=warnings + result.get('warnings', []))
|
||||
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
|
||||
library_ready = True
|
||||
step('Done', 1.0)
|
||||
meta.update(runtime_label=RUNTIMES[plan['runtime']]['label'], steam=str(reply.get('success', '')).strip())
|
||||
return meta
|
||||
finally:
|
||||
if steam_registered and not library_ready and not existed:
|
||||
# A newly registered title must not remain as a blank library tile.
|
||||
if shortcut:
|
||||
frame_android.shortcut_tool('remove', str(shortcut))
|
||||
else:
|
||||
ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120)
|
||||
registered = False
|
||||
if not registered and not existed:
|
||||
# A first install that failed part-way: don't leave an orphan folder behind.
|
||||
try:
|
||||
@@ -706,6 +731,23 @@ def _install(plan, step):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
def _library_shortcut(gid, name):
|
||||
shortcuts = json.loads(frame_android.shortcut_tool('list'))
|
||||
matches = [s for s in shortcuts if s.get('devkit_gameid') == gid]
|
||||
if not matches:
|
||||
try:
|
||||
meta = json.loads(ssh(f'cat {GAMES}/{gid}-framecontrol.json 2>/dev/null || true'))
|
||||
matches = [s for s in shortcuts if s.get('appid') == meta.get('shortcut')]
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
if not matches:
|
||||
matches = [s for s in shortcuts if s.get('name') in (gid, name)]
|
||||
if len(matches) > 1:
|
||||
raise FrameError('ambiguous Steam shortcut for ' + gid)
|
||||
return int(matches[0]['appid']) if matches else None
|
||||
|
||||
|
||||
LIST_SCRIPT = r'''
|
||||
import json, os
|
||||
root = os.path.expanduser('~/devkit-game')
|
||||
@@ -772,6 +814,9 @@ def remove(gid):
|
||||
try:
|
||||
gid = _check_id(gid)
|
||||
ensure_utils()
|
||||
shortcut = _library_shortcut(gid, gid)
|
||||
if shortcut:
|
||||
frame_android.shortcut_tool('remove', str(shortcut))
|
||||
# steamos-delete removes the folder and syncs Steam's shortcuts; its json files stay, so clear them too.
|
||||
ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120)
|
||||
ssh(f'rm -f {_json_files(gid)}', timeout=30)
|
||||
|
||||
@@ -784,6 +784,19 @@
|
||||
</section>
|
||||
|
||||
|
||||
<section class="panel" id="artworkSettings">
|
||||
<div class="shelf-head"><h2>Library artwork settings</h2></div>
|
||||
<p class="sub">SteamGridDB supplies community artwork for sideloaded games. Without a key, Frame Control uses source images and creates the remaining art.</p>
|
||||
<label for="steamGridKey">SteamGridDB API key</label>
|
||||
<input type="password" id="steamGridKey" autocomplete="off" spellcheck="false" placeholder="Optional API key">
|
||||
<div class="row" style="margin-top:8px">
|
||||
<button id="saveSteamGridKey" class="action small">Save key</button>
|
||||
<button id="clearSteamGridKey" class="small">Remove saved key</button>
|
||||
<a href="https://www.steamgriddb.com/profile/preferences/api" target="_blank" rel="noreferrer">Get a free key</a>
|
||||
</div>
|
||||
<div id="steamGridStatus" class="hint" role="status"></div>
|
||||
<button id="refreshAndroidArt" class="small" style="margin-top:12px">Refresh installed Android artwork</button>
|
||||
</section>
|
||||
<section class="panel" id="power">
|
||||
<div class="shelf-head"><h2>Remote & power</h2></div>
|
||||
<div class="actions">
|
||||
@@ -2625,5 +2638,6 @@ setView("headset");
|
||||
refresh().then(loadShots); // after status, so app names resolve
|
||||
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
|
||||
</script>
|
||||
<script src="/artwork-settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+13
-1
@@ -40,6 +40,7 @@ import frame_android # noqa: E402
|
||||
from apk_sources import search as apk_search, SourceError # noqa: E402
|
||||
import frame_apk_versions # noqa: E402
|
||||
import frame_catalog # noqa: E402
|
||||
import frame_steamgriddb
|
||||
import frame_host # noqa: E402
|
||||
import frame_store # noqa: E402
|
||||
import frame_titles # noqa: E402
|
||||
@@ -595,6 +596,11 @@ def android(body):
|
||||
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.",
|
||||
"app": m}
|
||||
return start_job(f"Install {pkg}", work)
|
||||
if action == "refresh-art":
|
||||
if not pkg and not body.get("all"):
|
||||
raise Failure('choose a package or all apps', 400)
|
||||
return start_job('Refresh Steam artwork', lambda: {
|
||||
'apps': frame_android.refresh_art(None if body.get('all') else pkg)})
|
||||
if action in ("launch", "stop"):
|
||||
m = getattr(frame_android, action)(pkg)
|
||||
return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"}
|
||||
@@ -1305,7 +1311,9 @@ def source_manage(body):
|
||||
raise Failure(str(e), 400)
|
||||
|
||||
|
||||
POST = {"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
POST = {
|
||||
"/api/settings/artwork": frame_steamgriddb.save_settings,
|
||||
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
|
||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||
"/api/webinstall/cancel": webinstall_cancel}
|
||||
@@ -1409,6 +1417,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
try:
|
||||
if path in ("/", "/index.html"):
|
||||
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
|
||||
elif path == "/api/settings/artwork":
|
||||
self.send_json(frame_steamgriddb.settings())
|
||||
elif path == "/artwork-settings.js":
|
||||
self.send_bytes((HERE / 'artwork-settings.js').read_bytes(), 'text/javascript; charset=utf-8')
|
||||
elif path == "/api/host":
|
||||
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
|
||||
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
||||
|
||||
Reference in new issue
Block a user