feat: in-game theme store — server catalog + verified downloads + install UI #154

Merged
funman300 merged 1 commits from feat/theme-store into master 2026-07-07 20:23:53 +00:00
Owner

Phase 1+2 of the theme-store roadmap: a free catalog served by solitaire_server and an in-app browse/install flow. This makes custom themes installable on Android for the first time — the manual drop-a-zip flow can never reach the app-private themes dir there.

Server — drop theme zips (+ optional <id>.png preview) into THEME_STORE_DIR (compose mounts ./theme_store), restart; catalog scanned once at startup with meta-only manifest parse, sha256, 20 MiB cap, best-effort skip of bad archives. Public endpoints: GET /api/themes, /api/themes/{id}/download, /api/themes/{id}/preview.

ClientThemeStoreClient (solitaire_data) verifies size + sha256 before releasing bytes; the engine plugin then writes the zip atomically into user_theme_dir and reuses the existing hardened import_theme pipeline (zip-slip/size/manifest validation) plus in-place registry refresh.

UI — Settings → Cosmetic → "Browse theme store" (uses the configured sync server; warns if none). Modal mirrors the leaderboard panel lifecycle.

Wire types — new additive solitaire_sync::theme_store module; SyncPayload/SyncProvider/merge untouched. New deps: sha2 (workspace→server+data), serde_json as solitaire_sync dev-dep.

Tests: 8 server (scan unit + router integration), 3 data↔server round-trip over TCP, 4 client verification units, 6 engine headless UI, 2 DTO round-trips. Workspace tests, clippy -D warnings, and fmt all green.

⚠️ Based on fix/review-2026-07-07 (#153) — merge that first; this diff then shrinks to the theme-store commits only.

🤖 Generated with Claude Code

Phase 1+2 of the theme-store roadmap: a **free** catalog served by `solitaire_server` and an in-app browse/install flow. This makes custom themes installable on Android for the first time — the manual drop-a-zip flow can never reach the app-private themes dir there. **Server** — drop theme zips (+ optional `<id>.png` preview) into `THEME_STORE_DIR` (compose mounts `./theme_store`), restart; catalog scanned once at startup with meta-only manifest parse, sha256, 20 MiB cap, best-effort skip of bad archives. Public endpoints: `GET /api/themes`, `/api/themes/{id}/download`, `/api/themes/{id}/preview`. **Client** — `ThemeStoreClient` (solitaire_data) verifies size + sha256 before releasing bytes; the engine plugin then writes the zip atomically into `user_theme_dir` and reuses the existing hardened `import_theme` pipeline (zip-slip/size/manifest validation) plus in-place registry refresh. **UI** — Settings → Cosmetic → "Browse theme store" (uses the configured sync server; warns if none). Modal mirrors the leaderboard panel lifecycle. **Wire types** — new additive `solitaire_sync::theme_store` module; `SyncPayload`/`SyncProvider`/merge untouched. New deps: `sha2` (workspace→server+data), `serde_json` as solitaire_sync **dev**-dep. Tests: 8 server (scan unit + router integration), 3 data↔server round-trip over TCP, 4 client verification units, 6 engine headless UI, 2 DTO round-trips. Workspace tests, clippy `-D warnings`, and fmt all green. ⚠️ Based on `fix/review-2026-07-07` (#153) — merge that first; this diff then shrinks to the theme-store commits only. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
funman300 added 8 commits 2026-07-07 20:13:05 +00:00
Two runs (447, 486) died with 'ld terminated with signal 9' linking the
solitaire_engine test binary — the runner OOMs on full dev debuginfo for
the Bevy dependency graph. line-tables-only preserves file:line panic
backtraces at a fraction of the link footprint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The repo was formatted under an older stable; rustfmt 1.9 (Rust 1.95)
wraps signatures and call sites differently, so every touched file was
picking up unrelated formatting hunks. One mechanical pass, and a
'cargo fmt --check' step in the test workflow (same pinned 1.95.0
toolchain) so drift can't accumulate again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
user_theme_dir() panicked on desktop when dirs::data_dir() returned
None (broken $HOME / $XDG_*). Return an empty path instead — exactly
what the wasm32 branch already did — so theme discovery reports 'no
user themes' and the bundled default keeps working. Warns once with
the set_user_theme_dir() workaround. CLAUDE.md §2.3: no panic! in
runtime logic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Random arm was only unreachable because seeds_for(Random) returns
None in a different function — a future catalog change would turn it
into a shipped runtime panic. Returning a system-time seed is the
correct Random behaviour either way. CLAUDE.md §2.3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
§8 documented a Waste variant that doesn't exist in klondike 0.4; the
real convention (Stock denotes the waste pile in pile-coordinate space)
is now stated where the enum is sketched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Refresh tokens are single-use since the rotation change (PR #136). Two
in-flight requests hitting 401 together (replay upload racing a manual
sync) both called /api/auth/refresh; the second presented an already-
consumed token, got rejected, and surfaced as a spurious 'session
expired' re-login prompt.

refresh_token() now takes the stale access token that earned the 401
and runs behind a tokio::sync::Mutex; a loser of the lock race that
finds the stored token already rotated returns Ok and retries with it
instead of spending the new refresh token.

Untested caveat: exercising the race in a unit test needs an in-memory
auth_tokens double (the real keyring is unavailable on headless Linux
runners) — noted for a future test-support addition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(engine): unify light/dark theme manifest URL resolution
Test / test (pull_request) Successful in 13m4s
018b69285d
- set_theme resolved every id to themes:// — the bundled dark/classic
  themes live at embedded://, so switching to them via the public API
  would NotFound and silently keep the old theme. All three load paths
  now share one theme_manifest_url resolver.
- load_initial_theme's settings-absent fallback was "classic", a
  leftover from v0.33 when classic was the default; it now derives from
  Settings::default() so it can't drift from the data crate again, with
  a test pinning the default id to a bundled theme.
- settings.rs doc no longer claims "classic" is migrated to "dark";
  only the pre-rename "default" id is rewritten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 1+2 of the theme-store roadmap: a free catalog served by
solitaire_server and an in-app browse/install flow, making custom
themes installable on Android for the first time (the manual
drop-a-zip flow can't reach the app-private themes dir there).

- solitaire_sync: ThemeCatalogEntry/ThemeCatalogResponse wire types
  (additive module; SyncPayload and SyncProvider untouched)
- solitaire_server: THEME_STORE_DIR scan at startup (meta-only
  theme.ron parse, sha256, 20 MiB cap, best-effort per archive);
  public GET /api/themes, /api/themes/{id}/download, /{id}/preview;
  compose volume + README_SERVER docs
- solitaire_data: ThemeStoreClient — catalog fetch + download with
  mandatory size/sha256 verification before bytes are released
- solitaire_engine: ThemeStorePlugin — 'Browse theme store' button in
  Settings → Cosmetic, modal catalog (leaderboard-style rebuild),
  download on AsyncComputeTaskPool, atomic .tmp+rename write into
  user_theme_dir, then the existing hardened import_theme pipeline and
  an in-place registry refresh

New deps: sha2 (workspace, server+data); ron/zip reused in server;
serde_json added to solitaire_sync dev-deps for DTO round-trip tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
funman300 merged commit 444f8d7e33 into master 2026-07-07 20:23:53 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: funman300/Ferrous-Solitaire#154