- A headset set up while a bare alias is in use doesn't take over by itself;
a login change from ~/.ssh/config waits for running installs.
- Saving a headset writes only the login fields that changed, and only if the
block still holds the old ones.
- SSH, SFTP, power and remote desktop open with the same headset and address
as every other command, and refuse when there's no address.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GitHub's opengraph preview is repo text, stats and an identicon; as a Steam hero
it looked broken. GitHub entries no longer default to it (the store draws its
fallback, Steam gets generated art). Source GIFs (common gameplay captures) are
accepted; the Frame's Chromium draws the first frame. Open Saber Plus uses its
gameplay GIF as banner. Verified on the Frame: hero/wide now show gameplay.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- 'Refresh artwork' (settings) and the API's refresh-art --all cover devkit
titles as well as Android apps; frame_titles.py gains refresh-art ID|--all.
- Apps and titles without complete Steam artwork are flagged (art_missing):
the app shows 'Add artwork', and the CLIs' list prints the refresh command.
- When the app lists them and Steam answers, Frame Control re-applies their
art in the background (at most every five minutes), e.g. for a title Steam
registered after an install made while it wasn't running.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Remove: collections and artwork clearing are best effort in Steam's JS, and
the host carries on to delete the files when Steam isn't running (Android
apps and devkit titles).
- Devkit titles: the shortcut is found by devkit id, the saved id, or an
executable/start folder inside the title's folder; never by display name.
Steam's overviews don't carry devkit_gameid (checked on the Frame
2026-09-28), so 'list' now reads exe/start dir from app details.
- Photo-based grid/wide/hero slots render as JPEG (a noise-heavy 3840x1240
hero was over 12 MiB as PNG on the Frame); the logo stays transparent PNG.
Rendering gets 75 s and retries once with generated art. Each slot is
cleared before it is set, since Steam keeps .png and .jpg side by side.
- Devkit titles keep their own VR flag (vr=None skips SetShortcutIsVR) and
their Sideloaded collection.
- A failed title install's cleanup can't replace the original error.
- refresh_art for devkit titles (frame_titles.refresh_art).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Any failure of a source image or SteamGridDB (HTTPException, odd JSON) now
becomes a warning and generated art, never an aborted install; refresh-art
--all reports each app and carries on.
- URL artwork goes through apk_sources._images: public addresses only, at most
three redirects, and one overall deadline for all of an install's fetches.
- PNGs are checked from their header only (any depth or interlace; Steam's
Chromium decodes them), JPEGs may have trailing padding, and 4K screenshots
are within limits. The slow pure-Python decoder is gone.
- SteamGridDB title matching keeps letters of every script and never matches
on an empty name. One warning per source slot, not per candidate.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Once flock is held no launcher owns a running container (a SIGKILLed launcher
left it), so it is stopped and the launch continues. fd 9 is closed for the
Lepton child so it can't keep the lock held.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Removing or moving the active headset's address waits for running installs,
like switching.
- A volume change still waiting to be sent goes to the headset whose slider
it was, and a switch cancels it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- The locked publication step also refuses a v1 index once v2 was accepted, so
an overlapping v1 fallback can't replace a v2 cache at an equal timestamp.
- A cached APK is touched before hashing; if it vanishes, it's downloaded again.
- Only the app prunes (at start and after store downloads), since claims are
in-process; the CLIs never prune.
- The CLI joins background refreshes on error exits too.
- The Windows lock loop retries only contention errors.
The concurrent-publication test now uses real flock contention.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A title waiting its turn to be read stays with the headset it was dropped
on, and is dropped if the app switches meanwhile.
- Probes still finishing from an earlier attempt can't overwrite the rows of
a newer one.
- The FRAME_ALIAS the server started with stays on the list after switching
away, so it can be picked again.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A batch of dropped files stays with the headset it was dropped on, and
stops if the app switches.
- Removing or moving the address in use reroutes at once; a headset with no
addresses reaches nothing rather than whatever ~/.ssh/config says.
- A late answer to an older device-list request is ignored.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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>
- Removing every headset leaves none in use (commands fail at once) instead of
falling back to the `frame` alias.
- ssh goes to the IP that answered for IPv6 too, with a link-local address's
interface (verified: frame.local over fe80::…%en9 on the real Frame).
- Find results only show in the panel of the headset they were for.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Two clients restoring the same package could each swap directories and then
delete the other's pre-restore copy. The swap and retention cleanup now run
under flock on .<package>.restore.lock.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Deletion re-stats under a lock shared with touch() (F-Droid cache reuse) and
claim()/release() (held by the store around install), so a reused or
installing APK is never removed from an out-of-date scan.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A query arriving between the queue handover and the completion event could be
queued with nobody to start it, leaving the source 'loading' forever.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The final timestamp recheck, cache write and state update now run under a file
lock (flock, or msvcrt on Windows), so the CLI and the app can't publish
indexes out of order. The CLI joins background refreshes before exiting so an
expired index doesn't stay expired.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- The headset a change is meant for is checked and the work counted in one
step, so a switch can't slip in between (uploads too).
- A sideloaded title read on one headset can't be installed on another; open
confirmations close on a switch.
- The only headset can't be removed while its ssh alias stays behind.
- Answers about the previous headset are dropped without touching panels; the
catalogue's Installed tags are rebuilt for the new headset.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Backups no longer abort on a symlink: it is left out and listed (path and
target) in manifest.json, now written last. A hard link is stored as a copy of
its file. Restore removes older pre-restore copies of the same package.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A switch publishes the new headset at once, so the page clears the old one's
panels and the lists behind them (games, store, Android apps, screenshots).
- The page names the headset its changes are for (X-Frame-Device); the server
refuses one meant for a headset it has switched away from (409).
- A rejected address edit changes nothing.
- Test now goes to the IPv4 address that answered, like the connection.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
install_obb needs the app's running instance, which doesn't exist straight after
install, so the store no longer calls it there. The install result says the app
needs its game data; after opening the app once, 'Add game data' copies the
downloaded OBB files (a background job).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Adding a repository downloads and verifies its whole index, so it now runs as a
job (runJob in the UI) and reports 'Trusted on first use: <fingerprint>' when
no pin was given. fdroidrepos:// links pass the server check, as documented.
Jobs report SourceError messages without a 'SourceError:' prefix.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Runs after each APK download and at server start. APKs used in the last hour
are kept; an F-Droid cache hit refreshes the APK's mtime.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Searches no longer wait for (or fail on) a refresh of an expired index; the
store notes which sources show saved listings. A failed refresh keeps the old
index and is retried after 10 minutes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A host that answers 403/429 is left alone until its Retry-After (or GitHub's
rate-limit reset; default 10 minutes). Meanwhile cached data is served, or the
source reports 'limited' with its own name, e.g. 'GitHub is limiting requests;
try again in 10 minutes'. Covers _web reads/downloads and F-Droid fetches.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each repository's newest accepted index timestamp is stored and older indexes
are refused. index-v1.jar is only a fallback while no v2 index has been
accepted. entry.jar must use SHA-2; the recorded IzzyOnDroid entry.jar is
SHA-256 and still verifies. Tests sign JARs with a throwaway key.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A search for a different query while a source is busy now queues (newest wins)
instead of being dropped, and warm() uses the browse limit so the first browse
reuses it. set_enabled calls the source before taking search._lock. A
SourceLimited error reports the source as 'limited'.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>