Render complete Steam artwork for every sideload and fix VR shortcut identity

Add optional SteamGridDB settings, source-first fallbacks rendered with Steam canvas, backfill commands and shared APK/native library details. Verify live artwork and Open Saber Steam Play/Stop; document SuperTux's clipboard crash.

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
This commit is contained in:
saphidandGPT-6 Astra committed 2026-09-28 22:20:27 +10:00
1 parent c06b3285f2
commit f695f398de
18 files changed
+1097 -289

No files matched your search

+118 -97
View File
@@ -1,105 +1,126 @@
# vr-library implementation notes
- Scope: `/tmp/vrapk/p1.md`; work only in steam-frame-vrlib, no delegation,
no push/PR/issues. Parent handles independent review.
- Read source interface, APK/VR docs, catalogue docs and installer/catalogue,
Frame skill and device architecture docs.
- Registry validation passed; no Steam Frame capability in rendered entries.
Prior-work skill absent at both skill roots. Existing worktree explicitly
authorized by task; no portfolio registration or out-of-worktree writes.
- Device preflight: SSH alias timed out; frame.local does not resolve. No
sessions started or stopped; no settings changed. Device evidence pending.
- Current launcher execs setsid --wait, replacing the wrapper and providing
no TERM/INT/HUP cleanup for its podman container. Need testable supervisor.
- Artwork: installer currently copies only APK icon; shortcut updates do not
refresh existing name/icon. Plan stdlib PNG fallbacks and Steam grid files.
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 and evidence
## Implementation
- Added `ui/frame_artwork.py`: bounded non-interlaced PNG decode/composition,
five fallback slots, transparent logo, supplied PNG/JPEG bytes or HTTP(S)
URLs. Standard library only; ASCII lettering, original label in native UI.
- Small additive `ui/frame_apk.py` fallback finds `assets/icon.png` in Open
Saber Plus (its manifest/adaptive icon yielded no PNG before). SuperTux's
normal APK icon works. Generated all slots from both supplied APKs and
visually inspected the individual grids plus contact sheet.
- `install(..., artwork=None)` forwards artwork through patching, stages it,
refreshes existing shortcuts instead of duplicating them, marks immersive
shortcuts VR, and stores presentation warnings in meta.json. Source images
retain their dimensions; generated slots use the requested Steam sizes.
- Frame helper uses native SetCustomArtworkForApp/ClearCustomArtworkForApp.
Collections use collectionStore: create static Android/Android VR, preserve
other members and dynamic/read-only names, remove obsolete membership.
- Remove clears artwork/managed memberships before app-folder deletion. A
Steam cleanup failure leaves local metadata for a retry. Failed new install
attempts to remove a newly created shortcut.
- Root-cause feedback loop: original launcher failed both cleanup tests
(no `podman stop` on TERM or normal exit). It exec'd setsid without a signal
handler. New supervising shell stays in Steam's tracked tree, handles
TERM/INT/HUP, stops its container, terminates its child process group, and
keeps the Lepton exit code. Lock + pre-existing container check prevent a
second launch from stopping someone else's session. Data paths unchanged.
- Stop helper calls TerminateApp with the exact 64-bit game-ID string (not
the 32-bit app ID, and not a lossy JavaScript number). Frame Control also
directly stops the container if Steam is unavailable.
- API signatures/collection semantics read from public source, not guessed:
[Steam client types](https://github.com/SteamDeckHomebrew/decky-frontend-lib/blob/main/src/globals/steam-client/App.ts)
and [Steam UI](https://github.com/SteamDatabase/SteamTracking/blob/master/ClientExtracted/steamui/chunk~2dcc5aaf7.js).
The fakeframe CEF/podman fixtures were extended accordingly; their new
library methods are explicitly source-derived, not recorded device calls.
- 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.
## Verification (2026-09-28)
## Visual critique and iterations
- `python3 -m unittest discover -s tests -p test_frame_android_library.py`:
initial two launcher cases FAILED on the old implementation; the focused
suite passed after the changes (20 cases at that run, then one JPEG case
added and covered by the final whole-suite run).
- `python3 -m unittest discover -s tests`: **187 tests, OK**, exit 0,
9.948 seconds. Actual local runtime is **Python 3.9.6** (`python3 --version`),
not just a syntax-compatibility check. Log: `/tmp/vrlib-evidence/tests.log`.
- `bash -n frame/android/lepton-app.sh`, `node --check
tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js`, Python AST parse
with `feature_version=(3,9)`, and `git diff --check`: exit 0.
- Generated self-contained Linux harness using the actual launcher and the
fixture Lepton/podman, retaining the Frame's real setsid/flock:
`ssh -o BatchMode=yes -o ConnectTimeout=8 frame 'python3 - 2>&1' <
/tmp/vrlib-evidence/linux-launcher-check.py` — **3 tests, OK**, exit 0;
normal exit + TERM + INT/HUP subcases, preserved saved-data sentinel.
It uses a TemporaryDirectory; no installed game or global setting touched.
Log: `/tmp/vrlib-evidence/linux-launcher-tests.log`.
- `ssh -o BatchMode=yes -o ConnectTimeout=8 frame python3 - <
ui/frame_vrshot.py`: exit 0; returned `/tmp/frame-vrcap/shot-28947-vr.png`.
Copied to `/tmp/vrlib-evidence/headset-preflight.png`, then removed only the
temporary remote screenshot. The capture shows Steam's startup error.
- Source APKs were read from `/tmp/vrapk/opensaberplus.apk` and
`/tmp/vrapk/supertux.apk`. Ten generated assets plus
`/tmp/vrlib-evidence/artwork-preview.png` are local artwork previews,
**not Steam UI screenshots**.
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.
## Device blocker and open questions
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.
- SSH initially timed out; later reachable on BUILD_ID 20260925.6191901,
SteamVR 2.18.1. `podman ps` was empty and vrserver showed no current game
scene. Steam CDP 8080 refused connections; no steamwebhelper was running.
The updater repeatedly extracted/installed, and the headset displayed
"There was an issue launching Steam". Bounded retries remained unavailable;
console_log had no new game launch entries. Captures/logs live under
`/tmp/vrlib-evidence/` (`device-preflight.log`, `device-final.log`).
- No real APK was reinstalled/launched/stopped. Therefore actual Steam Play
and Stop/reaper tracking, app data across a real stop/relaunch, native
library artwork display, collection persistence, VR scene attribution and
automatic dashboard dismissal are **not verified**. No Steam library
screenshot was possible. Next: restore Steam, reinstall the two APKs,
test one brief Steam launch/Stop at a time, capture library/headset output,
and check podman + console_log after each transition.
- VR shortcuts are marked through SetShortcutIsVR. Source inspection shows
Steam Resume hides the dashboard only when scene-app ID matches the
shortcut; whether Lepton gets that attribution remains open. No automatic
dashboard hiding or permanent standby workaround added. Power settings
were never changed, so no restoration was needed.
- Full fakeframe container E2E suite was not run; its updated JS fixture was
exercised by the local Node/V8 test. Native Windows install path untested.
- No independent reviewer process launched: the explicit brief forbids
delegation and assigns review/integration to the parent. No review claimed.
- No registry observation write outside this worktree: brief explicitly
restricts work to this checkout (plus its requested evidence directory).
Final sheet `/tmp/vrlib-evidence/artwork-preview-final.png` is an artwork
preview, NOT a Steam UI screenshot. Rows: portrait, wide, hero, logo/icon.
## Device verification (2026-09-28, build 20260925.6191901, SteamVR 2.18.1)
Steam was initially unavailable (old notes/evidence), then recovered. Both
APKs were reinstalled and refreshed successfully with zero library warnings:
- Open Saber Plus: org.godotengine.open_saber_plus, instance 2802929330,
shortcut 3346865537, game ID 14374678025558032384.
- SuperTux: org.supertux.supertux2, instance 2811892472,
shortcut 2883168793, game ID 12383115674816348160.
`steam-cache-final.log`: both actual Steam cache sets are 600x900, 920x430,
3840x1240, 1280x480, with app icon 256x256. `steam-details-targets.json`:
both correct names/sort-as, Android + Android VR, existing Played preserved.
Native managed note readback result 1 (success), saved as steam-notes-final.json.
Open Saber first session: Steam tracked it for 32 seconds; process and
container remained alive until only SteamClient TerminateApp was called.
After identity fix, a second session remained alive at 22 seconds (Android
PID 992), SteamVR identified steam.app.3346865537, and screenshot shows the
actual game scene instead of blank Resume tile. Steam Stop removed tracked
process and container within ~5 seconds. No direct podman-stop fallback was
used for this verification. Same data paths; existing game play count shown
in capture, but no separate save-file sentinel added to the real game.
Evidence: opensaber-identity-session.log, opensaber-identity-headset.png,
opensaber-running.log, opensaber-steam-stop.log, opensaber-headset-running.png.
SuperTux: Steam Play tracked the wrapper for ~17 seconds. SDLClipboardHandler
crashes with NullPointerException on missing ClipboardManager during activity
creation. Lepton and supervisor then cleaned up; it never reached a VR scene.
Steam Stop was issued after the crash, so sustained SuperTux Stop is NOT proven.
Evidence: supertux-running.log, supertux-lepton.log, supertux-shot.json.
Other test activity appeared on the device during checks (other Android
container and Gravitas). Neither was stopped or modified. Final Open Saber
preflight had no Android containers; other desktop overlays later appeared
in its headset capture. No global VR standby/dashboard settings changed.
Direct Steam UI Page.captureScreenshot timed out; no library UI screenshot.
## Automated verification and limits
- Whole suite: `python3 -m unittest discover -s tests`, Python 3.9.6. Final
result: **206 tests OK, 10.294 seconds, exit 0**. Log:
`/tmp/vrlib-evidence/tests-final.log`.
- Offline entrypoint tests exercise real shared install/render/configure flow
with SSH/API mocked: CLI, upload, catalogue, versions, web download, source
seam, native title and refresh. Provider ranking/failure/settings tests.
- Node contract test runs actual renderer against explicitly synthetic canvas,
validates all PNG dimensions and hero/logo text placement. This canvas is
also used by fakeframe; transparent fixture images are not visual evidence.
- Launcher tests assert stable context plus shortcut passthrough, signals,
normal exit, duplicate guard and saved-data sentinel. Earlier real Linux
setsid/flock fixture run: 3 tests OK (linux-launcher-tests.log).
- `bash -n`, Node syntax checks, Python AST feature_version=(3,9), diff checks.
- Docker E2E not run: `docker info --format '{{.ServerVersion}}'` exits 1;
daemon socket /var/run/docker.sock does not exist.
- Authenticated SGDB lookup/download unverified (no key configured).
- Windows/Linux packaged binaries not built/launched; filters tested.
- Native devkit art uses shared tested seam; no additional native title was
installed on device. Sibling source-search endpoint not in this worktree:
only its public installer contract is tested.
- Independent review not spawned: explicit brief forbids delegation and assigns
parent review/integration. No other provider/model participation claimed.
+4 -2
View File
@@ -46,7 +46,8 @@
"to": "ui",
"filter": [
"*.py",
"*.html"
"*.html",
"*.js"
]
},
{
@@ -61,7 +62,8 @@
"to": "frame/android",
"filter": [
"*.sh",
"*.py"
"*.py",
"*.js"
]
},
{
+86 -39
View File
@@ -86,50 +86,97 @@ Khronos-style loader and found SteamVR through `/vendor`.
## In the Steam library
**Implemented; Steam UI behavior still inferred (2026-09-28).** Installs and
updates refresh the same shortcut's name, VR flag and five artwork slots:
600×900 grid, 920×430 wide capsule, 3840×1240 hero, transparent 1280×480 logo
and 256×256 icon. Stdlib PNG fallbacks use the APK icon and label (including
Godot's `assets/icon.png`); unsupported/missing icons get a letter tile.
Generated lettering uses ASCII; Steam's native title retains the full label.
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.
Sources can pass `install(apk_path, artwork={'hero': image_bytes_or_http_url})`.
Slots are `grid`, `wide`, `hero`, `logo`, `icon`; supplied PNG/JPEG images keep
their dimensions. Limits: 12 MiB and 8 million pixels per image. The PNG
reader accepts non-interlaced 1/2/4-bit palette/grayscale and 8-bit
RGB/RGBA/grayscale. PNG icons also seed missing slots. Images are staged
inside the app directory, then applied through Steam's custom-artwork API.
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.
Immersive installs join **Android** and **Android VR** and get
`SetShortcutIsVR`; `--flat` installs join **Android** only. Existing dynamic
or read-only collections are preserved. Unsupported APIs/collection conflicts
appear in `library_warnings`. Removal clears all five custom slots and managed
memberships before deleting the shortcut and folder; `--keep-data` retains
app data. Failed Steam cleanup leaves metadata for a retry.
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.
The launcher stays alive around Lepton's separate session and handles
TERM/INT/HUP by stopping its own container and child process group. Normal
exit also cleans up. A lock and container check refuse duplicate launches.
The Stop helper uses `SteamClient.Apps.TerminateApp` with the exact 64-bit
game ID string, matching Steam's Stop action; Frame Control also falls back
to a direct container stop. Persistent data remains under the same instance ID.
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.
**Verified:** offline artwork, mocked SSH and V8 API tests; signal and
normal-exit tests on the Frame with real Linux `setsid`/`flock` and fake
Lepton/podman. **Not verified:** actual Steam Play/Stop, library rendering,
collections and direct-to-scene launch. On build `20260925.6191901`, the
headset showed “There was an issue launching Steam”, CDP port 8080 refused
connections and the updater was stuck. Capture:
`/tmp/vrlib-evidence/headset-preflight.png` on the development Mac. No installed
apps or global VR settings were changed during verification.
Backfill installed Android apps without reinstalling or stopping them:
The VR flag does not override wear detection. Steam's Resume action hides
the dashboard when its scene-app ID matches the shortcut (inferred from
[Steam's UI source](https://github.com/SteamDatabase/SteamTracking/blob/master/ClientExtracted/steamui/chunk~2dcc5aaf7.js)).
Whether Lepton gets that association still needs a headset test. No forced
hide or power override is installed. If using temporary standby settings
for unattended testing, restore `power.pauseCompositorOnStandby 1` and
`power.turnOffScreensTimeout 5`; see [the device notes](how-the-frame-works.md).
```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
+7
View File
@@ -20,6 +20,13 @@ 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"
+144
View File
@@ -0,0 +1,144 @@
// Runs in Steam's Chromium context: identical fonts/rendering from every host OS.
async function renderLibraryArtwork(input) {
const sizes = {grid:[600,900], wide:[920,430], hero:[3840,1240], logo:[1280,480], icon:[256,256]};
const label = String(input.label || 'Untitled').trim().slice(0,180);
const font = '"Motiva Sans", "Noto Sans", Arial, sans-serif';
await document.fonts.load(`800 120px ${font}`, label);
const images = {}, warnings = [];
for (const [slot, item] of Object.entries(input.images || {})) {
try {
const img = new Image();
img.src = `data:image/${item[0]};base64,${item[1]}`;
await img.decode();
if (!img.width || !img.height || img.width*img.height > 8000000) throw Error('dimensions');
images[slot] = img;
} catch (_) { warnings.push(`${slot} could not be decoded; generated art used`); }
}
let icon = images.icon;
if (icon) {
// Remove only a near-black matte connected to the outside of an opaque icon.
const cut=document.createElement('canvas');cut.width=icon.width;cut.height=icon.height;
const c=cut.getContext('2d');c.drawImage(icon,0,0);
const pixels=c.getImageData(0,0,cut.width,cut.height), d=pixels.data, w=cut.width,h=cut.height;
const corners=[0,w-1,(h-1)*w,h*w-1];
if(corners.every(i=>d[i*4+3]>250 && Math.max(d[i*4],d[i*4+1],d[i*4+2])<24)) {
const seen=new Uint8Array(w*h), queue=corners.slice();
for(let q=0;q<queue.length;q++) {
const i=queue[q];if(seen[i])continue;seen[i]=1;
if(Math.max(d[i*4],d[i*4+1],d[i*4+2])>24)continue;
d[i*4+3]=0;
if(i%w)queue.push(i-1);if(i%w<w-1)queue.push(i+1);
if(i>=w)queue.push(i-w);if(i<w*(h-1))queue.push(i+w);
}
c.putImageData(pixels,0,0);icon=cut;
}
}
// Quantized, saturated dominant colors avoid white/black icon margins.
let colors = [[48,91,137], [24,36,63]];
if (icon) {
const sample = document.createElement('canvas'); sample.width=48; sample.height=48;
const s=sample.getContext('2d'); s.drawImage(icon,0,0,48,48);
const data=s.getImageData(0,0,48,48).data, bins=new Map();
for (let i=0;i<data.length;i+=4) {
const rgb=[data[i],data[i+1],data[i+2]], hi=Math.max(...rgb), lo=Math.min(...rgb);
if (data[i+3]<150 || hi<45 || lo>220 || hi-lo<25) continue;
const key=rgb.map(v=>Math.round(v/32)*32).join(',');
bins.set(key,(bins.get(key)||0)+1);
}
const ranked=[...bins].sort((a,b)=>b[1]-a[1]);
if (ranked.length) {
colors[0]=ranked[0][0].split(',').map(Number);
colors[1]=(ranked.find(([key])=>key.split(',').reduce((n,v,i)=>n+Math.abs(Number(v)-colors[0][i]),0)>170)||ranked[0])[0].split(',').map(Number);
}
}
// Preserve hue while lifting muted icon colors into a richer background palette.
colors=colors.map(c=>{const low=Math.min(...c),range=Math.max(...c)-low||1;
return c.map(v=>45+(v-low)/range*165);});
const rgb=(c,a=1)=>`rgba(${c.map(v=>Math.min(255,Math.round(v))).join(',')},${a})`;
function image(ctx,img,x,y,w,h,cover=false) {
const scale=cover?Math.max(w/img.width,h/img.height):Math.min(w/img.width,h/img.height);
const dw=img.width*scale,dh=img.height*scale;
ctx.save(); ctx.beginPath(); ctx.rect(x,y,w,h); ctx.clip();
ctx.drawImage(img,x+(w-dw)/2,y+(h-dh)/2,dw,dh); ctx.restore();
}
function title(ctx,w,h,top,bottom,maxSize) {
let lines=[],size=maxSize;
const maxWidth=w*.84;
for (;size>=18;size-=2) {
ctx.font=`800 ${size}px ${font}`;
lines=[]; let line='';
for (const word of label.split(/\s+/)) {
const next=line?line+' '+word:word;
if (line && ctx.measureText(next).width>maxWidth) {lines.push(line);line=word;} else line=next;
}
lines.push(line);
if (lines.length*size*1.08<=bottom-top && lines.every(l=>ctx.measureText(l).width<=maxWidth)) break;
}
if(lines.length===2) {
const words=lines[0].split(' ');
if(words.length>1) {
const first=words.slice(0,-1).join(' '), second=words.slice(-1)[0]+' '+lines[1];
if(ctx.measureText(second).width<=maxWidth &&
Math.abs(ctx.measureText(first).width-ctx.measureText(second).width)<
Math.abs(ctx.measureText(lines[0]).width-ctx.measureText(lines[1]).width)) lines=[first,second];
}
}
// A long unbroken label is still fitted, including scripts without spaces.
ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillStyle='#fff';
ctx.shadowColor='rgba(0,0,0,.45)'; ctx.shadowBlur=size*.28; ctx.shadowOffsetY=size*.06;
let y=top+(bottom-top-lines.length*size*1.08)/2+size*.54;
for (const line of lines) {ctx.fillText(line,w/2,y,maxWidth); y+=size*1.08;}
ctx.shadowBlur=0; ctx.shadowOffsetY=0;
}
const result={};
for (const [slot,[w,h]] of Object.entries(sizes)) {
const canvas=document.createElement('canvas'); canvas.width=w; canvas.height=h;
const ctx=canvas.getContext('2d'); ctx.imageSmoothingQuality='high';
const direct=images[slot];
const feature=images.feature_graphic||images.banner;
const scene=direct || ((slot==='hero'||slot==='wide') && (feature||images.screenshot));
if (scene) {
if (slot==='logo'||slot==='icon') image(ctx,scene,0,0,w,h);
else image(ctx,scene,0,0,w,h,true);
} else if (slot==='logo') {
title(ctx,w,h,h*.08,h*.92,150);
} else {
const gradient=ctx.createLinearGradient(0,0,w,h);
gradient.addColorStop(0,rgb(colors[0].map(v=>v*.68)));
gradient.addColorStop(.6,rgb(colors[1].map(v=>v*.32)));
gradient.addColorStop(1,'#080c16'); ctx.fillStyle=gradient;ctx.fillRect(0,0,w,h);
if (icon) {
ctx.save();ctx.globalAlpha=.16;ctx.filter=`blur(${Math.round(w*.055)}px) saturate(1.4)`;
image(ctx,icon,-w*.15,-h*.15,w*1.3,h*1.3,true);ctx.restore();
}
const glow=ctx.createRadialGradient(w*.5,h*.32,0,w*.5,h*.32,w*.8);
glow.addColorStop(0,rgb(colors[0],.27));glow.addColorStop(1,rgb(colors[1],0));
ctx.fillStyle=glow;ctx.fillRect(0,0,w,h);
const vignette=ctx.createLinearGradient(0,h*.25,0,h);
vignette.addColorStop(0,'rgba(0,0,0,0)');vignette.addColorStop(1,'rgba(0,0,0,.56)');
ctx.fillStyle=vignette;ctx.fillRect(0,0,w,h);
const box=slot==='grid'?[w*.12,h*.14,w*.76,w*.76]:
slot==='wide'?[w*.36,h*.06,w*.28,h*.59]:
slot==='hero'?[w*.365,h*.12,w*.27,h*.78]:[w*.08,h*.08,w*.84,h*.84];
if (icon) {
ctx.save();ctx.shadowColor='rgba(0,0,0,.65)';ctx.shadowBlur=Math.min(w,h)*.055;
ctx.shadowOffsetY=Math.min(w,h)*.022;
// Opaque square icons read as deliberate app tiles, not pasted rectangles.
const [x,y,bw,bh]=box, side=Math.min(bw,bh);
if(slot!=='hero' && icon===images.icon) {
ctx.beginPath();ctx.roundRect(x+(bw-side)/2,y+(bh-side)/2,side,side,side*.14);ctx.clip();
}
image(ctx,icon,...box);ctx.restore();
} else if (slot !== 'hero') {
// A typographic monogram when the APK contains no usable image.
ctx.font=`800 ${Math.min(w,h)*.48}px ${font}`;ctx.fillStyle='rgba(255,255,255,.94)';
ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText([...label][0]||'A',w/2,h*.38);
}
if (slot==='grid') title(ctx,w,h,h*.7,h*.93,66);
if (slot==='wide') title(ctx,w,h,h*.69,h*.92,52);
// Hero intentionally has no title: Steam overlays the transparent logo.
}
result[slot]=canvas.toDataURL('image/png').split(',')[1];
}
return {images:result,warnings,font};
}
+78 -13
View File
@@ -9,7 +9,7 @@ Python stdlib only; the Mac runs it with `ssh frame python3 - <args> < this`.
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'
@@ -89,8 +89,8 @@ def evaluate(js):
ASSETS = {'grid': 0, 'hero': 1, 'logo': 2, 'wide': 3, 'icon': 4}
def collections_js(appid, vr=None):
wanted = [] if vr is None else ['Android', *(['Android VR'] if vr else [])]
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" ||
@@ -100,7 +100,7 @@ def collections_js(appid, vr=None):
return ["Steam collections API unavailable"];
const app = {{appid: {appid}}};
const warnings = [];
for (const name of ["Android", "Android VR"]) {{
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)) {{
@@ -123,7 +123,65 @@ def collections_js(appid, vr=None):
}}'''
def configure(appid, name, exe, start_dir, icon, vr, artwork):
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:
@@ -135,12 +193,15 @@ def configure(appid, name, exe, start_dir, icon, vr, artwork):
data = f.read(12 * 1024 * 1024 + 1)
if len(data) > 12 * 1024 * 1024:
raise ValueError('artwork is too large')
images.append([ASSETS[slot], ext, base64.b64encode(data).decode()])
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)});
SteamClient.Apps.SetShortcutExe(id, {json.dumps(exe)});
SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)});
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)});
@@ -148,10 +209,11 @@ def configure(appid, name, exe, start_dir, icon, vr, artwork):
if (typeof SteamClient.Apps.SetCustomArtworkForApp === "function") {{
for (const [type, ext, data] of {json.dumps(images)})
await SteamClient.Apps.SetCustomArtworkForApp(id, data, ext, type);
}} else warnings.push("Steam artwork API unavailable");
{collections_js(int(appid), vr)}
}} 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}};
}})()''')
@@ -162,7 +224,7 @@ def remove(appid):
{collections_js(int(appid))}
const warnings = await syncCollections();
if (typeof SteamClient.Apps.ClearCustomArtworkForApp === "function") {{
for (const type of [0, 1, 2, 3, 4])
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);
@@ -185,10 +247,13 @@ 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]))))
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')
@@ -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)];
@@ -65,6 +65,7 @@ function build(steam) {
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)}); },
+16
View File
@@ -0,0 +1,16 @@
const fs=require('fs'),vm=require('vm'),assert=require('assert');
const stub=require(process.cwd()+'/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub');
(async()=>{
const surface=stub.surface(),ctx=vm.createContext(surface);
vm.runInContext(fs.readFileSync('frame/android/library_artwork.js','utf8'),ctx);
const result=await ctx.renderLibraryArtwork({label:'Example Game',images:{icon:['png','fixture']}});
assert.deepEqual(Object.keys(result.images),['grid','wide','hero','logo','icon']);
for(const [slot,size] of Object.entries({grid:[600,900],wide:[920,430],hero:[3840,1240],logo:[1280,480],icon:[256,256]})) {
const b=Buffer.from(result.images[slot],'base64');assert.equal(b.readUInt32BE(16),size[0]);assert.equal(b.readUInt32BE(20),size[1]);
}
const hero=surface.canvases.find(c=>c.width===3840);assert.equal(hero.text.length,0);
const logo=surface.canvases.find(c=>c.width===1280);assert(logo.text.length);assert.equal(logo.draws,0);
const before=surface.canvases.length;await ctx.renderLibraryArtwork({label:'No Icon',images:{}});
assert.equal(surface.canvases.slice(before).find(c=>c.width===3840).text.length,0);
console.log('five dimensions, textless hero, title logo: OK');
})().catch(e=>{console.error(e);process.exit(1)});
+34 -28
View File
@@ -32,6 +32,7 @@ class LauncherTests(unittest.TestCase):
(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'
@@ -40,6 +41,8 @@ class LauncherTests(unittest.TestCase):
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')
@@ -118,41 +121,39 @@ class ArtworkTests(unittest.TestCase):
self.assertEqual(background[8:12], bytes([4, 9, 142, 255]))
self.assertEqual(background[12:], bytes([10, 20, 30, 255]))
def test_all_slots_have_expected_dimensions(self):
images = art.prepare('Open Saber Plus', (FIXTURES / 'icon.png').read_bytes())
self.assertEqual(set(images), set(art.SLOTS))
for slot, (ext, data) in images.items():
self.assertEqual(ext, 'png')
self.assertEqual(struct.unpack_from('>II', data, 16), art.SLOTS[slot])
# Transparent logo, opaque capsules; corrupt/missing icon gets a monogram.
_, _, logo = art.decode(images['logo'][1])
self.assertIn(0, logo[3::4])
self.assertIn(255, logo[3::4])
self.assertEqual(set(art.fallback('日本語', b'bad icon')), set(art.SLOTS))
def test_supplied_slots_and_url(self):
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.object(art.urllib.request, 'urlopen', return_value=response) as fetch:
images = art.prepare('Game', artwork={'hero': data, 'icon': 'https://example.org/icon.png'})
self.assertEqual(images['hero'], ('png', data))
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)
self.assertEqual(fetch.call_args.kwargs['timeout'], 20)
self.assertEqual(fetch.call_args.args[0].get_header('User-agent'), 'FrameControl/1.0')
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')
self.assertEqual(art.prepare('Game', artwork={'wide': data})['wide'], ('jpg', data))
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': 'file:///etc/passwd'}, {'hero': b'bad'}, ['hero']):
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())
@@ -209,10 +210,12 @@ class InstallTests(unittest.TestCase):
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), \
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, \
@@ -229,7 +232,7 @@ class InstallTests(unittest.TestCase):
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/icon.png'))
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'], [])
@@ -238,7 +241,7 @@ class InstallTests(unittest.TestCase):
def test_first_install_adds_shortcut(self):
_, _, api, _ = self.install(None)
self.assertEqual([c.args[0] for c in api.call_args_list], ['add', 'configure'])
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'}
@@ -250,13 +253,15 @@ class InstallTests(unittest.TestCase):
self.assertIs(install.call_args.args[-1], artwork)
def test_failed_new_install_removes_shortcut(self):
def tool(*args):
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), \
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:
@@ -304,7 +309,7 @@ class SteamAPITests(unittest.TestCase):
with patch.object(shortcuts, 'evaluate', return_value={}) as evaluate:
shortcuts.remove(42)
js = evaluate.call_args.args[0]
self.assertIn('[0, 1, 2, 3, 4]', js)
self.assertIn('[0, 1, 2, 3]', js)
self.assertLess(js.index('ClearCustomArtworkForApp(id, type)'), js.index('RemoveShortcut(id)'))
self.assertIn('const wanted = []', js)
@@ -335,10 +340,11 @@ class SteamContextTests(unittest.TestCase):
{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', '4'})
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, {})
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)
+208
View File
@@ -0,0 +1,208 @@
"""Every public sideload entry point reaches the mandatory five-slot writer."""
import contextlib
import io
import json
import shutil
import subprocess
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'ui'))
import frame_android as android
import frame_artwork as artwork
import frame_catalog as catalog
import frame_apk_versions as versions
import frame_titles as titles
import frame_steamgriddb as sgdb
import server
import frame_webinstall as webinstall
class EntryPoints(unittest.TestCase):
def setUp(self):
self.stack = contextlib.ExitStack()
self.addCleanup(self.stack.close)
self.info = {'package':'org.example.game', 'label':'Example', 'version':'1', 'version_code':1,
'vr':False, 'repairable':False, 'launchable':True, 'min_sdk':24, 'abis':[], 'icon_png':None}
self.stack.enter_context(patch.object(android, 'apk_info', return_value=self.info))
self.stack.enter_context(patch.object(android, 'read_meta', return_value=None))
self.stack.enter_context(patch.object(android, '_copy'))
self.stack.enter_context(patch.object(android, 'ssh', return_value='/home/steamos'))
self.stack.enter_context(patch.object(artwork, 'prepare', return_value=({}, [])))
self.api = self.stack.enter_context(patch.object(android, 'shortcut_tool', side_effect=self.steam))
def steam(self, *args, **kwargs):
if args[0] == 'add': return '3346865537'
if args[0] == 'render': return json.dumps({'paths': {s:'/tmp/'+s+'.png' for s in artwork.SLOTS}})
if args[0] == 'list': return json.dumps([{'appid':3346865537,'name':'Example','devkit_gameid':'Example'}])
return '{"warnings":[]}'
def assert_art(self):
calls = [c.args for c in self.api.call_args_list]
self.assertEqual(sum(c[0] == 'render' for c in calls), 1)
configs = [c for c in calls if c[0] == 'configure']
self.assertEqual(len(configs), 1)
self.assertEqual(set(json.loads(configs[0][7])), set(artwork.SLOTS))
def test_cli_install(self):
with patch.object(sys, 'argv', ['frame_android.py', 'install', 'game.apk']), patch('builtins.print'):
android.main()
self.assert_art()
def test_file_upload(self):
class Request:
headers = {'X-Filename':'game.apk','X-Mode':'apk','Content-Length':'3'}
rfile = io.BytesIO(b'apk')
with patch.object(server, 'ensure_master'):
result = server.Handler.upload(Request())
self.assertEqual(result['app']['package'], self.info['package'])
self.assert_art()
def test_catalogue_install(self):
with patch.object(catalog, 'app', return_value={'r':'yes','t':False,'n':'Example'}), \
patch.object(catalog, 'fetch_apk', return_value='game.apk'), \
patch.object(catalog, 'fetch_icon', return_value=None):
catalog.install(self.info['package'])
self.assert_art()
def test_version_finder_install(self):
record = {'url':'https://example.org/app.apk','sha256':'fixture','version_code':1,'source':'F-Droid'}
with patch.object(versions, '_versions', return_value=([record], [])), \
patch.object(catalog, 'fetch_apk', return_value='game.apk'):
versions.install(self.info['package'], record['url'])
self.assert_art()
def test_web_download_install(self):
result = webinstall.dispatch('game.apk', source='https://example.org/game.apk')
self.assertEqual(result['kind'], 'apk')
self.assert_art()
def test_refresh_api(self):
with patch.object(server, 'ensure_master'), \
patch.object(server, 'start_job', side_effect=lambda label, work: work()), \
patch.object(android, 'refresh_art', return_value=[]) as refresh:
self.assertEqual(server.android({'action':'refresh-art', 'all':True}), {'apps':[]})
refresh.assert_called_once_with(None)
def test_source_search_shared_installer_contract(self):
# Source workers hand their download and optional images to this public seam.
android.install('game.apk', name='Example', source='source-search', artwork={'banner': b'fixture'})
self.assert_art()
def test_native_title_install(self):
with tempfile.TemporaryDirectory() as root:
plan = {'id':'Example','name':'Example','root':root,'size':3,'target':'game.exe',
'runtime':'proton-experimental','source':'example.zip'}
def ssh(cmd, **kwargs):
if 'test -d' in cmd: return ''
if 'steamos-prepare-upload' in cmd: return '{"directory":"/home/steamos/devkit-game/Example"}'
if 'steam-client-create-shortcut' in cmd: return '{"success":"registered"}'
return ''
with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'), \
patch.object(titles, '_copy_tree'), patch.object(titles, '_rsync', return_value=True):
result = titles._install(plan, lambda *args: None)
self.assertEqual(result['shortcut'], 3346865537)
self.assert_art()
config = next(c.args for c in self.api.call_args_list if c.args[0] == 'configure')
self.assertEqual(json.loads(config[8])['category'], 'Sideloaded')
self.assertEqual(config[3:5], ('','')) # Never replace devkit's executable/runtime wiring.
def test_native_renamed_shortcut_uses_saved_identity(self):
self.api.side_effect = lambda *args, **kw: '[{"appid":42,"name":"Renamed"}]'
with patch.object(titles, 'ssh', return_value='{"shortcut":42}'):
self.assertEqual(titles._library_shortcut('Original', 'Original'), 42)
def test_native_failure_removes_new_blank_shortcut(self):
with tempfile.TemporaryDirectory() as root:
plan = {'id':'Example','name':'Example','root':root,'size':3,'target':'game.exe',
'runtime':'proton-experimental','source':'example.zip'}
def ssh(cmd, **kwargs):
if 'steamos-prepare-upload' in cmd: return '{"directory":"/home/steamos/devkit-game/Example"}'
if 'steam-client-create-shortcut' in cmd: return '{"success":"registered"}'
return ''
with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'), \
patch.object(titles, '_copy_tree'), patch.object(titles, '_rsync', return_value=True), \
patch.object(android, 'apply_library', side_effect=android.FrameError('render failed')):
with self.assertRaisesRegex(android.FrameError, 'render failed'):
titles._install(plan, lambda *args: None)
self.assertIn(('remove', '3346865537'), [c.args for c in self.api.call_args_list])
def test_refresh_does_not_reinstall_or_stop(self):
meta = {**self.info, 'instance':2800000001, 'shortcut':3346865537, 'flatscreen':False}
with patch.object(android, '_meta_or_fail', return_value=meta), \
patch.object(android, 'ssh', side_effect=lambda cmd, **kw: json.dumps({'icon_png':''}) if cmd == 'python3 -' else '/home/steamos'), \
patch.object(android, 'stop') as stop, patch.object(android, '_copy') as copy:
android.refresh_art(self.info['package'])
stop.assert_not_called(); copy.assert_not_called(); self.assert_art()
def test_all_refresh_reports_partial_failures(self):
with patch.object(android, 'list_apps', return_value=[{'package':'org.a.game'},{'package':'org.b.game'}]), \
patch.object(android, '_meta_or_fail', side_effect=android.FrameError('missing')):
result=android.refresh_art()
self.assertEqual(len(result),2)
self.assertTrue(all('error' in a for a in result))
def test_render_failure_cannot_report_success(self):
self.api.side_effect = lambda *args, **kw: '3346865537' if args[0]=='add' else '{"paths":{}}'
with self.assertRaisesRegex(android.FrameError,'every slot'):
android.install('game.apk')
class Renderer(unittest.TestCase):
@unittest.skipUnless(shutil.which('node'), 'Node is needed for the canvas contract fixture')
def test_canvas_slots_and_title_placement(self):
subprocess.run(['node', str(ROOT / 'tests/fixtures/library/check-renderer.js')],
cwd=str(ROOT), check=True, capture_output=True, timeout=30)
def test_desktop_packages_include_renderer_and_settings(self):
config = json.loads((ROOT / 'app/package.json').read_text())
resources = {r['from']: r['filter'] for r in config['build']['extraResources']}
self.assertIn('*.js', resources['../ui'])
self.assertIn('*.js', resources['../frame/android'])
class SteamGridDB(unittest.TestCase):
def test_no_key_is_silent_and_offline(self):
with patch.object(sgdb,'api_key',return_value=''), patch.object(sgdb,'_get') as get:
self.assertEqual(sgdb.lookup('Game'),({},[]))
get.assert_not_called()
def test_exact_match_and_top_votes_per_slot(self):
calls=[]
def get(path,key):
calls.append(path)
if 'search' in path: return [{'id':1,'name':'Other Game'},{'id':2,'name':'Game'}]
dims=(600,900) if '600x900' in path else (920,430)
return [{'url':'https://example.org/low.png','score':2,'width':dims[0],'height':dims[1]},
{'url':'https://example.org/top.png','score':20,'width':dims[0],'height':dims[1]},
{'url':'https://example.org/nsfw.png','score':99,'nsfw':True,'width':dims[0],'height':dims[1]}]
with patch.object(sgdb,'api_key',return_value='test-key'),patch.object(sgdb,'_get',side_effect=get):
images,warnings=sgdb.lookup('Game VR')
self.assertEqual(set(images),set(artwork.SLOTS));self.assertEqual(warnings,[])
self.assertTrue(all(url.endswith('/top.png') for url in images.values()))
self.assertTrue(all('/game/2?' in p for p in calls[1:]))
def test_wrong_title_and_failed_lookup_fall_back(self):
with patch.object(sgdb,'api_key',return_value='test-key'),patch.object(sgdb,'_get',return_value=[{'id':1,'name':'Unrelated'}]):
self.assertEqual(sgdb.lookup('Game'),({},[]))
with patch.object(sgdb,'api_key',return_value='test-key'),patch.object(sgdb,'_get',side_effect=OSError('private-secret')):
result=sgdb.lookup('Game')
self.assertNotIn('private-secret',str(result));self.assertEqual(result[0],{})
def test_settings_never_return_key(self):
with tempfile.TemporaryDirectory() as root, patch.object(sgdb,'settings_path',return_value=Path(root)/'settings.json'), \
patch.dict(sgdb.os.environ,{},clear=True):
result=sgdb.save_settings({'steamgriddb_api_key':'secret-fixture'})
self.assertTrue(result['steamgriddb_configured'])
self.assertNotIn('secret-fixture',json.dumps(result))
self.assertEqual(sgdb.api_key(),'secret-fixture')
if sys.platform!='win32':self.assertEqual((Path(root)/'settings.json').stat().st_mode&0o777,0o600)
sgdb.save_settings({'steamgriddb_api_key':''})
self.assertFalse(sgdb.settings()['steamgriddb_configured'])
if __name__=='__main__':unittest.main()
+31
View File
@@ -0,0 +1,31 @@
(() => {
const get=id=>document.getElementById(id), status=get('steamGridStatus');
async function request(url,body) {
const r=await fetch(url,body===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const data=await r.json(); if(!r.ok) throw Error(data.error||'Request failed'); return data;
}
function show(data) {
status.textContent=data.environment?'SteamGridDB key is set by an environment variable.':
data.steamgriddb_configured?'SteamGridDB key saved.':'No key configured. Source images and generated art are enabled.';
}
async function save(value) {
try {show(await request('/api/settings/artwork',{steamgriddb_api_key:value}));get('steamGridKey').value='';}
catch(e) {status.textContent=e.message;}
}
get('saveSteamGridKey').onclick=()=>save(get('steamGridKey').value.trim());
get('clearSteamGridKey').onclick=()=>save('');
get('refreshAndroidArt').onclick=async()=>{
const button=get('refreshAndroidArt');button.disabled=true;
try {
const result=await runJob('Refresh Android artwork','android-artwork',()=>request('/api/android',{action:'refresh-art',all:true}));
if (result) {
const apps=Array.isArray(result.apps)?result.apps:[result.apps];
const failed=apps.filter(a=>a.error);
status.textContent=failed.length?`${failed.length} refresh failed: ${failed.map(a=>a.package+': '+a.error).join('; ')}`:
`Refreshed artwork for ${apps.length} apps.`;
}
} catch(e) {status.textContent=e.message;}
finally {button.disabled=false;}
};
request('/api/settings/artwork').then(show).catch(e=>{status.textContent=e.message;});
})();
+119 -17
View File
@@ -8,7 +8,7 @@ Lepton Development, which wipes its apps on exit. See docs/apks.md.
Python stdlib only. CLI: python3 ui/frame_android.py
install APK [--vr|--flat] [--no-xr-compat] | info APK | versions APK-or-PKG
patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG
refresh-art PKG|--all | patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG
"""
import json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
@@ -57,8 +57,12 @@ def ssh(cmd, input=None, timeout=120):
def shortcut_tool(*args, timeout=60):
with open(SHORTCUTS) as f:
return ssh('python3 - ' + ' '.join(shlex.quote(a) for a in args), input=f.read(),
timeout=timeout).strip()
script = f.read()
if args and args[0] == 'render':
with open(os.path.join(ROOT, 'frame/android/library_artwork.js')) as f:
script = 'ART_RENDERER = ' + repr(f.read()) + '\n' + script
return ssh('python3 - ' + ' '.join(shlex.quote(a) for a in args), input=script,
timeout=timeout).strip()
def instance_id(pkg):
@@ -161,7 +165,7 @@ def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr
def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
try:
images = frame_artwork.prepare(name or info['label'], info.get('icon_png'), artwork)
images, art_warnings = frame_artwork.prepare(name or info['label'], info.get('icon_png'), artwork)
except (ValueError, OSError) as e:
raise FrameError(f'could not prepare artwork: {e}') from e
iid = instance_id(pkg)
@@ -172,35 +176,31 @@ def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
ssh(f'mkdir -p {d}')
_copy(apk_path, f'{d}/app.apk.part')
_copy(LAUNCHER, f'{d}/launch.sh', executable=True, timeout=120)
ssh(f'mkdir -p {d}/artwork')
paths = {}
for slot, (ext, data) in images.items():
path = f'{d}/icon.{ext}' if slot == 'icon' else f'{d}/artwork/{slot}.{ext}'
ssh(f'cat > {path}.tmp && mv {path}.tmp {path}', input=data)
paths[slot] = path
icon = paths['icon']
marker = f'touch {d}/lepton-show-flatscreen' if flatscreen else f'rm -f {d}/lepton-show-flatscreen'
ssh(f'mv {d}/app.apk.part {d}/app.apk && echo {iid} > {d}/instance.id && {marker}')
home = ssh('echo $HOME').strip()
shortcut = _int((existing or {}).get('shortcut'))
if not shortcut or shortcut not in _shortcut_ids():
reply = shortcut_tool('add', name or info['label'], f'{home}/{d}/launch.sh', f'{home}/{d}',
f'{home}/{icon}')
'')
shortcut = _int(reply.strip().splitlines()[-1] if reply.strip() else None)
if not shortcut:
raise FrameError(f'Steam did not return a shortcut id (got {reply[:80]!r})')
created = shortcut
presentation = json.loads(shortcut_tool(
'configure', str(shortcut), name or info['label'], f'{home}/{d}/launch.sh',
f'{home}/{d}', f'{home}/{icon}', '0' if flatscreen else '1',
json.dumps({slot: f'{home}/{path}' for slot, path in paths.items()})) or '{}')
presentation = apply_library(shortcut, name or info['label'], d, images,
vr=not flatscreen, home=home,
exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}',
details={'package': pkg, 'version': info['version'],
'source': source or os.path.basename(apk_path)})
presentation['warnings'] = art_warnings + presentation.get('warnings', [])
meta = {'package': pkg, 'label': name or info['label'], 'version': info['version'],
'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut),
'vr': info.get('vr', False), 'vr_issues': info.get('vr_issues', []),
'launchable': info.get('launchable', False), 'patched': info.get('patched', []),
'flatscreen': flatscreen, 'installed': time.strftime('%Y-%m-%dT%H:%M:%S'),
'source': source or os.path.basename(apk_path),
'library_warnings': presentation.get('warnings', [])}
'library_warnings': presentation.get('warnings', []),
'artwork': presentation.get('artwork', {}), 'library_version': 2}
_write_meta(d, meta)
ok = True
return meta
@@ -218,6 +218,101 @@ def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
pass
def apply_library(shortcut, label, directory, images, vr=False, home=None, exe='', start_dir='', details=None,
category='Android'):
"""Mandatory for every sideload: render all five slots before reporting success."""
home = home or ssh('echo $HOME').strip()
d = directory
ssh(f'mkdir -p {shlex.quote(d)}/artwork')
paths = {}
for slot, (ext, data) in images.items():
path = f'{d}/artwork/source-{slot}.{ext}'
ssh(f'cat > {shlex.quote(path)}', input=data)
paths[slot] = f'{home}/{path}' if not path.startswith('/') else path
manifest = {'label': label, 'images': paths}
plan = f'{d}/artwork/input.json'
ssh(f'cat > {shlex.quote(plan)}', input=json.dumps(manifest))
absolute = f'{home}/{plan}' if not plan.startswith('/') else plan
rendered = json.loads(shortcut_tool('render', absolute, timeout=120))
art = rendered['paths']
if set(art) != set(frame_artwork.SLOTS):
raise FrameError('Steam artwork renderer did not produce every slot')
result = json.loads(shortcut_tool('configure', str(shortcut), label, exe, start_dir, art['icon'],
'1' if vr else '0', json.dumps(art),
json.dumps({'category': category, 'details': details or {}}), timeout=120))
result['warnings'] = rendered.get('warnings', []) + result.get('warnings', [])
result['artwork'] = art
if category == 'Android':
ssh(f'cat > {shlex.quote(d)}/shortcut.id', input=str(int(shortcut)))
return result
def refresh_art(pkg=None, artwork=None):
"""Refresh existing APK library entries without reinstalling or stopping them."""
if pkg is None:
results = []
for app in list_apps():
try:
results.append(refresh_art(app['package'], artwork))
except (FrameError, OSError, ValueError) as e:
results.append({'package': app['package'], 'error': str(e)})
return results
with _install_lock:
m = _meta_or_fail(pkg)
d = f'{APPS_DIR}/{pkg}'
# Parse the APK on the Frame, transferring only its icon/label, not the APK.
modules = {}
for module in ('frame_apk', 'frame_apk_vr'):
with open(os.path.join(ROOT, 'ui', module + '.py')) as f:
modules[module] = f.read()
script = 'import sys, types, json, base64\n'
for module, source in modules.items():
script += f'm = types.ModuleType({module!r}); sys.modules[{module!r}] = m; exec({source!r}, m.__dict__)\n'
script += f"info = sys.modules['frame_apk'].apk_info({(d + '/app.apk')!r})\n"
script += "info['icon_png'] = base64.b64encode(info.get('icon_png') or b'').decode()\n"
script += "import os\ninfo['artwork'] = {}\n"
script += f"directory = os.path.realpath({d!r})\n"
script += """try:
with open(os.path.join(directory, 'artwork/input.json')) as f:
cached = json.load(f)
for slot, path in cached.get('images', {}).items():
if os.path.commonpath([os.path.realpath(path), directory]) != directory:
continue
with open(path, 'rb') as f:
data = f.read(12 * 1024 * 1024 + 1)
if len(data) <= 12 * 1024 * 1024:
info['artwork'][slot] = base64.b64encode(data).decode()
except (OSError, ValueError, TypeError):
pass
print(json.dumps(info))
"""
info = json.loads(ssh('python3 -', input=script))
import base64
icon = base64.b64decode(info['icon_png'])
cached = {k: base64.b64decode(v) for k, v in info.get('artwork', {}).items()}
images, warnings = frame_artwork.prepare(m['label'], icon, artwork if artwork is not None else cached)
home = ssh('echo $HOME').strip()
created = False
if not m['shortcut'] or m['shortcut'] not in _shortcut_ids():
m['shortcut'] = _int(shortcut_tool('add', m['label'], f'{home}/{d}/launch.sh', f'{home}/{d}'))
if not m['shortcut']:
raise FrameError('Steam did not create the missing shortcut')
m['game_id'] = game_id(m['shortcut'])
created = True
try:
result = apply_library(m['shortcut'], m['label'], d, images, vr=not m.get('flatscreen', True),
home=home, exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}', details=m)
except Exception:
if created:
shortcut_tool('remove', str(m['shortcut']))
raise
m.update(artwork=result.get('artwork', {}), library_version=2)
m['library_warnings'] = warnings + result.get('warnings', [])
m['artwork_refreshed'] = time.strftime('%Y-%m-%dT%H:%M:%S')
_write_meta(d, m)
return m
def _int(v):
try:
n = int(v)
@@ -375,6 +470,13 @@ def main():
elif cmd == 'install':
r = install(args[0], flatscreen=False if '--vr' in args else True if '--flat' in args else None,
xr_compat=False if '--no-xr-compat' in args else None)
elif cmd == 'refresh-art':
if len(args) != 1:
raise FrameError('usage: refresh-art PACKAGE or refresh-art --all')
r = refresh_art(None if args[0] == '--all' else args[0])
if isinstance(r, list) and any('error' in item for item in r):
print(json.dumps(r, indent=1))
raise SystemExit(1)
elif cmd == 'patch':
import argparse
parser = argparse.ArgumentParser(description='Patch and v2-sign an APK locally')
+38 -86
View File
@@ -1,8 +1,5 @@
"""Small stdlib PNG compositor for Steam's Android library artwork."""
import hashlib
"""Bounded artwork inputs for Steam library canvas rendering."""
import struct
import textwrap
import unicodedata
import urllib.parse
import urllib.request
import zlib
@@ -12,18 +9,6 @@ SLOTS = {'grid': (600, 900), 'wide': (920, 430), 'hero': (3840, 1240),
MAX_IMAGE = 12 * 1024 * 1024
MAX_PIXELS = 8_000_000
PNG = b'\x89PNG\r\n\x1a\n'
# Five-column display lettering; labels remain exact in Steam's native text.
GLYPHS = dict(zip('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789- .?', (
'0E11111F111111', '1E11111E11111E', '0F10101010100F', '1E11111111111E',
'1F10101E10101F', '1F10101E101010', '0F10101711110F', '1111111F111111',
'0E04040404040E', '0702020212120C', '11121418141211', '1010101010101F',
'111B1515111111', '11191513111111', '0E11111111110E', '1E11111E101010',
'0E11111115120D', '1E11111E141211', '0F10100E01011E', '1F040404040404',
'1111111111110E', '11111111110A04', '11111115151B11', '11110A040A1111',
'11110A04040404', '1F01020408101F', '0E11131519110E', '040C040404040E',
'0E11010204081F', '1E01010E01011E', '02060A121F0202', '1F10101E01011E',
'0E10101E11110E', '1F010204080808', '0E11110E11110E', '0E11110F01010E',
'0000001F000000', '00000000000000', '00000000000C0C', '0E110102040004')))
def chunk(kind, data):
@@ -150,24 +135,6 @@ def image_type(data):
raise ValueError('artwork must be a supported PNG or JPEG')
def rectangle(pixels, width, x, y, w, h, color):
row = bytes(color) * w
for yy in range(y, y + h):
start = (yy * width + x) * 4
pixels[start:start + len(row)] = row
def lettering(pixels, width, text, y, scale, color):
x = (width - (len(text) * 6 - 1) * scale) // 2
for ch in text:
glyph = bytes.fromhex(GLYPHS.get(ch, GLYPHS['?']))
for yy, bits in enumerate(glyph):
for xx in range(5):
if bits & (1 << (4 - xx)):
rectangle(pixels, width, x + xx * scale, y + yy * scale, scale, scale, color)
x += 6 * scale
def stamp(pixels, width, icon, x, y, size):
iw, ih, source = icon
dw, dh = max(1, size * iw // max(iw, ih)), max(1, size * ih // max(iw, ih))
@@ -182,60 +149,45 @@ def stamp(pixels, width, icon, x, y, size):
pixels[dst + 3] = a + pixels[dst + 3] * (255 - a) // 255
def fallback(label, icon_png=None):
text = unicodedata.normalize('NFKD', label).encode('ascii', 'ignore').decode().upper().strip() or 'ANDROID APP'
text = ' '.join(text.split())[:80]
seed = hashlib.sha256(label.encode()).digest()
accent = tuple(100 + c % 110 for c in seed[:3]) + (255,)
try:
icon = decode(icon_png)
except (ValueError, zlib.error, struct.error):
icon = None
out = {}
for slot, (w, h) in SLOTS.items():
pixels = bytearray(w * h * 4)
if slot != 'logo':
for y in range(h):
color = (14 + 12 * y // h, 22 + 16 * y // h, 34 + 22 * y // h, 255)
rectangle(pixels, w, 0, y, w, 1, color)
rectangle(pixels, w, w // 12, h - max(6, h // 70), w * 5 // 6, max(3, h // 180), accent)
size = {'grid': 320, 'wide': 190, 'hero': 640, 'logo': 0, 'icon': 192}[slot]
if size:
x, y = (w - size) // 2, {'grid': 160, 'wide': 38, 'hero': 200, 'icon': 32}[slot]
if icon:
stamp(pixels, w, icon, x, y, size)
else:
scale = size // 9
lettering(pixels, w, text[0], y + (size - scale * 7) // 2, scale, accent)
if slot != 'icon':
scale = {'grid': 5, 'wide': 5, 'hero': 12, 'logo': 10}[slot]
limit = (w - w // 6) // (scale * 6)
lines = textwrap.wrap(text, limit)[:3 if slot in ('grid', 'logo') else 2]
if len(textwrap.wrap(text, limit)) > len(lines):
lines[-1] = lines[-1][:-3] + '...'
y = {'grid': 580, 'wide': 280, 'hero': 930, 'logo': (h - len(lines) * scale * 9) // 2}[slot]
for line in lines:
lettering(pixels, w, line, y, scale, (240, 245, 250, 255))
y += scale * 9
out[slot] = ('png', png(w, h, pixels))
return out
def fetch(value):
if isinstance(value, str):
if urllib.parse.urlsplit(value).scheme not in ('http', 'https'):
raise ValueError('artwork URLs must use HTTP(S)')
request = urllib.request.Request(value, headers={'User-Agent': 'FrameControl/1.0'})
with urllib.request.urlopen(request, timeout=20) as response:
if urllib.parse.urlsplit(response.geturl()).scheme not in ('http', 'https'):
raise ValueError('artwork redirect must use HTTP(S)')
value = response.read(MAX_IMAGE + 1)
return image_type(value), value
def prepare(label, icon_png=None, artwork=None):
"""Gather inputs; the Frame's Chromium canvas renders every final slot."""
import frame_steamgriddb
artwork = artwork or {}
if not isinstance(artwork, dict) or set(artwork) - set(SLOTS):
raise ValueError('artwork slots are grid, wide, hero, logo and icon')
supplied = {}
allowed = set(SLOTS) | {'banner', 'feature_graphic', 'screenshots', 'screenshot'}
if not isinstance(artwork, dict) or set(artwork) - allowed:
raise ValueError('unknown artwork slot')
supplied, warnings = {}, []
for slot, value in artwork.items():
if isinstance(value, str):
if urllib.parse.urlsplit(value).scheme not in ('http', 'https'):
raise ValueError('artwork URLs must use HTTP(S)')
request = urllib.request.Request(value, headers={'User-Agent': 'FrameControl/1.0'})
with urllib.request.urlopen(request, timeout=20) as response:
if urllib.parse.urlsplit(response.geturl()).scheme not in ('http', 'https'):
raise ValueError('artwork redirect must use HTTP(S)')
value = response.read(MAX_IMAGE + 1)
supplied[slot] = (image_type(value), value)
result = fallback(label, supplied.get('icon', ('', icon_png))[1])
result.update(supplied)
return result
values = value if slot == 'screenshots' and isinstance(value, (list, tuple)) else [value]
for candidate in values[:4]:
try:
supplied['screenshot' if slot == 'screenshots' else slot] = fetch(candidate)
break
except (ValueError, OSError):
warnings.append('Source ' + slot + ' unavailable; using fallback art')
if 'icon' not in supplied and icon_png:
try:
supplied['icon'] = fetch(icon_png)
except (ValueError, OSError):
pass
provider, provider_warnings = frame_steamgriddb.lookup(label)
warnings.extend(provider_warnings)
for slot, value in provider.items():
try:
supplied[slot] = fetch(value)
except (ValueError, OSError):
warnings.append('SteamGridDB ' + slot + ' download failed; using fallback art')
return supplied, warnings
+103
View File
@@ -0,0 +1,103 @@
"""Optional SteamGridDB artwork. Credentials stay on the host, never in app metadata."""
import json
import os
import re
import tempfile
import urllib.parse
import urllib.request
import frame_host
API = 'https://www.steamgriddb.com/api/v2'
MAX_JSON = 2 * 1024 * 1024
def settings_path():
return frame_host.data_dir('artwork-settings.json')
def api_key():
env = os.environ.get('STEAMGRIDDB_API_KEY') or os.environ.get('FRAME_STEAMGRIDDB_API_KEY')
if env:
return env.strip()
try:
return str(json.loads(settings_path().read_text()).get('steamgriddb_api_key') or '')
except (OSError, ValueError, AttributeError):
return ''
def settings():
return {'steamgriddb_configured': bool(api_key()),
'environment': bool(os.environ.get('STEAMGRIDDB_API_KEY') or os.environ.get('FRAME_STEAMGRIDDB_API_KEY'))}
def save_settings(body):
key = body.get('steamgriddb_api_key')
if not isinstance(key, str) or len(key) > 200 or (key and not re.fullmatch(r'[A-Za-z0-9_-]+', key)):
raise ValueError('enter a valid SteamGridDB API key, or an empty value to remove it')
path = settings_path()
path.parent.mkdir(parents=True, exist_ok=True)
fd, temp = tempfile.mkstemp(prefix='.artwork-', dir=str(path.parent))
try:
with os.fdopen(fd, 'w') as f:
json.dump({'steamgriddb_api_key': key}, f)
os.replace(temp, path)
finally:
if os.path.exists(temp):
os.remove(temp)
return settings()
def _get(path, key):
request = urllib.request.Request(API + path, headers={'Authorization': 'Bearer ' + key,
'User-Agent': 'FrameControl/1.0'})
# Do not carry the credential to redirects or include it in error messages.
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
with urllib.request.build_opener(NoRedirect()).open(request, timeout=12) as response:
data = response.read(MAX_JSON + 1)
if len(data) > MAX_JSON:
raise ValueError('SteamGridDB response too large')
result = json.loads(data)
if not result.get('success') or not isinstance(result.get('data'), list):
raise ValueError('SteamGridDB lookup failed')
return result['data']
def _name(value):
return re.sub(r'[^a-z0-9]', '', str(value).casefold())
def lookup(name):
"""Best-voted art per slot for an exact title match; unrelated games are never guessed."""
key = api_key()
if not key:
return {}, []
try:
matches = _get('/search/autocomplete/' + urllib.parse.quote(name, safe=''), key)
names = {_name(name), _name(re.sub(r'\s+VR$', '', name, flags=re.I))}
game = next((g for g in matches if _name(g.get('name')) in names), None)
if not game:
return {}, []
gid = int(game['id'])
result, warnings = {}, []
for slot, kind, dimensions in [('grid', 'grids', '600x900'), ('wide', 'grids', '920x430'),
('hero', 'heroes', ''), ('logo', 'logos', ''), ('icon', 'icons', '')]:
try:
query = {'types': 'static', 'nsfw': 'false', 'humor': 'false', 'mimes': 'image/png,image/jpeg'}
if dimensions:
query['dimensions'] = dimensions
records = _get('/' + kind + '/game/' + str(gid) + '?' + urllib.parse.urlencode(query), key)
records = [r for r in records if r.get('url', '').startswith('https://') and not r.get('nsfw')]
if dimensions:
w, h = map(int, dimensions.split('x'))
records = [r for r in records if (r.get('width'), r.get('height')) == (w, h)]
records.sort(key=lambda r: (int(r.get('score') or 0), int(r.get('upvotes') or 0)), reverse=True)
if records:
result[slot] = records[0]['url']
except (OSError, ValueError, KeyError, TypeError):
warnings.append('SteamGridDB ' + slot + ' unavailable; using source or generated art')
return result, warnings
except (OSError, ValueError, KeyError, TypeError):
return {}, ['SteamGridDB unavailable; using source or generated art']
+50 -5
View File
@@ -632,7 +632,7 @@ def _rsync():
_install_lock = threading.Lock()
def install(path, name=None, exe=None, runtime=None, progress=None):
def install(path, name=None, exe=None, runtime=None, progress=None, artwork=None):
"""Sideload a .zip, folder or executable as a Devkit Game; returns the title dict.
name: the Steam name (sanitised to the title id), default from the file name.
@@ -643,12 +643,12 @@ def install(path, name=None, exe=None, runtime=None, progress=None):
"""
plan = inspect(path, name)
try:
return install_plan(plan, name=name, exe=exe, runtime=runtime, progress=progress)
return install_plan(plan, name=name, exe=exe, runtime=runtime, progress=progress, artwork=artwork)
finally:
discard(plan)
def install_plan(plan, name=None, exe=None, runtime=None, progress=None):
def install_plan(plan, name=None, exe=None, runtime=None, progress=None, artwork=None):
"""Install an inspect() plan, optionally with another name, target or runtime."""
if name:
plan['name'], plan['id'] = name, title_id(name)
@@ -656,13 +656,21 @@ def install_plan(plan, name=None, exe=None, runtime=None, progress=None):
_choose(plan, exe or plan['target'], runtime)
step = progress or (lambda *a: None)
with _install_lock:
return _install(plan, step)
return _install(plan, step, artwork)
def _install(plan, step):
def _install(plan, step, artwork=None):
gid = plan['id']
if not NEW_ID_RE.match(gid) or gid.lower() in RESERVED_IDS:
raise FrameError(f'bad title id {gid!r}')
icon = None
for filename in ('icon.png', 'logo.png'):
path = os.path.join(plan['root'], filename)
if os.path.isfile(path):
with open(path, 'rb') as f:
icon = f.read(frame_android.frame_artwork.MAX_IMAGE + 1)
break
images, warnings = frame_android.frame_artwork.prepare(plan['name'], icon, artwork)
step("Syncing Valve's devkit tools to the Frame", 0.02)
ensure_utils()
existed = ssh(f'test -d {GAMES}/{gid} && echo yes || true', timeout=30).strip() == 'yes'
@@ -672,6 +680,7 @@ def _install(plan, step):
if not DIR_RE.match(directory) or not directory.endswith(f'/{GAMES}/{gid}'):
raise FrameError(f'steamos-prepare-upload returned an unexpected folder {directory!r}')
registered = False
library_ready, shortcut, steam_registered = False, None, False
try:
step(f"Copying {plan['size'] / 1e6:.0f} MB to the Frame", 0.1)
if _rsync():
@@ -694,10 +703,26 @@ def _install(plan, step):
err = str(reply['error']).strip().rstrip('.')
hint = ' With Steam running on the Frame, install it again.' if 'not running' in err else ''
raise FrameError(f"Uploaded, but Steam didn't register it: {err}.{hint}")
steam_registered = True
shortcut = _library_shortcut(gid, plan['name'])
if not shortcut:
raise FrameError('Steam registered the title but its shortcut is not available for mandatory artwork; retry install')
result = frame_android.apply_library(shortcut, plan['name'], directory + '/.frame-artwork', images,
category='Sideloaded', details={'source': plan['source']})
meta.update(shortcut=shortcut, library_warnings=warnings + result.get('warnings', []))
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
library_ready = True
step('Done', 1.0)
meta.update(runtime_label=RUNTIMES[plan['runtime']]['label'], steam=str(reply.get('success', '')).strip())
return meta
finally:
if steam_registered and not library_ready and not existed:
# A newly registered title must not remain as a blank library tile.
if shortcut:
frame_android.shortcut_tool('remove', str(shortcut))
else:
ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120)
registered = False
if not registered and not existed:
# A first install that failed part-way: don't leave an orphan folder behind.
try:
@@ -706,6 +731,23 @@ def _install(plan, step):
pass
def _library_shortcut(gid, name):
shortcuts = json.loads(frame_android.shortcut_tool('list'))
matches = [s for s in shortcuts if s.get('devkit_gameid') == gid]
if not matches:
try:
meta = json.loads(ssh(f'cat {GAMES}/{gid}-framecontrol.json 2>/dev/null || true'))
matches = [s for s in shortcuts if s.get('appid') == meta.get('shortcut')]
except (ValueError, AttributeError):
pass
if not matches:
matches = [s for s in shortcuts if s.get('name') in (gid, name)]
if len(matches) > 1:
raise FrameError('ambiguous Steam shortcut for ' + gid)
return int(matches[0]['appid']) if matches else None
LIST_SCRIPT = r'''
import json, os
root = os.path.expanduser('~/devkit-game')
@@ -772,6 +814,9 @@ def remove(gid):
try:
gid = _check_id(gid)
ensure_utils()
shortcut = _library_shortcut(gid, gid)
if shortcut:
frame_android.shortcut_tool('remove', str(shortcut))
# steamos-delete removes the folder and syncs Steam's shortcuts; its json files stay, so clear them too.
ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120)
ssh(f'rm -f {_json_files(gid)}', timeout=30)
+14
View File
@@ -628,6 +628,19 @@
</section>
<section class="panel" id="artworkSettings">
<div class="shelf-head"><h2>Library artwork settings</h2></div>
<p class="sub">SteamGridDB supplies community artwork for sideloaded games. Without a key, Frame Control uses source images and creates the remaining art.</p>
<label for="steamGridKey">SteamGridDB API key</label>
<input type="password" id="steamGridKey" autocomplete="off" spellcheck="false" placeholder="Optional API key">
<div class="row" style="margin-top:8px">
<button id="saveSteamGridKey" class="action small">Save key</button>
<button id="clearSteamGridKey" class="small">Remove saved key</button>
<a href="https://www.steamgriddb.com/profile/preferences/api" target="_blank" rel="noreferrer">Get a free key</a>
</div>
<div id="steamGridStatus" class="hint" role="status"></div>
<button id="refreshAndroidArt" class="small" style="margin-top:12px">Refresh installed Android artwork</button>
</section>
<section class="panel" id="power">
<div class="shelf-head"><h2>Remote &amp; power</h2></div>
<div class="actions">
@@ -2305,5 +2318,6 @@ setView("headset");
refresh().then(loadShots); // after status, so app names resolve
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
</script>
<script src="/artwork-settings.js"></script>
</body>
</html>
+13 -1
View File
@@ -39,6 +39,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
import frame_android # noqa: E402
import frame_apk_versions # noqa: E402
import frame_catalog # noqa: E402
import frame_steamgriddb
import frame_host # noqa: E402
import frame_store # noqa: E402
import frame_titles # noqa: E402
@@ -588,6 +589,11 @@ def android(body):
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.",
"app": m}
return start_job(f"Install {pkg}", work)
if action == "refresh-art":
if not pkg and not body.get("all"):
raise Failure('choose a package or all apps', 400)
return start_job('Refresh Steam artwork', lambda: {
'apps': frame_android.refresh_art(None if body.get('all') else pkg)})
if action in ("launch", "stop"):
m = getattr(frame_android, action)(pkg)
return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"}
@@ -1227,7 +1233,9 @@ def _sweep_one(prefix, d):
pass
POST = {"/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
POST = {
"/api/settings/artwork": frame_steamgriddb.save_settings,
"/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
"/api/webinstall/cancel": webinstall_cancel}
@@ -1331,6 +1339,10 @@ class Handler(BaseHTTPRequestHandler):
try:
if path in ("/", "/index.html"):
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/api/settings/artwork":
self.send_json(frame_steamgriddb.settings())
elif path == "/artwork-settings.js":
self.send_bytes((HERE / 'artwork-settings.js').read_bytes(), 'text/javascript; charset=utf-8')
elif path == "/api/host":
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,