d87397b382
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>
103 lines
3.8 KiB
Rust
103 lines
3.8 KiB
Rust
//! 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);
|
|
}
|
|
}
|