feat: in-game theme store — server catalog + verified downloads + install UI
Test / test (pull_request) Successful in 10m15s

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>
This commit is contained in:
funman300
2026-07-07 13:12:41 -07:00
parent 018b69285d
commit d87397b382
23 changed files with 1718 additions and 3 deletions
+124
View File
@@ -1614,6 +1614,7 @@ async fn auth_rate_limit_returns_429_on_11th_request() {
let state = solitaire_server::AppState {
pool: test_pool().await,
jwt_secret: TEST_SECRET.to_string(),
theme_store: std::sync::Arc::new(solitaire_server::theme_store::ThemeStore::empty()),
};
let app = solitaire_server::build_router(state);
@@ -1675,6 +1676,7 @@ async fn sync_push_rate_limit_returns_429_on_11th_request() {
let state = solitaire_server::AppState {
pool: test_pool().await,
jwt_secret: TEST_SECRET.to_string(),
theme_store: std::sync::Arc::new(solitaire_server::theme_store::ThemeStore::empty()),
};
let app = solitaire_server::build_router(state);
@@ -1879,3 +1881,125 @@ async fn replay_upload_malformed_body_returns_400() {
let resp = post_authed(app, "/api/replays", &token, bad).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
// ---------------------------------------------------------------------------
// Theme store
// ---------------------------------------------------------------------------
/// Writes a minimal theme zip (manifest only — the catalog scan reads
/// just the `meta` block) into `dir` and returns its path.
fn write_store_zip(
dir: &std::path::Path,
file_name: &str,
id: &str,
name: &str,
) -> std::path::PathBuf {
use std::io::Write as _;
let manifest = format!(
r#"(
meta: (
id: "{id}",
name: "{name}",
author: "Test Author",
version: "1.0.0",
card_aspect: (2, 3),
),
faces: {{}},
back: "back.svg",
)"#
);
let path = dir.join(file_name);
let file = std::fs::File::create(&path).expect("create zip");
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default();
writer.start_file("theme.ron", options).expect("start_file");
writer
.write_all(manifest.as_bytes())
.expect("write manifest");
writer.finish().expect("finish zip");
path
}
/// `GET /api/themes` returns the scanned catalog as JSON, no auth needed.
#[tokio::test]
async fn theme_catalog_lists_scanned_themes() {
let dir = tempfile::tempdir().expect("tempdir");
let zip_path = write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
let store = solitaire_server::theme_store::ThemeStore::scan(dir.path());
let app = solitaire_server::build_test_router_with_theme_store(test_pool().await, store);
let req = Request::builder()
.uri("/api/themes")
.body(Body::empty())
.expect("build request");
let resp = app.oneshot(req).await.expect("oneshot");
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("read body");
let catalog: solitaire_sync::ThemeCatalogResponse =
serde_json::from_slice(&body).expect("parse catalog");
assert_eq!(catalog.themes.len(), 1);
let entry = &catalog.themes[0];
assert_eq!(entry.id, "neon");
assert_eq!(entry.name, "Neon");
assert_eq!(entry.download_url, "/api/themes/neon/download");
assert_eq!(entry.preview_url, None);
let zip_bytes = std::fs::read(&zip_path).expect("read zip");
assert_eq!(entry.size_bytes, zip_bytes.len() as u64);
}
/// The download endpoint streams back exactly the bytes on disk, so a
/// client hashing the response gets the catalog's sha256.
#[tokio::test]
async fn theme_download_returns_archive_bytes() {
let dir = tempfile::tempdir().expect("tempdir");
let zip_path = write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
let store = solitaire_server::theme_store::ThemeStore::scan(dir.path());
let app = solitaire_server::build_test_router_with_theme_store(test_pool().await, store);
let req = Request::builder()
.uri("/api/themes/neon/download")
.body(Body::empty())
.expect("build request");
let resp = app.oneshot(req).await.expect("oneshot");
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.and_then(|v| v.to_str().ok()),
Some("application/zip")
);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("read body");
assert_eq!(&body[..], &std::fs::read(&zip_path).expect("read zip")[..]);
}
/// Unknown ids 404 on both file endpoints; a theme without preview art
/// 404s on `/preview` while still serving its download.
#[tokio::test]
async fn theme_unknown_id_and_missing_preview_return_404() {
let dir = tempfile::tempdir().expect("tempdir");
write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
let store = solitaire_server::theme_store::ThemeStore::scan(dir.path());
let app = solitaire_server::build_test_router_with_theme_store(test_pool().await, store);
for uri in [
"/api/themes/nope/download",
"/api/themes/nope/preview",
"/api/themes/neon/preview",
] {
let req = Request::builder()
.uri(uri)
.body(Body::empty())
.expect("build request");
let resp = app.clone().oneshot(req).await.expect("oneshot");
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"expected 404 for {uri}"
);
}
}