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
+7
View File
@@ -24,6 +24,9 @@ uuid = { workspace = true }
dirs = { workspace = true }
reqwest = { workspace = true }
tokio = { workspace = true }
# Theme-store downloads are verified against the catalog's SHA-256
# before the archive is handed to the engine's theme importer.
sha2 = { workspace = true }
# `keyring-core` is the typed Entry/Error API used by
# `auth_tokens`. The crate's own dependency tree pulls in
@@ -47,6 +50,10 @@ sqlx = { workspace = true }
jsonwebtoken = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
# theme_store_round_trip builds a theme zip in a tempdir for the
# in-process store server.
zip = { workspace = true }
tempfile = { workspace = true }
[lints]
workspace = true
+5
View File
@@ -161,6 +161,11 @@ pub use sync_client::LocalOnlyProvider;
#[cfg(not(target_arch = "wasm32"))]
pub use sync_client::{SolitaireServerClient, provider_for_backend};
#[cfg(not(target_arch = "wasm32"))]
pub mod theme_store_client;
#[cfg(not(target_arch = "wasm32"))]
pub use theme_store_client::{ThemeStoreClient, ThemeStoreError};
pub mod replay;
pub use replay::{
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
+200
View File
@@ -0,0 +1,200 @@
//! HTTP client for the server's theme-store endpoints.
//!
//! Fetches the catalog (`GET /api/themes`) and downloads theme
//! archives, verifying each download's size and SHA-256 against the
//! catalog entry before returning the bytes. The caller (the engine's
//! theme-store UI) hands verified bytes to the theme importer, which
//! independently re-validates the archive's structure — the store
//! pipeline never trusts a byte the importer hasn't checked.
//!
//! Native-only: gated out on wasm32 alongside the other `reqwest`
//! consumers in this crate.
use sha2::{Digest, Sha256};
use solitaire_sync::{ThemeCatalogEntry, ThemeCatalogResponse};
use thiserror::Error;
/// Hard cap on a theme archive download, matching the engine
/// importer's `MAX_ARCHIVE_BYTES` — anything larger can never import,
/// so downloading it is pure waste.
pub const MAX_THEME_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024;
/// Errors surfaced by [`ThemeStoreClient`].
#[derive(Debug, Error)]
pub enum ThemeStoreError {
/// The request could not be sent or the response body not read.
#[error("network error: {0}")]
Network(String),
/// The server answered with a non-success status code.
#[error("server returned HTTP {0}")]
Http(u16),
/// The catalog JSON did not parse.
#[error("malformed catalog: {0}")]
MalformedCatalog(String),
/// The archive is larger than [`MAX_THEME_DOWNLOAD_BYTES`] or its
/// catalog-declared size.
#[error("archive size {got} exceeds the expected {expected} bytes")]
Oversized { expected: u64, got: u64 },
/// The downloaded bytes do not hash to the catalog's SHA-256 —
/// the file changed on the server or was corrupted in transit.
#[error("archive checksum mismatch (expected {expected}, got {got})")]
ChecksumMismatch { expected: String, got: String },
}
/// Client for one server's theme store.
///
/// Unauthenticated: the catalog and downloads are public endpoints,
/// so unlike `SolitaireServerClient` there is no token handling.
pub struct ThemeStoreClient {
/// Base URL of the server, trailing slash stripped.
base_url: String,
client: reqwest::Client,
}
impl ThemeStoreClient {
/// Construct a client for the server at `base_url`
/// (e.g. `"https://solitaire.example.com"`).
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into().trim_end_matches('/').to_owned(),
client: reqwest::Client::new(),
}
}
/// Fetch the theme catalog, sorted by display name (server-side).
pub async fn fetch_catalog(&self) -> Result<Vec<ThemeCatalogEntry>, ThemeStoreError> {
let resp = self
.client
.get(format!("{}/api/themes", self.base_url))
.send()
.await
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(ThemeStoreError::Http(resp.status().as_u16()));
}
let catalog: ThemeCatalogResponse = resp
.json()
.await
.map_err(|e| ThemeStoreError::MalformedCatalog(e.to_string()))?;
Ok(catalog.themes)
}
/// Download `entry`'s archive and verify it against the catalog's
/// size and SHA-256. Returns the verified `.zip` bytes, ready for
/// the engine's theme importer.
pub async fn download_theme(
&self,
entry: &ThemeCatalogEntry,
) -> Result<Vec<u8>, ThemeStoreError> {
// The catalog itself could name an absurd size; refuse before
// buffering anything.
if entry.size_bytes > MAX_THEME_DOWNLOAD_BYTES {
return Err(ThemeStoreError::Oversized {
expected: MAX_THEME_DOWNLOAD_BYTES,
got: entry.size_bytes,
});
}
let resp = self
.client
.get(format!("{}{}", self.base_url, entry.download_url))
.send()
.await
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(ThemeStoreError::Http(resp.status().as_u16()));
}
let bytes = resp
.bytes()
.await
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
verify_archive(&bytes, entry)?;
Ok(bytes.to_vec())
}
}
/// Checks downloaded `bytes` against the catalog `entry`'s declared
/// size and SHA-256. Pure so it can be unit-tested without a server.
fn verify_archive(bytes: &[u8], entry: &ThemeCatalogEntry) -> Result<(), ThemeStoreError> {
if bytes.len() as u64 != entry.size_bytes {
return Err(ThemeStoreError::Oversized {
expected: entry.size_bytes,
got: bytes.len() as u64,
});
}
let got = hex_digest(bytes);
// Case-insensitive: the server emits lowercase hex, but a
// hand-authored catalog mirror shouldn't fail on case alone.
if !got.eq_ignore_ascii_case(&entry.sha256) {
return Err(ThemeStoreError::ChecksumMismatch {
expected: entry.sha256.clone(),
got,
});
}
Ok(())
}
/// Lowercase-hex SHA-256 of `bytes`. Mirrors the server's digest
/// encoding in `solitaire_server::theme_store`.
fn hex_digest(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
out.push_str(&format!("{byte:02x}"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn entry_for(bytes: &[u8]) -> ThemeCatalogEntry {
ThemeCatalogEntry {
id: "neon".into(),
name: "Neon".into(),
author: "Test".into(),
version: "1.0.0".into(),
card_aspect: (2, 3),
size_bytes: bytes.len() as u64,
sha256: hex_digest(bytes),
download_url: "/api/themes/neon/download".into(),
preview_url: None,
}
}
#[test]
fn verify_accepts_matching_bytes() {
let bytes = b"theme archive bytes";
assert!(verify_archive(bytes, &entry_for(bytes)).is_ok());
}
#[test]
fn verify_accepts_uppercase_catalog_hash() {
let bytes = b"theme archive bytes";
let mut entry = entry_for(bytes);
entry.sha256 = entry.sha256.to_uppercase();
assert!(verify_archive(bytes, &entry).is_ok());
}
#[test]
fn verify_rejects_size_mismatch() {
let bytes = b"theme archive bytes";
let mut entry = entry_for(bytes);
entry.size_bytes += 1;
assert!(matches!(
verify_archive(bytes, &entry),
Err(ThemeStoreError::Oversized { .. })
));
}
#[test]
fn verify_rejects_checksum_mismatch() {
let bytes = b"theme archive bytes";
let mut entry = entry_for(bytes);
entry.sha256 = "0".repeat(64);
assert!(matches!(
verify_archive(bytes, &entry),
Err(ThemeStoreError::ChecksumMismatch { .. })
));
}
}
@@ -0,0 +1,118 @@
//! End-to-end theme-store test: a real `solitaire_server` router
//! serving a scanned catalog over a localhost TCP socket, consumed by
//! [`solitaire_data::ThemeStoreClient`].
//!
//! Mirrors the `sync_round_trip` harness: `TcpListener` on port 0,
//! router in a background `tokio::spawn`, no explicit shutdown.
#![cfg(not(target_arch = "wasm32"))]
use std::io::Write as _;
use std::path::{Path, PathBuf};
use solitaire_data::ThemeStoreClient;
use solitaire_server::theme_store::ThemeStore;
/// Minimal theme archive: the catalog scan only reads the `meta`
/// block, so no face SVGs are needed.
fn write_store_zip(dir: &Path, file_name: &str, id: &str, name: &str) -> PathBuf {
let manifest = format!(
r#"(
meta: (
id: "{id}",
name: "{name}",
author: "Round Trip",
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
}
/// Spawn the test server with a theme store scanned from `store_dir`
/// and return its base URL.
async fn spawn_store_server(store_dir: &Path) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind test listener");
let addr = listener.local_addr().expect("listener has no local addr");
let pool = solitaire_server::build_test_pool().await;
let app =
solitaire_server::build_test_router_with_theme_store(pool, ThemeStore::scan(store_dir));
tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app).await {
eprintln!("test server crashed: {e}");
}
});
format!("http://{addr}")
}
/// Catalog fetch → download → verified bytes match the file the
/// server scanned. This is the exact path the engine's store UI runs.
#[tokio::test]
async fn catalog_fetch_and_verified_download_round_trip() {
let dir = tempfile::tempdir().expect("tempdir");
let zip_path = write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
let base_url = spawn_store_server(dir.path()).await;
let client = ThemeStoreClient::new(&base_url);
let catalog = client.fetch_catalog().await.expect("fetch catalog");
assert_eq!(catalog.len(), 1);
let entry = &catalog[0];
assert_eq!(entry.id, "neon");
let bytes = client.download_theme(entry).await.expect("download");
assert_eq!(bytes, std::fs::read(&zip_path).expect("read zip"));
}
/// A catalog entry whose hash no longer matches the served file must
/// be rejected client-side — the importer never sees unverified bytes.
#[tokio::test]
async fn download_with_stale_catalog_hash_is_rejected() {
let dir = tempfile::tempdir().expect("tempdir");
write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
let base_url = spawn_store_server(dir.path()).await;
let client = ThemeStoreClient::new(&base_url);
let mut entry = client.fetch_catalog().await.expect("fetch catalog")[0].clone();
entry.sha256 = "0".repeat(64);
let err = client
.download_theme(&entry)
.await
.expect_err("mismatched hash must fail");
assert!(
matches!(
err,
solitaire_data::ThemeStoreError::ChecksumMismatch { .. }
),
"expected ChecksumMismatch, got: {err:?}"
);
}
/// An empty (or absent) store directory serves an empty catalog — the
/// client sees a store with nothing in it, not an error.
#[tokio::test]
async fn empty_store_yields_empty_catalog() {
let dir = tempfile::tempdir().expect("tempdir");
let base_url = spawn_store_server(dir.path()).await;
let client = ThemeStoreClient::new(&base_url);
let catalog = client.fetch_catalog().await.expect("fetch catalog");
assert!(catalog.is_empty());
}