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",
|
"to": "ui",
|
||||||
"filter": [
|
"filter": [
|
||||||
"*.py",
|
"*.py",
|
||||||
"*.html"
|
"*.html",
|
||||||
|
"*.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -69,7 +70,8 @@
|
|||||||
"to": "frame/android",
|
"to": "frame/android",
|
||||||
"filter": [
|
"filter": [
|
||||||
"*.sh",
|
"*.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
|
The loader was never the problem: Wolvic's Quest `libopenxr_loader.so` is a
|
||||||
Khronos-style loader and found SteamVR through `/vendor`.
|
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
|
## Out of scope
|
||||||
|
|
||||||
- **Meta entitlement.** Apps that call the Oculus Platform SDK
|
- **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.
|
# A number that isn't a real Steam app; it names this app's Lepton context.
|
||||||
export SteamAppId="$(cat "$DIR/instance.id")"
|
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"
|
export STEAM_COMPAT_INSTALL_PATH="$DIR"
|
||||||
# Must be under ~/.local/share/Steam: only that tree is mounted in the container.
|
# 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"
|
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
|
# Lepton's setpgid --foreground re-exec needs a terminal that Steam shortcuts
|
||||||
# and SSH don't have; give it its own session instead.
|
# and SSH don't have; give it its own session instead.
|
||||||
export IS_PARENT=true
|
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 add NAME EXE START_DIR [ICON] -> prints the shortcut app id
|
||||||
steam_shortcuts.py list -> JSON [{appid, name, exe}]
|
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
|
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'
|
DEVTOOLS = 'http://127.0.0.1:8080/json'
|
||||||
|
|
||||||
@@ -83,6 +85,153 @@ def evaluate(js):
|
|||||||
return res.get('result', {}).get('value')
|
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():
|
def main():
|
||||||
cmd, args = sys.argv[1], sys.argv[2:]
|
cmd, args = sys.argv[1], sys.argv[2:]
|
||||||
if cmd == 'add':
|
if cmd == 'add':
|
||||||
@@ -98,11 +247,18 @@ def main():
|
|||||||
print(evaluate(js))
|
print(evaluate(js))
|
||||||
elif cmd == 'list':
|
elif cmd == 'list':
|
||||||
js = '''(() => appStore.allApps.filter(a => a.app_type === 1073741824)
|
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)))
|
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':
|
elif cmd == 'remove':
|
||||||
evaluate(f'SteamClient.Apps.RemoveShortcut({int(args[0])})')
|
print(json.dumps(remove(int(args[0]))))
|
||||||
print('removed')
|
|
||||||
else:
|
else:
|
||||||
sys.exit(__doc__)
|
sys.exit(__doc__)
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ containers = {n: c for n, c in fs.read()['lepton'].items() if alive(c)}
|
|||||||
if cmd == 'ps':
|
if cmd == 'ps':
|
||||||
for name, c in sorted(containers.items()):
|
for name, c in sorted(containers.items()):
|
||||||
print(f"{name} {c['port']}")
|
print(f"{name} {c['port']}")
|
||||||
|
elif cmd == 'inspect':
|
||||||
|
if args[-1] not in containers:
|
||||||
|
sys.exit(1)
|
||||||
|
print('true')
|
||||||
elif cmd == 'stop':
|
elif cmd == 'stop':
|
||||||
name = args[-1]
|
name = args[-1]
|
||||||
c = containers.get(name)
|
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 => ({
|
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 },
|
local_per_client_data: { installed: true, display_status: 1, status_percentage: 0 },
|
||||||
});
|
});
|
||||||
const allApps = () => [...steam.apps.map(gameOverview), ...steam.shortcuts.map(shortcutOverview)];
|
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 {
|
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: {
|
appStore: {
|
||||||
get allApps() { return allApps(); },
|
get allApps() { return allApps(); },
|
||||||
GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; },
|
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); },
|
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); },
|
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); },
|
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) {
|
RemoveShortcut(id) {
|
||||||
steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id));
|
steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id));
|
||||||
delete steam.compat_tools[String(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')
|
search.add_game_data('org.brush')
|
||||||
mod.download.assert_called_once_with(mod.sources()[0] | {'status': 'not searched'}, 'brush', version_code=1)
|
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):
|
def test_discovery_and_demo_are_opt_in(self):
|
||||||
module = fake()
|
module = fake()
|
||||||
with patch.object(search.pkgutil, 'iter_modules', return_value=[types.SimpleNamespace(name='example')]), \
|
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'),
|
kwargs = {'name': entry.get('name'), 'icon_png': downloaded.get('icon_png') or entry.get('icon_png'),
|
||||||
'source': source['name']}
|
'source': source['name']}
|
||||||
if 'artwork' in inspect.signature(frame_android.install).parameters:
|
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:
|
if progress:
|
||||||
progress('Installing', None)
|
progress('Installing', None)
|
||||||
from apk_sources import _web
|
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
|
Python stdlib only. CLI: python3 ui/frame_android.py
|
||||||
install APK [--vr|--flat] [--no-xr-compat] | info APK | versions APK-or-PKG
|
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
|
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 json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
|
||||||
|
|
||||||
import frame_apk
|
import frame_apk
|
||||||
|
import frame_artwork
|
||||||
import frame_host
|
import frame_host
|
||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
@@ -57,8 +58,12 @@ def ssh(cmd, input=None, timeout=120):
|
|||||||
|
|
||||||
def shortcut_tool(*args, timeout=60):
|
def shortcut_tool(*args, timeout=60):
|
||||||
with open(SHORTCUTS) as f:
|
with open(SHORTCUTS) as f:
|
||||||
return ssh('python3 - ' + ' '.join(shlex.quote(a) for a in args), input=f.read(),
|
script = f.read()
|
||||||
timeout=timeout).strip()
|
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):
|
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))
|
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)
|
info = apk_info(apk_path)
|
||||||
if icon_png:
|
if icon_png:
|
||||||
info['icon_png'] = 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')
|
patched = os.path.join(tmp, 'app.apk')
|
||||||
info['patched'] = patch(apk_path, patched, add)['patched']
|
info['patched'] = patch(apk_path, patched, add)['patched']
|
||||||
info['launchable'] = True
|
info['launchable'] = True
|
||||||
return _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path))
|
return _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path), artwork)
|
||||||
return _install(apk_path, info, pkg, flatscreen, name, source)
|
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)
|
iid = instance_id(pkg)
|
||||||
d = f'{APPS_DIR}/{pkg}'
|
d = f'{APPS_DIR}/{pkg}'
|
||||||
existing = read_meta(pkg)
|
existing = read_meta(pkg)
|
||||||
ok = False
|
ok, created = False, None
|
||||||
try:
|
try:
|
||||||
ssh(f'mkdir -p {d}')
|
ssh(f'mkdir -p {d}')
|
||||||
_copy(apk_path, f'{d}/app.apk.part')
|
_copy(apk_path, f'{d}/app.apk.part')
|
||||||
_copy(LAUNCHER, f'{d}/launch.sh', executable=True, timeout=120)
|
_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'
|
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}')
|
ssh(f'mv {d}/app.apk.part {d}/app.apk && echo {iid} > {d}/instance.id && {marker}')
|
||||||
home = ssh('echo $HOME').strip()
|
home = ssh('echo $HOME').strip()
|
||||||
shortcut = _int((existing or {}).get('shortcut'))
|
shortcut = _int((existing or {}).get('shortcut'))
|
||||||
if not shortcut or shortcut not in _shortcut_ids():
|
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}',
|
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)
|
shortcut = _int(reply.strip().splitlines()[-1] if reply.strip() else None)
|
||||||
if not shortcut:
|
if not shortcut:
|
||||||
raise FrameError(f'Steam did not return a shortcut id (got {reply[:80]!r})')
|
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'],
|
meta = {'package': pkg, 'label': name or info['label'], 'version': info['version'],
|
||||||
'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut),
|
'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut),
|
||||||
'vr': info.get('vr', False), 'vr_issues': info.get('vr_issues', []),
|
'vr': info.get('vr', False), 'vr_issues': info.get('vr_issues', []),
|
||||||
'launchable': info.get('launchable', False), 'patched': info.get('patched', []),
|
'launchable': info.get('launchable', False), 'patched': info.get('patched', []),
|
||||||
'flatscreen': flatscreen, 'installed': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
'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)
|
_write_meta(d, meta)
|
||||||
ok = True
|
ok = True
|
||||||
return meta
|
return meta
|
||||||
finally:
|
finally:
|
||||||
|
if not ok and created:
|
||||||
|
try:
|
||||||
|
shortcut_tool('remove', str(created))
|
||||||
|
except FrameError:
|
||||||
|
pass
|
||||||
if not ok and not existing:
|
if not ok and not existing:
|
||||||
# A first install that failed part-way: don't leave an orphan folder behind.
|
# A first install that failed part-way: don't leave an orphan folder behind.
|
||||||
try:
|
try:
|
||||||
@@ -200,6 +219,101 @@ def _install(apk_path, info, pkg, flatscreen, name, source):
|
|||||||
pass
|
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):
|
def _int(v):
|
||||||
try:
|
try:
|
||||||
n = int(v)
|
n = int(v)
|
||||||
@@ -271,6 +385,11 @@ def launch(pkg):
|
|||||||
|
|
||||||
def stop(pkg):
|
def stop(pkg):
|
||||||
m = _meta_or_fail(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)
|
ssh(f"podman stop -t 5 lepton-steamlaunch-{int(m['instance'])} >/dev/null 2>&1 || true", timeout=60)
|
||||||
return m
|
return m
|
||||||
|
|
||||||
@@ -279,10 +398,8 @@ def remove(pkg, keep_data=False):
|
|||||||
m = _meta_or_fail(pkg)
|
m = _meta_or_fail(pkg)
|
||||||
stop(pkg)
|
stop(pkg)
|
||||||
if m['shortcut']:
|
if m['shortcut']:
|
||||||
try:
|
result = json.loads(shortcut_tool('remove', str(int(m['shortcut']))) or '{}')
|
||||||
shortcut_tool('remove', str(int(m['shortcut'])))
|
m['library_warnings'] = result.get('warnings', [])
|
||||||
except FrameError:
|
|
||||||
pass # already gone from Steam
|
|
||||||
iid = int(m['instance'])
|
iid = int(m['instance'])
|
||||||
extra = '' if keep_data else f' {COMPAT}/{iid} {SHADERS}/{iid}'
|
extra = '' if keep_data else f' {COMPAT}/{iid} {SHADERS}/{iid}'
|
||||||
ssh(f'rm -rf {APPS_DIR}/{pkg}{extra}')
|
ssh(f'rm -rf {APPS_DIR}/{pkg}{extra}')
|
||||||
@@ -369,6 +486,13 @@ def main():
|
|||||||
elif cmd == 'install':
|
elif cmd == 'install':
|
||||||
r = install(args[0], flatscreen=False if '--vr' in args else True if '--flat' in args else None,
|
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)
|
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':
|
elif cmd == 'patch':
|
||||||
import argparse
|
import argparse
|
||||||
parser = argparse.ArgumentParser(description='Patch and v2-sign an APK locally')
|
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.
|
# 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),
|
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)
|
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__':
|
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()
|
_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.
|
"""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.
|
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)
|
plan = inspect(path, name)
|
||||||
try:
|
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:
|
finally:
|
||||||
discard(plan)
|
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."""
|
"""Install an inspect() plan, optionally with another name, target or runtime."""
|
||||||
if name:
|
if name:
|
||||||
plan['name'], plan['id'] = name, title_id(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)
|
_choose(plan, exe or plan['target'], runtime)
|
||||||
step = progress or (lambda *a: None)
|
step = progress or (lambda *a: None)
|
||||||
with _install_lock:
|
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']
|
gid = plan['id']
|
||||||
if not NEW_ID_RE.match(gid) or gid.lower() in RESERVED_IDS:
|
if not NEW_ID_RE.match(gid) or gid.lower() in RESERVED_IDS:
|
||||||
raise FrameError(f'bad title id {gid!r}')
|
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)
|
step("Syncing Valve's devkit tools to the Frame", 0.02)
|
||||||
ensure_utils()
|
ensure_utils()
|
||||||
existed = ssh(f'test -d {GAMES}/{gid} && echo yes || true', timeout=30).strip() == 'yes'
|
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}'):
|
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}')
|
raise FrameError(f'steamos-prepare-upload returned an unexpected folder {directory!r}')
|
||||||
registered = False
|
registered = False
|
||||||
|
library_ready, shortcut, steam_registered = False, None, False
|
||||||
try:
|
try:
|
||||||
step(f"Copying {plan['size'] / 1e6:.0f} MB to the Frame", 0.1)
|
step(f"Copying {plan['size'] / 1e6:.0f} MB to the Frame", 0.1)
|
||||||
if _rsync():
|
if _rsync():
|
||||||
@@ -694,10 +703,26 @@ def _install(plan, step):
|
|||||||
err = str(reply['error']).strip().rstrip('.')
|
err = str(reply['error']).strip().rstrip('.')
|
||||||
hint = ' With Steam running on the Frame, install it again.' if 'not running' in err else ''
|
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}")
|
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)
|
step('Done', 1.0)
|
||||||
meta.update(runtime_label=RUNTIMES[plan['runtime']]['label'], steam=str(reply.get('success', '')).strip())
|
meta.update(runtime_label=RUNTIMES[plan['runtime']]['label'], steam=str(reply.get('success', '')).strip())
|
||||||
return meta
|
return meta
|
||||||
finally:
|
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:
|
if not registered and not existed:
|
||||||
# A first install that failed part-way: don't leave an orphan folder behind.
|
# A first install that failed part-way: don't leave an orphan folder behind.
|
||||||
try:
|
try:
|
||||||
@@ -706,6 +731,23 @@ def _install(plan, step):
|
|||||||
pass
|
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'''
|
LIST_SCRIPT = r'''
|
||||||
import json, os
|
import json, os
|
||||||
root = os.path.expanduser('~/devkit-game')
|
root = os.path.expanduser('~/devkit-game')
|
||||||
@@ -772,6 +814,9 @@ def remove(gid):
|
|||||||
try:
|
try:
|
||||||
gid = _check_id(gid)
|
gid = _check_id(gid)
|
||||||
ensure_utils()
|
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.
|
# 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'{PY}steamos-delete --delete-title {gid}', timeout=120)
|
||||||
ssh(f'rm -f {_json_files(gid)}', timeout=30)
|
ssh(f'rm -f {_json_files(gid)}', timeout=30)
|
||||||
|
|||||||
@@ -784,6 +784,19 @@
|
|||||||
</section>
|
</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">
|
<section class="panel" id="power">
|
||||||
<div class="shelf-head"><h2>Remote & power</h2></div>
|
<div class="shelf-head"><h2>Remote & power</h2></div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -2625,5 +2638,6 @@ setView("headset");
|
|||||||
refresh().then(loadShots); // after status, so app names resolve
|
refresh().then(loadShots); // after status, so app names resolve
|
||||||
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
|
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/artwork-settings.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+13
-1
@@ -40,6 +40,7 @@ import frame_android # noqa: E402
|
|||||||
from apk_sources import search as apk_search, SourceError # noqa: E402
|
from apk_sources import search as apk_search, SourceError # noqa: E402
|
||||||
import frame_apk_versions # noqa: E402
|
import frame_apk_versions # noqa: E402
|
||||||
import frame_catalog # noqa: E402
|
import frame_catalog # noqa: E402
|
||||||
|
import frame_steamgriddb
|
||||||
import frame_host # noqa: E402
|
import frame_host # noqa: E402
|
||||||
import frame_store # noqa: E402
|
import frame_store # noqa: E402
|
||||||
import frame_titles # 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.",
|
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.",
|
||||||
"app": m}
|
"app": m}
|
||||||
return start_job(f"Install {pkg}", work)
|
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"):
|
if action in ("launch", "stop"):
|
||||||
m = getattr(frame_android, action)(pkg)
|
m = getattr(frame_android, action)(pkg)
|
||||||
return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"}
|
return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"}
|
||||||
@@ -1305,7 +1311,9 @@ def source_manage(body):
|
|||||||
raise Failure(str(e), 400)
|
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/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
|
||||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||||
"/api/webinstall/cancel": webinstall_cancel}
|
"/api/webinstall/cancel": webinstall_cancel}
|
||||||
@@ -1409,6 +1417,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
try:
|
try:
|
||||||
if path in ("/", "/index.html"):
|
if path in ("/", "/index.html"):
|
||||||
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
|
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":
|
elif path == "/api/host":
|
||||||
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
|
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
|
||||||
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
||||||
|
|||||||
Reference in new issue
Block a user