feat: in-game theme store — server catalog + verified downloads + install UI
Test / test (pull_request) Successful in 10m15s
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:
@@ -10,11 +10,13 @@ pub mod achievements;
|
||||
pub mod merge;
|
||||
pub mod progress;
|
||||
pub mod stats;
|
||||
pub mod theme_store;
|
||||
|
||||
pub use achievements::AchievementRecord;
|
||||
pub use merge::{merge, merge_at};
|
||||
pub use progress::{PlayerProgress, level_for_xp};
|
||||
pub use stats::StatsSnapshot;
|
||||
pub use theme_store::{ThemeCatalogEntry, ThemeCatalogResponse};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Theme-store catalog wire types.
|
||||
//!
|
||||
//! Shared between `solitaire_server` (which builds the catalog by
|
||||
//! scanning its theme-store directory) and `solitaire_data` (which
|
||||
//! fetches it and downloads theme archives). These are **additive**
|
||||
//! API types: they do not participate in [`crate::SyncPayload`] and
|
||||
//! changing them cannot break the sync wire format.
|
||||
//!
|
||||
//! Store entries describe downloadable card-art theme archives — the
|
||||
//! same `.zip` format consumed by the engine's theme importer (a
|
||||
//! `theme.ron` manifest plus 53 SVGs).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One downloadable theme in the server's catalog.
|
||||
///
|
||||
/// URLs are server-relative (e.g. `/api/themes/neon/download`) so a
|
||||
/// client can join them onto whichever base URL it is configured with.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ThemeCatalogEntry {
|
||||
/// Unique theme id — `meta.id` from the archive's `theme.ron`.
|
||||
/// Also the id the theme installs under on the client.
|
||||
pub id: String,
|
||||
/// Display name shown in the store UI.
|
||||
pub name: String,
|
||||
/// Author attribution (free-form text).
|
||||
pub author: String,
|
||||
/// Version string (conventionally semver).
|
||||
pub version: String,
|
||||
/// Card aspect ratio as `(numerator, denominator)`.
|
||||
pub card_aspect: (u32, u32),
|
||||
/// Size of the `.zip` archive in bytes. Clients use this for
|
||||
/// display and as a pre-download sanity bound.
|
||||
pub size_bytes: u64,
|
||||
/// Lowercase-hex SHA-256 of the `.zip` archive. Clients MUST
|
||||
/// verify the downloaded bytes against this digest before handing
|
||||
/// the archive to the theme importer.
|
||||
pub sha256: String,
|
||||
/// Server-relative URL of the theme archive.
|
||||
pub download_url: String,
|
||||
/// Server-relative URL of a PNG preview, when the store ships one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub preview_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Response body of the catalog listing endpoint (`GET /api/themes`).
|
||||
///
|
||||
/// Wrapped in a struct (rather than a bare `Vec`) so future additive
|
||||
/// fields — pagination, store revision, purchase metadata — don't
|
||||
/// break existing clients.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ThemeCatalogResponse {
|
||||
/// Every theme currently offered by the server, sorted by name.
|
||||
pub themes: Vec<ThemeCatalogEntry>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry() -> ThemeCatalogEntry {
|
||||
ThemeCatalogEntry {
|
||||
id: "neon".into(),
|
||||
name: "Neon".into(),
|
||||
author: "Rusty Solitaire".into(),
|
||||
version: "1.0.0".into(),
|
||||
card_aspect: (2, 3),
|
||||
size_bytes: 123_456,
|
||||
sha256: "ab".repeat(32),
|
||||
download_url: "/api/themes/neon/download".into(),
|
||||
preview_url: Some("/api/themes/neon/preview".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_round_trips_through_json() {
|
||||
let response = ThemeCatalogResponse {
|
||||
themes: vec![entry()],
|
||||
};
|
||||
let json = serde_json::to_string(&response).expect("serialize");
|
||||
let back: ThemeCatalogResponse = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back, response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_preview_url_deserializes_to_none() {
|
||||
// Older servers (or themes without preview art) omit the key
|
||||
// entirely; the field must default rather than error.
|
||||
let json = r#"{
|
||||
"id": "neon",
|
||||
"name": "Neon",
|
||||
"author": "Rusty Solitaire",
|
||||
"version": "1.0.0",
|
||||
"card_aspect": [2, 3],
|
||||
"size_bytes": 1,
|
||||
"sha256": "00",
|
||||
"download_url": "/api/themes/neon/download"
|
||||
}"#;
|
||||
let entry: ThemeCatalogEntry = serde_json::from_str(json).expect("deserialize");
|
||||
assert_eq!(entry.preview_url, None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user