Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea1014285d | |||
| 299f6bfea7 | |||
| 6f27e775f2 | |||
| 358bbc7eb5 | |||
| 444f8d7e33 | |||
| a80547c514 | |||
| d87397b382 | |||
| a4ad848c93 | |||
| ff8c00d2f4 | |||
| 38b81a4004 | |||
| ae7af9adf4 |
Generated
+8
@@ -7351,13 +7351,16 @@ dependencies = [
|
|||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"solitaire_core",
|
"solitaire_core",
|
||||||
"solitaire_server",
|
"solitaire_server",
|
||||||
"solitaire_sync",
|
"solitaire_sync",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7402,10 +7405,13 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
|
"ron",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"solitaire_sync",
|
"solitaire_sync",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower",
|
||||||
@@ -7414,6 +7420,7 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7422,6 +7429,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ dirs = "6"
|
|||||||
keyring = "4"
|
keyring = "4"
|
||||||
keyring-core = "1"
|
keyring-core = "1"
|
||||||
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
||||||
|
sha2 = "0.10"
|
||||||
arboard = { version = "3", default-features = false }
|
arboard = { version = "3", default-features = false }
|
||||||
jni = { version = "0.21", default-features = false }
|
jni = { version = "0.21", default-features = false }
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,26 @@ docker compose up -d
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Theme store
|
||||||
|
|
||||||
|
The server can offer card-art themes for in-game download. Drop theme
|
||||||
|
`.zip` archives (the same format the game's Settings → Import accepts:
|
||||||
|
a `theme.ron` manifest plus 53 SVGs) into the directory named by
|
||||||
|
`THEME_STORE_DIR` (default: `theme_store/` next to the binary), then
|
||||||
|
restart the server — the catalog is scanned once at startup. An
|
||||||
|
optional `<theme-id>.png` in the same directory becomes the theme's
|
||||||
|
store preview.
|
||||||
|
|
||||||
|
Endpoints (public, no auth):
|
||||||
|
|
||||||
|
- `GET /api/themes` — catalog JSON (id, name, author, size, sha256)
|
||||||
|
- `GET /api/themes/<id>/download` — the archive
|
||||||
|
- `GET /api/themes/<id>/preview` — the preview PNG, if present
|
||||||
|
|
||||||
|
Archives that are oversized (> 20 MiB), unreadable, or have a
|
||||||
|
malformed `theme.ron` are skipped with a warning in the server log;
|
||||||
|
they never fail startup.
|
||||||
|
|
||||||
## Admin — Password Reset
|
## Admin — Password Reset
|
||||||
|
|
||||||
If a player loses access to their account, the server binary includes a
|
If a player loses access to their account, the server binary includes a
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ spec:
|
|||||||
key: jwt-secret
|
key: jwt-secret
|
||||||
- name: SERVER_PORT
|
- name: SERVER_PORT
|
||||||
value: "8080"
|
value: "8080"
|
||||||
|
# Theme-store catalog directory on the persistent volume.
|
||||||
|
# Scanned once at startup — after dropping new theme zips
|
||||||
|
# into /data/theme_store, restart the deployment.
|
||||||
|
- name: THEME_STORE_DIR
|
||||||
|
value: /data/theme_store
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: db-data
|
- name: db-data
|
||||||
mountPath: /data
|
mountPath: /data
|
||||||
|
|||||||
@@ -6,8 +6,13 @@ services:
|
|||||||
# Override DATABASE_URL so the DB always lands in the persistent volume,
|
# Override DATABASE_URL so the DB always lands in the persistent volume,
|
||||||
# regardless of what .env contains.
|
# regardless of what .env contains.
|
||||||
DATABASE_URL: sqlite:///data/solitaire.db
|
DATABASE_URL: sqlite:///data/solitaire.db
|
||||||
|
# Theme-store catalog directory (scanned once at startup; the
|
||||||
|
# host ./theme_store folder is where the operator drops theme
|
||||||
|
# zips + preview PNGs).
|
||||||
|
THEME_STORE_DIR: /theme_store
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
- ./theme_store:/theme_store:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
expose:
|
expose:
|
||||||
- "${SERVER_PORT:-8080}"
|
- "${SERVER_PORT:-8080}"
|
||||||
|
|||||||
@@ -1109,6 +1109,66 @@ impl GameState {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Full winning line from the current position:
|
||||||
|
///
|
||||||
|
/// * `Ok(Some(line))` — winnable; applying every instruction in order via
|
||||||
|
/// [`GameState::apply_instruction`] reaches a won game.
|
||||||
|
/// * `Ok(None)` — provably unwinnable, or already won (no moves to show).
|
||||||
|
/// * `Err(SolveError)` — inconclusive; budget exhausted before a verdict.
|
||||||
|
///
|
||||||
|
/// Delegates to upstream [`card_game::Session::solve`] on a solve-budgeted
|
||||||
|
/// copy of the board like [`GameState::solve_first_move`], but returns the
|
||||||
|
/// whole path instead of the first move, compacted with
|
||||||
|
/// [`card_game::Solution::clean_solution`] (drops move ranges that loop
|
||||||
|
/// back to an already-seen state — the raw DFS trace is full of them).
|
||||||
|
///
|
||||||
|
/// Foundation→foundation shuffles ([`KlondikeInstruction::is_useless`])
|
||||||
|
/// are additionally stripped when the remaining sequence still replays to
|
||||||
|
/// a win; if stripping one would break a later move's preconditions the
|
||||||
|
/// unstripped cleaned line is returned instead, so the replay contract
|
||||||
|
/// above always holds. `clean_solution` is quadratic-ish in line length —
|
||||||
|
/// callers should run this off the UI thread with modest budgets.
|
||||||
|
pub fn winning_line(
|
||||||
|
&self,
|
||||||
|
moves_budget: u64,
|
||||||
|
states_budget: u64,
|
||||||
|
) -> Result<Option<Vec<KlondikeInstruction>>, SolveError> {
|
||||||
|
if self.is_won() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let inner = KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation);
|
||||||
|
let config = SessionConfig {
|
||||||
|
inner: inner.clone(),
|
||||||
|
undo_penalty: 0,
|
||||||
|
solve_moves_budget: moves_budget,
|
||||||
|
solve_states_budget: states_budget,
|
||||||
|
};
|
||||||
|
let start = self.session.state().state().clone();
|
||||||
|
let session = Session::new(start.clone(), config);
|
||||||
|
|
||||||
|
let Some(solution) = session.solve()? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let cleaned: Vec<KlondikeInstruction> = solution
|
||||||
|
.clean_solution()
|
||||||
|
.iter()
|
||||||
|
.map(|snapshot| *snapshot.instruction())
|
||||||
|
.collect();
|
||||||
|
let filtered: Vec<KlondikeInstruction> = cleaned
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|instruction| !instruction.is_useless())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if line_replays_to_win(start, &inner, &filtered) {
|
||||||
|
Ok(Some(filtered))
|
||||||
|
} else {
|
||||||
|
Ok(Some(cleaned))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Solvability of a fresh Classic-mode deal from `seed` + `draw_mode`.
|
/// Solvability of a fresh Classic-mode deal from `seed` + `draw_mode`.
|
||||||
///
|
///
|
||||||
/// Fresh-deal solving models standard Klondike rules, so the non-standard
|
/// Fresh-deal solving models standard Klondike rules, so the non-standard
|
||||||
@@ -1126,6 +1186,25 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `true` when applying `line` in order from `start` is legal at every step
|
||||||
|
/// and ends in a won game. Pure replay check backing
|
||||||
|
/// [`GameState::winning_line`]'s "the returned sequence always replays to a
|
||||||
|
/// win" contract.
|
||||||
|
fn line_replays_to_win(
|
||||||
|
mut state: Klondike,
|
||||||
|
config: &KlondikeConfig,
|
||||||
|
line: &[KlondikeInstruction],
|
||||||
|
) -> bool {
|
||||||
|
let mut stats = <Klondike as card_game::Game>::Stats::default();
|
||||||
|
for &instruction in line {
|
||||||
|
if !state.is_instruction_valid(config, instruction) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state.process_instruction(&mut stats, config, instruction);
|
||||||
|
}
|
||||||
|
state.is_win()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1351,6 +1430,69 @@ mod tests {
|
|||||||
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
|
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Full winning line (winning_line) ──────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn winning_line_replays_to_a_won_game() {
|
||||||
|
// Seed 0xD1FF_0000_0000_0012 / DrawOne is proven Winnable at 5k
|
||||||
|
// budgets by `budget_is_passed_through_not_clamped`. Standard rules
|
||||||
|
// (take-from-foundation off) keep the search space identical to
|
||||||
|
// that baseline. The returned line must apply cleanly through the
|
||||||
|
// normal instruction pipeline and end in a win — the whole point
|
||||||
|
// of the API contract.
|
||||||
|
let mut game = GameState::new(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne);
|
||||||
|
game.take_from_foundation = false;
|
||||||
|
let line = game
|
||||||
|
.winning_line(5_000, 5_000)
|
||||||
|
.expect("this seed must not exhaust a 5k budget")
|
||||||
|
.expect("this seed must be winnable");
|
||||||
|
assert!(!line.is_empty(), "a winnable unfinished game needs moves");
|
||||||
|
for (i, instruction) in line.iter().enumerate() {
|
||||||
|
game.apply_instruction(*instruction)
|
||||||
|
.unwrap_or_else(|e| panic!("move {i} of the line must be legal: {e}"));
|
||||||
|
}
|
||||||
|
assert!(game.is_won(), "line must end in a won game");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn winning_line_contains_no_useless_moves() {
|
||||||
|
// The foundation→foundation strip must survive the replay check on
|
||||||
|
// this seed; a line shown to the player should never shuffle
|
||||||
|
// between foundations. Same proven-winnable seed and standard
|
||||||
|
// rules as `winning_line_replays_to_a_won_game`.
|
||||||
|
let mut game = GameState::new(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne);
|
||||||
|
game.take_from_foundation = false;
|
||||||
|
let line = game
|
||||||
|
.winning_line(5_000, 5_000)
|
||||||
|
.expect("budget")
|
||||||
|
.expect("winnable");
|
||||||
|
assert!(
|
||||||
|
!line.iter().any(KlondikeInstruction::is_useless),
|
||||||
|
"filtered line must not contain foundation→foundation moves"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn winning_line_is_inconclusive_when_budget_exhausted() {
|
||||||
|
let game = GameState::new(7, DrawStockConfig::DrawOne);
|
||||||
|
let outcome = game.winning_line(5_000, 0);
|
||||||
|
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn winning_line_matches_first_move_verdict() {
|
||||||
|
// The two solver entry points must agree on winnability for the
|
||||||
|
// same position and budgets (both are deterministic DFS).
|
||||||
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let first = game.solve_first_move(5_000, 5_000);
|
||||||
|
let line = game.winning_line(5_000, 5_000);
|
||||||
|
assert_eq!(
|
||||||
|
matches!(first, Ok(Some(_))),
|
||||||
|
matches!(line, Ok(Some(_))),
|
||||||
|
"solve_first_move and winning_line disagree on winnability"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn budget_is_passed_through_not_clamped() {
|
fn budget_is_passed_through_not_clamped() {
|
||||||
// This seed is Inconclusive at 1k states but Winnable at 5k — proving the
|
// This seed is Inconclusive at 1k states but Winnable at 5k — proving the
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ uuid = { workspace = true }
|
|||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
tokio = { 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
|
# `keyring-core` is the typed Entry/Error API used by
|
||||||
# `auth_tokens`. The crate's own dependency tree pulls in
|
# `auth_tokens`. The crate's own dependency tree pulls in
|
||||||
@@ -47,6 +50,10 @@ sqlx = { workspace = true }
|
|||||||
jsonwebtoken = { workspace = true }
|
jsonwebtoken = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { 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]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -161,6 +161,11 @@ pub use sync_client::LocalOnlyProvider;
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use sync_client::{SolitaireServerClient, provider_for_backend};
|
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 mod replay;
|
||||||
pub use replay::{
|
pub use replay::{
|
||||||
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
|
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -18,13 +18,15 @@ use crate::{
|
|||||||
DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
|
DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
|
||||||
HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
|
HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
|
||||||
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
|
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
|
||||||
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncProvider,
|
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin,
|
||||||
TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin,
|
StatsPlugin, SyncProvider, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
||||||
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
||||||
|
WinSummaryPlugin,
|
||||||
};
|
};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use crate::{
|
use crate::{
|
||||||
AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin,
|
AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin,
|
||||||
|
ThemeStorePlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Groups all Ferrous Solitaire gameplay plugins.
|
/// Groups all Ferrous Solitaire gameplay plugins.
|
||||||
@@ -92,6 +94,7 @@ impl Plugin for CoreGamePlugin {
|
|||||||
.add_plugins(FeedbackAnimPlugin)
|
.add_plugins(FeedbackAnimPlugin)
|
||||||
.add_plugins(CardAnimationPlugin)
|
.add_plugins(CardAnimationPlugin)
|
||||||
.add_plugins(AutoCompletePlugin)
|
.add_plugins(AutoCompletePlugin)
|
||||||
|
.add_plugins(SolutionPlaybackPlugin)
|
||||||
.add_plugins(ReplayPlaybackPlugin)
|
.add_plugins(ReplayPlaybackPlugin)
|
||||||
.add_plugins(ReplayOverlayPlugin)
|
.add_plugins(ReplayOverlayPlugin)
|
||||||
.add_plugins(StatsPlugin::default())
|
.add_plugins(StatsPlugin::default())
|
||||||
@@ -126,6 +129,7 @@ impl Plugin for CoreGamePlugin {
|
|||||||
.add_plugins(AudioPlugin)
|
.add_plugins(AudioPlugin)
|
||||||
.add_plugins(SyncPlugin::new(sync_provider))
|
.add_plugins(SyncPlugin::new(sync_provider))
|
||||||
.add_plugins(SyncSetupPlugin)
|
.add_plugins(SyncSetupPlugin)
|
||||||
|
.add_plugins(ThemeStorePlugin)
|
||||||
.add_plugins(AnalyticsPlugin)
|
.add_plugins(AnalyticsPlugin)
|
||||||
.add_plugins(LeaderboardPlugin);
|
.add_plugins(LeaderboardPlugin);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,12 @@ pub struct ManualSyncRequestEvent;
|
|||||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
pub struct SyncConfigureRequestEvent;
|
pub struct SyncConfigureRequestEvent;
|
||||||
|
|
||||||
|
/// Request to open the in-game theme-store modal. Fired by the
|
||||||
|
/// "Browse theme store" button in the Settings cosmetic section;
|
||||||
|
/// handled by `theme_store_plugin`.
|
||||||
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
|
pub struct ThemeStoreOpenRequestEvent;
|
||||||
|
|
||||||
/// Request to disconnect from the current sync backend, clear stored
|
/// Request to disconnect from the current sync backend, clear stored
|
||||||
/// credentials, and reset to `SyncBackend::Local`. Fired by the "Disconnect"
|
/// credentials, and reset to `SyncBackend::Local`. Fired by the "Disconnect"
|
||||||
/// button in the Settings sync section.
|
/// button in the Settings sync section.
|
||||||
@@ -153,6 +159,12 @@ pub struct DeleteAccountRequestEvent;
|
|||||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
pub struct PauseRequestEvent;
|
pub struct PauseRequestEvent;
|
||||||
|
|
||||||
|
/// Request to solve the current deal and auto-play the winning line.
|
||||||
|
/// Fired by the pause menu's "Show solution" button; consumed by
|
||||||
|
/// `solution_playback_plugin`.
|
||||||
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
|
pub struct ShowSolutionRequestEvent;
|
||||||
|
|
||||||
/// Request to toggle the help / controls overlay. Fired by the HUD "Help"
|
/// Request to toggle the help / controls overlay. Fired by the HUD "Help"
|
||||||
/// button alongside the existing `F1` accelerator so the overlay is
|
/// button alongside the existing `F1` accelerator so the overlay is
|
||||||
/// reachable without a keyboard. Consumed by `help_plugin::toggle_help_screen`.
|
/// reachable without a keyboard. Consumed by `help_plugin::toggle_help_screen`.
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ pub mod safe_area;
|
|||||||
mod schedule_checks;
|
mod schedule_checks;
|
||||||
pub mod selection_plugin;
|
pub mod selection_plugin;
|
||||||
pub mod settings_plugin;
|
pub mod settings_plugin;
|
||||||
|
pub mod solution_playback_plugin;
|
||||||
pub mod splash_plugin;
|
pub mod splash_plugin;
|
||||||
pub mod stats_plugin;
|
pub mod stats_plugin;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
@@ -54,6 +55,8 @@ pub mod sync_plugin;
|
|||||||
pub mod sync_setup_plugin;
|
pub mod sync_setup_plugin;
|
||||||
pub mod table_plugin;
|
pub mod table_plugin;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod theme_store_plugin;
|
||||||
pub mod time_attack_plugin;
|
pub mod time_attack_plugin;
|
||||||
pub mod touch_selection_plugin;
|
pub mod touch_selection_plugin;
|
||||||
pub mod ui_focus;
|
pub mod ui_focus;
|
||||||
@@ -159,6 +162,7 @@ pub use settings_plugin::{
|
|||||||
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
|
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
|
||||||
};
|
};
|
||||||
pub use solitaire_data::SyncProvider;
|
pub use solitaire_data::SyncProvider;
|
||||||
|
pub use solution_playback_plugin::{SolutionPlayback, SolutionPlaybackPlugin, SolutionSolveTask};
|
||||||
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
|
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
|
||||||
pub use stats_plugin::{
|
pub use stats_plugin::{
|
||||||
LatestReplayPath, ReplayHistoryResource, ReplayNextButton, ReplayPrevButton,
|
LatestReplayPath, ReplayHistoryResource, ReplayNextButton, ReplayPrevButton,
|
||||||
@@ -176,6 +180,8 @@ pub use theme::{
|
|||||||
ActiveTheme, CardTheme, CardThemeLoader, ThemeEntry, ThemePlugin, ThemeRegistry,
|
ActiveTheme, CardTheme, CardThemeLoader, ThemeEntry, ThemePlugin, ThemeRegistry,
|
||||||
ThemeRegistryPlugin, set_theme,
|
ThemeRegistryPlugin, set_theme,
|
||||||
};
|
};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub use theme_store_plugin::{ThemeStorePlugin, ThemeStoreScreen};
|
||||||
pub use time_attack_plugin::{
|
pub use time_attack_plugin::{
|
||||||
TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource,
|
TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ struct PauseResumeButton;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct PauseForfeitButton;
|
struct PauseForfeitButton;
|
||||||
|
|
||||||
|
/// Marker on the "Show solution" secondary button on the pause modal.
|
||||||
|
/// A click resumes the game and fires `ShowSolutionRequestEvent`;
|
||||||
|
/// `solution_playback_plugin` takes it from there.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct PauseSolutionButton;
|
||||||
|
|
||||||
/// Marker on the forfeit-confirm modal scrim.
|
/// Marker on the forfeit-confirm modal scrim.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct ForfeitConfirmScreen;
|
pub struct ForfeitConfirmScreen;
|
||||||
@@ -107,6 +113,7 @@ impl Plugin for PausePlugin {
|
|||||||
.add_message::<PauseRequestEvent>()
|
.add_message::<PauseRequestEvent>()
|
||||||
.add_message::<ForfeitRequestEvent>()
|
.add_message::<ForfeitRequestEvent>()
|
||||||
.add_message::<ForfeitEvent>()
|
.add_message::<ForfeitEvent>()
|
||||||
|
.add_message::<crate::events::ShowSolutionRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.init_resource::<PausedResource>()
|
.init_resource::<PausedResource>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
@@ -125,6 +132,7 @@ impl Plugin for PausePlugin {
|
|||||||
handle_pause_draw_buttons,
|
handle_pause_draw_buttons,
|
||||||
handle_pause_resume_button,
|
handle_pause_resume_button,
|
||||||
handle_pause_forfeit_button,
|
handle_pause_forfeit_button,
|
||||||
|
handle_pause_solution_button,
|
||||||
handle_forfeit_request,
|
handle_forfeit_request,
|
||||||
handle_forfeit_confirm_buttons,
|
handle_forfeit_confirm_buttons,
|
||||||
handle_forfeit_keyboard,
|
handle_forfeit_keyboard,
|
||||||
@@ -304,6 +312,22 @@ fn handle_pause_resume_button(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Translates a click on the pause modal's "Show solution" button into
|
||||||
|
/// a resume (`PauseRequestEvent` — playback can't run while paused)
|
||||||
|
/// plus a `ShowSolutionRequestEvent` for `solution_playback_plugin`.
|
||||||
|
fn handle_pause_solution_button(
|
||||||
|
interaction_query: Query<&Interaction, (Changed<Interaction>, With<PauseSolutionButton>)>,
|
||||||
|
mut pause: MessageWriter<PauseRequestEvent>,
|
||||||
|
mut solution: MessageWriter<crate::events::ShowSolutionRequestEvent>,
|
||||||
|
) {
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction == Interaction::Pressed {
|
||||||
|
pause.write(PauseRequestEvent);
|
||||||
|
solution.write(crate::events::ShowSolutionRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Translates a click on the pause modal's Forfeit button into a
|
/// Translates a click on the pause modal's Forfeit button into a
|
||||||
/// `ForfeitRequestEvent` so `handle_forfeit_request` can spawn the
|
/// `ForfeitRequestEvent` so `handle_forfeit_request` can spawn the
|
||||||
/// confirm modal — same code path as the `G` accelerator.
|
/// confirm modal — same code path as the `G` accelerator.
|
||||||
@@ -498,6 +522,14 @@ fn spawn_pause_screen(
|
|||||||
ButtonVariant::Tertiary,
|
ButtonVariant::Tertiary,
|
||||||
font_res,
|
font_res,
|
||||||
);
|
);
|
||||||
|
spawn_modal_button(
|
||||||
|
actions,
|
||||||
|
PauseSolutionButton,
|
||||||
|
"Show solution",
|
||||||
|
None,
|
||||||
|
ButtonVariant::Secondary,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
spawn_modal_button(
|
spawn_modal_button(
|
||||||
actions,
|
actions,
|
||||||
PauseResumeButton,
|
PauseResumeButton,
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ impl Plugin for SafeAreaInsetsPlugin {
|
|||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
app.init_resource::<android::SafeAreaPollTries>()
|
app.init_resource::<android::SafeAreaPollTries>()
|
||||||
.add_systems(Update, android::refresh_insets)
|
.add_systems(Update, android::refresh_insets)
|
||||||
.add_systems(Update, android::rearm_on_resumed);
|
.add_systems(Update, android::rearm_on_resumed)
|
||||||
|
.add_systems(Update, android::refresh_surface_size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +226,7 @@ fn on_app_resumed(
|
|||||||
mod android {
|
mod android {
|
||||||
use super::{AppLifecycle, SafeAreaInsets};
|
use super::{AppLifecycle, SafeAreaInsets};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::WindowResized;
|
||||||
|
|
||||||
/// Tracks how many frames `refresh_insets` has polled. Stored as a
|
/// Tracks how many frames `refresh_insets` has polled. Stored as a
|
||||||
/// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
|
/// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
|
||||||
@@ -299,11 +301,108 @@ mod android {
|
|||||||
) {
|
) {
|
||||||
for event in lifecycle.read() {
|
for event in lifecycle.read() {
|
||||||
if matches!(event, AppLifecycle::WillResume) {
|
if matches!(event, AppLifecycle::WillResume) {
|
||||||
|
// Evidence line for #130: winit's Android backend has open
|
||||||
|
// TODOs around forwarding resume notifications, so whether
|
||||||
|
// this ever fires on a given device is an open question.
|
||||||
|
info!("safe_area: AppLifecycle::WillResume received; re-arming inset poll");
|
||||||
poll.0 = 0;
|
poll.0 = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Polls the decor view's size via JNI and forces a relayout when it
|
||||||
|
/// disagrees with Bevy's cached `Window` resolution (#130).
|
||||||
|
///
|
||||||
|
/// winit's Android backend does not forward content-rect changes that
|
||||||
|
/// happen while the app is backgrounded (fold/unfold on foldables), so
|
||||||
|
/// after a fold cycle Bevy can keep rendering and laying out for the
|
||||||
|
/// previous screen's dimensions. Unlike `refresh_insets` this poller
|
||||||
|
/// never settles: it cannot rely on `AppLifecycle::WillResume` to re-arm
|
||||||
|
/// it, because that event is itself delivered through the same unreliable
|
||||||
|
/// lifecycle plumbing. A JNI round-trip every `POLL_INTERVAL_FRAMES`
|
||||||
|
/// frames is cheap.
|
||||||
|
///
|
||||||
|
/// On a mismatch it:
|
||||||
|
/// 1. writes the real size into `window.resolution` so the renderer
|
||||||
|
/// reconfigures the surface and systems reading `window.width()` see
|
||||||
|
/// the truth,
|
||||||
|
/// 2. emits a synthetic `WindowResized` (logical pixels) so
|
||||||
|
/// `on_window_resized` in `table_plugin` recomputes the board layout,
|
||||||
|
/// 3. re-arms the inset poller, because a screen change almost always
|
||||||
|
/// moves the system bars too — covering the "re-poll never fires"
|
||||||
|
/// hole left open in #116.
|
||||||
|
pub(super) fn refresh_surface_size(
|
||||||
|
mut frame: Local<u32>,
|
||||||
|
mut windows: Query<(Entity, &mut Window)>,
|
||||||
|
mut resize_events: MessageWriter<WindowResized>,
|
||||||
|
mut poll: ResMut<SafeAreaPollTries>,
|
||||||
|
) {
|
||||||
|
const POLL_INTERVAL_FRAMES: u32 = 30; // ~0.5 s @ 60 fps
|
||||||
|
|
||||||
|
*frame += 1;
|
||||||
|
if !frame.is_multiple_of(POLL_INTERVAL_FRAMES) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some((entity, mut window)) = windows.iter_mut().next() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (decor_w, decor_h) = match query_decor_size() {
|
||||||
|
Ok(size) => size,
|
||||||
|
Err(e) => {
|
||||||
|
// One-time note; the bridge simply isn't up yet during the
|
||||||
|
// first frames of a launch.
|
||||||
|
if *frame == POLL_INTERVAL_FRAMES {
|
||||||
|
warn!("safe_area: decor size query failed (will retry): {e}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if decor_w == 0 || decor_h == 0 {
|
||||||
|
return; // decor view not laid out yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads go through `Deref` and do not trip change detection; only
|
||||||
|
// mutate `window` once a mismatch is confirmed.
|
||||||
|
let cached_w = window.resolution.physical_width();
|
||||||
|
let cached_h = window.resolution.physical_height();
|
||||||
|
if decor_w == cached_w && decor_h == cached_h {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"safe_area: decor view is {decor_w}x{decor_h} but cached resolution is \
|
||||||
|
{cached_w}x{cached_h}; forcing relayout (fold/unfold missed by winit?)"
|
||||||
|
);
|
||||||
|
window.resolution.set_physical_resolution(decor_w, decor_h);
|
||||||
|
let scale = window.scale_factor();
|
||||||
|
resize_events.write(WindowResized {
|
||||||
|
window: entity,
|
||||||
|
width: decor_w as f32 / scale,
|
||||||
|
height: decor_h as f32 / scale,
|
||||||
|
});
|
||||||
|
poll.0 = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Physical pixel size of the activity's decor view — the ground truth
|
||||||
|
/// for the surface we are actually being displayed on, independent of
|
||||||
|
/// whatever winit last told Bevy.
|
||||||
|
fn query_decor_size() -> Result<(u32, u32), String> {
|
||||||
|
use solitaire_data::android_jni;
|
||||||
|
|
||||||
|
android_jni::with_activity_env(|env, activity| {
|
||||||
|
let window = env
|
||||||
|
.call_method(activity, "getWindow", "()Landroid/view/Window;", &[])?
|
||||||
|
.l()?;
|
||||||
|
let decor = env
|
||||||
|
.call_method(&window, "getDecorView", "()Landroid/view/View;", &[])?
|
||||||
|
.l()?;
|
||||||
|
let w = env.call_method(&decor, "getWidth", "()I", &[])?.i()?;
|
||||||
|
let h = env.call_method(&decor, "getHeight", "()I", &[])?.i()?;
|
||||||
|
Ok((w.max(0) as u32, h.max(0) as u32))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn query_insets() -> Result<SafeAreaInsets, String> {
|
fn query_insets() -> Result<SafeAreaInsets, String> {
|
||||||
use solitaire_data::android_jni;
|
use solitaire_data::android_jni;
|
||||||
|
|
||||||
|
|||||||
@@ -407,6 +407,10 @@ pub(super) fn handle_settings_buttons(
|
|||||||
| SettingsButton::DeleteAccount => {
|
| SettingsButton::DeleteAccount => {
|
||||||
// Handled by `handle_sync_buttons`.
|
// Handled by `handle_sync_buttons`.
|
||||||
}
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
SettingsButton::OpenThemeStore => {
|
||||||
|
// Handled by `handle_sync_buttons`.
|
||||||
|
}
|
||||||
SettingsButton::Done => {
|
SettingsButton::Done => {
|
||||||
screen.0 = false;
|
screen.0 = false;
|
||||||
}
|
}
|
||||||
@@ -423,6 +427,9 @@ pub(super) fn handle_sync_buttons(
|
|||||||
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||||
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
|
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
|
||||||
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
|
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))] mut open_theme_store: MessageWriter<
|
||||||
|
crate::events::ThemeStoreOpenRequestEvent,
|
||||||
|
>,
|
||||||
mut screen: ResMut<SettingsScreen>,
|
mut screen: ResMut<SettingsScreen>,
|
||||||
) {
|
) {
|
||||||
for (interaction, button) in &interaction_query {
|
for (interaction, button) in &interaction_query {
|
||||||
@@ -439,6 +446,13 @@ pub(super) fn handle_sync_buttons(
|
|||||||
screen.0 = false;
|
screen.0 = false;
|
||||||
configure_sync.write(SyncConfigureRequestEvent);
|
configure_sync.write(SyncConfigureRequestEvent);
|
||||||
}
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
SettingsButton::OpenThemeStore => {
|
||||||
|
// Same shape as ConnectSync: close settings first so the
|
||||||
|
// store modal's own-scrim guard doesn't block.
|
||||||
|
screen.0 = false;
|
||||||
|
open_theme_store.write(crate::events::ThemeStoreOpenRequestEvent);
|
||||||
|
}
|
||||||
SettingsButton::DisconnectSync => {
|
SettingsButton::DisconnectSync => {
|
||||||
logout_sync.write(SyncLogoutRequestEvent);
|
logout_sync.write(SyncLogoutRequestEvent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,6 +250,9 @@ enum SettingsButton {
|
|||||||
/// Scan `user_theme_dir()` for new `.zip` files and import each one.
|
/// Scan `user_theme_dir()` for new `.zip` files and import each one.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
ScanThemes,
|
ScanThemes,
|
||||||
|
/// Open the in-game theme-store modal (closes Settings first).
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
OpenThemeStore,
|
||||||
SyncNow,
|
SyncNow,
|
||||||
/// Open the sync-server Connect modal (shown when backend = Local).
|
/// Open the sync-server Connect modal (shown when backend = Local).
|
||||||
ConnectSync,
|
ConnectSync,
|
||||||
@@ -313,6 +316,8 @@ impl SettingsButton {
|
|||||||
SettingsButton::SelectTheme(_) => 85,
|
SettingsButton::SelectTheme(_) => 85,
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
SettingsButton::ScanThemes => 86,
|
SettingsButton::ScanThemes => 86,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
SettingsButton::OpenThemeStore => 87,
|
||||||
// Sync section
|
// Sync section
|
||||||
SettingsButton::SyncNow => 90,
|
SettingsButton::SyncNow => 90,
|
||||||
SettingsButton::ConnectSync => 91,
|
SettingsButton::ConnectSync => 91,
|
||||||
@@ -371,6 +376,7 @@ impl Plugin for SettingsPlugin {
|
|||||||
.add_message::<SyncLogoutRequestEvent>()
|
.add_message::<SyncLogoutRequestEvent>()
|
||||||
.add_message::<DeleteAccountRequestEvent>()
|
.add_message::<DeleteAccountRequestEvent>()
|
||||||
.add_message::<ToggleSettingsRequestEvent>()
|
.add_message::<ToggleSettingsRequestEvent>()
|
||||||
|
.add_message::<crate::events::ThemeStoreOpenRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<MouseWheel>()
|
.add_message::<MouseWheel>()
|
||||||
.add_message::<TouchInput>()
|
.add_message::<TouchInput>()
|
||||||
|
|||||||
@@ -239,6 +239,8 @@ pub(super) fn spawn_settings_panel(
|
|||||||
}
|
}
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
import_themes_row(body, font_res);
|
import_themes_row(body, font_res);
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
theme_store_row(body, font_res);
|
||||||
|
|
||||||
// --- Privacy (only shown when a Matomo URL is configured) ---
|
// --- Privacy (only shown when a Matomo URL is configured) ---
|
||||||
if settings.matomo_url.is_some() {
|
if settings.matomo_url.is_some() {
|
||||||
@@ -1193,6 +1195,31 @@ pub(super) fn import_themes_row(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Theme store" row: one pill button that closes Settings and opens
|
||||||
|
/// the in-game theme-store modal (`theme_store_plugin`). Sits directly
|
||||||
|
/// under the Import row so both install paths live together.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub(super) fn theme_store_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) {
|
||||||
|
parent
|
||||||
|
.spawn((
|
||||||
|
FocusRow,
|
||||||
|
Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.with_children(|row| {
|
||||||
|
pill_button(
|
||||||
|
row,
|
||||||
|
SettingsButton::OpenThemeStore,
|
||||||
|
"Browse theme store",
|
||||||
|
"Download themes from your sync server without leaving the game.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn icon_button(
|
pub(super) fn icon_button(
|
||||||
parent: &mut ChildSpawnerCommands,
|
parent: &mut ChildSpawnerCommands,
|
||||||
label: &str,
|
label: &str,
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
//! "Show solution" — solve the current deal off-thread and auto-play
|
||||||
|
//! the winning line through the normal move pipeline.
|
||||||
|
//!
|
||||||
|
//! The pause menu's "Show solution" button fires
|
||||||
|
//! [`crate::events::ShowSolutionRequestEvent`]. This plugin snapshots
|
||||||
|
//! the live [`GameState`], runs [`GameState::winning_line`] on
|
||||||
|
//! [`AsyncComputeTaskPool`] (the solver plus `clean_solution` can take
|
||||||
|
//! seconds — §2.4 never block the main thread), then steps the returned
|
||||||
|
//! instructions on a cadence, one `MoveRequestEvent` /
|
||||||
|
//! `DrawRequestEvent` per tick — the same events player input produces,
|
||||||
|
//! so animations, scoring, undo history, and win detection all behave
|
||||||
|
//! exactly as if the player made the moves.
|
||||||
|
//!
|
||||||
|
//! Playback cancels on Esc, on pause, on undo / new-game requests, and
|
||||||
|
//! on any rejected move (which is what a player interfering mid-line
|
||||||
|
//! produces — their move diverges the state, the next scripted step
|
||||||
|
//! becomes illegal, and the rejection stops the run cleanly).
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||||
|
|
||||||
|
use solitaire_core::game_state::GameState;
|
||||||
|
use solitaire_core::{
|
||||||
|
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, KlondikeInstruction,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::events::{
|
||||||
|
DrawRequestEvent, InfoToastEvent, MoveRejectedEvent, MoveRequestEvent, NewGameRequestEvent,
|
||||||
|
ShowSolutionRequestEvent, UndoRequestEvent,
|
||||||
|
};
|
||||||
|
use crate::game_plugin::GameMutation;
|
||||||
|
use crate::pause_plugin::PausedResource;
|
||||||
|
use crate::resources::GameStateResource;
|
||||||
|
|
||||||
|
/// Seconds between scripted moves — slow enough to follow, fast enough
|
||||||
|
/// not to drag on a 100-move line.
|
||||||
|
const STEP_INTERVAL_SECS: f32 = 0.45;
|
||||||
|
|
||||||
|
/// Initial delay before the first scripted move, giving the pause modal
|
||||||
|
/// time to close and the player a beat to see what's happening.
|
||||||
|
const FIRST_STEP_DELAY_SECS: f32 = 0.9;
|
||||||
|
|
||||||
|
/// In-flight solver task plus the `move_count` snapshot used to detect
|
||||||
|
/// a stale result (player moved while the solver ran). Mirrors
|
||||||
|
/// `PendingHintTask`.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
pub struct SolutionSolveTask {
|
||||||
|
inner: Option<(u32, Task<SolveTaskOutput>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the solver task carries back to the main thread.
|
||||||
|
enum SolveTaskOutput {
|
||||||
|
Line(Vec<KlondikeInstruction>),
|
||||||
|
Unwinnable,
|
||||||
|
Inconclusive,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue of instructions currently being auto-played, or empty when no
|
||||||
|
/// playback is active. HUD/UI may read `is_active` to badge the state.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
pub struct SolutionPlayback {
|
||||||
|
queue: VecDeque<KlondikeInstruction>,
|
||||||
|
cooldown: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SolutionPlayback {
|
||||||
|
/// `true` while a solution line is being auto-played.
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
!self.queue.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(&mut self) {
|
||||||
|
self.queue.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bevy plugin for the Show-solution flow. See the module docs.
|
||||||
|
pub struct SolutionPlaybackPlugin;
|
||||||
|
|
||||||
|
impl Plugin for SolutionPlaybackPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
// add_message is idempotent — GamePlugin registers most of these
|
||||||
|
// too, but this plugin must also boot standalone under
|
||||||
|
// MinimalPlugins in tests.
|
||||||
|
app.init_resource::<SolutionSolveTask>()
|
||||||
|
.init_resource::<SolutionPlayback>()
|
||||||
|
.add_message::<ShowSolutionRequestEvent>()
|
||||||
|
.add_message::<InfoToastEvent>()
|
||||||
|
.add_message::<MoveRequestEvent>()
|
||||||
|
.add_message::<DrawRequestEvent>()
|
||||||
|
.add_message::<MoveRejectedEvent>()
|
||||||
|
.add_message::<UndoRequestEvent>()
|
||||||
|
.add_message::<NewGameRequestEvent>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
handle_show_solution_request,
|
||||||
|
poll_solution_task,
|
||||||
|
cancel_playback_on_interrupt,
|
||||||
|
drive_solution_playback,
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.before(GameMutation),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts a solver task from the live game state. A repeat request
|
||||||
|
/// while one is already in flight (or playback is running) is ignored
|
||||||
|
/// — the button is idempotent, not a queue.
|
||||||
|
fn handle_show_solution_request(
|
||||||
|
mut requests: MessageReader<ShowSolutionRequestEvent>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
mut task: ResMut<SolutionSolveTask>,
|
||||||
|
playback: Res<SolutionPlayback>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
if requests.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requests.clear();
|
||||||
|
if task.inner.is_some() || playback.is_active() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(game) = game else { return };
|
||||||
|
if game.0.is_won() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.write(InfoToastEvent("Searching for a solution…".to_string()));
|
||||||
|
let snapshot: GameState = game.0.clone();
|
||||||
|
let move_count = snapshot.move_count();
|
||||||
|
let handle = AsyncComputeTaskPool::get().spawn(async move {
|
||||||
|
match snapshot.winning_line(DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET) {
|
||||||
|
Ok(Some(line)) => SolveTaskOutput::Line(line),
|
||||||
|
Ok(None) => SolveTaskOutput::Unwinnable,
|
||||||
|
Err(_) => SolveTaskOutput::Inconclusive,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
task.inner = Some((move_count, handle));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls the solver; on completion either starts playback or explains
|
||||||
|
/// why there is nothing to play. A result computed for a position the
|
||||||
|
/// player has since moved past is discarded silently.
|
||||||
|
fn poll_solution_task(
|
||||||
|
mut task: ResMut<SolutionSolveTask>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
mut playback: ResMut<SolutionPlayback>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
let Some((move_count_at_spawn, handle)) = task.inner.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(output) = future::block_on(future::poll_once(handle)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let move_count_at_spawn = *move_count_at_spawn;
|
||||||
|
task.inner = None;
|
||||||
|
|
||||||
|
let Some(game) = game else { return };
|
||||||
|
if game.0.move_count() != move_count_at_spawn {
|
||||||
|
return; // Stale — the board moved while we were solving.
|
||||||
|
}
|
||||||
|
|
||||||
|
match output {
|
||||||
|
SolveTaskOutput::Line(line) if line.is_empty() => {}
|
||||||
|
SolveTaskOutput::Line(line) => {
|
||||||
|
toast.write(InfoToastEvent(format!(
|
||||||
|
"Solution found — playing {} moves. Press Esc to stop.",
|
||||||
|
line.len()
|
||||||
|
)));
|
||||||
|
playback.queue = line.into();
|
||||||
|
playback.cooldown = FIRST_STEP_DELAY_SECS;
|
||||||
|
}
|
||||||
|
SolveTaskOutput::Unwinnable => {
|
||||||
|
toast.write(InfoToastEvent(
|
||||||
|
"No winning line exists from this position.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
SolveTaskOutput::Inconclusive => {
|
||||||
|
toast.write(InfoToastEvent(
|
||||||
|
"Couldn't find a solution within the search budget.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops playback on Esc, pause, undo / new-game requests, or a
|
||||||
|
/// rejected move (the signature of the player diverging the board
|
||||||
|
/// mid-line). Runs before `drive_solution_playback` so a cancel takes
|
||||||
|
/// effect without one extra scripted move slipping out.
|
||||||
|
fn cancel_playback_on_interrupt(
|
||||||
|
mut playback: ResMut<SolutionPlayback>,
|
||||||
|
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
mut rejected: MessageReader<MoveRejectedEvent>,
|
||||||
|
mut undos: MessageReader<UndoRequestEvent>,
|
||||||
|
mut new_games: MessageReader<NewGameRequestEvent>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
if !playback.is_active() {
|
||||||
|
rejected.clear();
|
||||||
|
undos.clear();
|
||||||
|
new_games.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let esc = keys.is_some_and(|k| k.just_pressed(KeyCode::Escape));
|
||||||
|
let interrupted = esc
|
||||||
|
|| paused.is_some_and(|p| p.0)
|
||||||
|
|| rejected.read().next().is_some()
|
||||||
|
|| undos.read().next().is_some()
|
||||||
|
|| new_games.read().next().is_some();
|
||||||
|
if interrupted {
|
||||||
|
playback.stop();
|
||||||
|
toast.write(InfoToastEvent("Solution playback stopped.".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits the next scripted instruction every [`STEP_INTERVAL_SECS`]
|
||||||
|
/// while playback is active, translated to the same request events
|
||||||
|
/// player input produces. Instructions that no longer decode against
|
||||||
|
/// the live state stop the run instead of guessing.
|
||||||
|
fn drive_solution_playback(
|
||||||
|
mut playback: ResMut<SolutionPlayback>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
time: Res<Time>,
|
||||||
|
mut moves: MessageWriter<MoveRequestEvent>,
|
||||||
|
mut draws: MessageWriter<DrawRequestEvent>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
if !playback.is_active() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(game) = game else {
|
||||||
|
playback.stop();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if game.0.is_won() {
|
||||||
|
playback.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
playback.cooldown -= time.delta_secs();
|
||||||
|
if playback.cooldown > 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
playback.cooldown = STEP_INTERVAL_SECS;
|
||||||
|
|
||||||
|
let Some(instruction) = playback.queue.pop_front() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
match instruction {
|
||||||
|
KlondikeInstruction::RotateStock => {
|
||||||
|
draws.write(DrawRequestEvent);
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
// Decode against the LIVE state — tableau run lengths depend on
|
||||||
|
// the current face-up counts, so this must happen at step time,
|
||||||
|
// not at solve time.
|
||||||
|
let Some((from, to, count)) = game.0.instruction_to_piles(other) else {
|
||||||
|
playback.stop();
|
||||||
|
toast.write(InfoToastEvent("Solution playback stopped.".to_string()));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
moves.write(MoveRequestEvent { from, to, count });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::game_plugin::GamePlugin;
|
||||||
|
use crate::table_plugin::TablePlugin;
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
use solitaire_core::DrawStockConfig;
|
||||||
|
|
||||||
|
/// Seed proven Winnable at 5k budgets by the core
|
||||||
|
/// `budget_is_passed_through_not_clamped` test. Solved here under
|
||||||
|
/// standard rules (no take-from-foundation) to match that baseline.
|
||||||
|
const WINNABLE_SEED: u64 = 0xD1FF_0000_0000_0012;
|
||||||
|
|
||||||
|
fn winnable_state() -> GameState {
|
||||||
|
let mut game = GameState::new(WINNABLE_SEED, DrawStockConfig::DrawOne);
|
||||||
|
game.take_from_foundation = false;
|
||||||
|
game
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full pipeline: GamePlugin consumes the Move/Draw requests the
|
||||||
|
/// playback driver emits, exactly as in production.
|
||||||
|
fn headless_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(GamePlugin)
|
||||||
|
.add_plugins(TablePlugin)
|
||||||
|
.add_plugins(SolutionPlaybackPlugin);
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_solution(app: &mut App) {
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<ShowSolutionRequestEvent>>()
|
||||||
|
.write(ShowSolutionRequestEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pump updates until the solver task resolves (wall-clock bounded,
|
||||||
|
/// mirroring `winnable_solver_emits_hint_after_async_completes`).
|
||||||
|
fn pump_until_solved(app: &mut App) {
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||||
|
while app.world().resource::<SolutionSolveTask>().inner.is_some() {
|
||||||
|
app.update();
|
||||||
|
std::thread::yield_now();
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<SolutionSolveTask>().inner.is_none(),
|
||||||
|
"solver task should have completed within 30 s wall-clock",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_solves_and_arms_playback() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(GameStateResource(winnable_state()));
|
||||||
|
|
||||||
|
request_solution(&mut app);
|
||||||
|
app.update();
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<SolutionSolveTask>().inner.is_some(),
|
||||||
|
"request must spawn a solver task",
|
||||||
|
);
|
||||||
|
pump_until_solved(&mut app);
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<SolutionPlayback>().is_active(),
|
||||||
|
"a winnable position must arm playback",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn playback_reaches_win_through_normal_pipeline() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(GameStateResource(winnable_state()));
|
||||||
|
request_solution(&mut app);
|
||||||
|
app.update();
|
||||||
|
pump_until_solved(&mut app);
|
||||||
|
assert!(app.world().resource::<SolutionPlayback>().is_active());
|
||||||
|
|
||||||
|
// Force each step instead of waiting out the real cadence; a line
|
||||||
|
// is at most a few hundred instructions.
|
||||||
|
for _ in 0..600 {
|
||||||
|
app.world_mut().resource_mut::<SolutionPlayback>().cooldown = 0.0;
|
||||||
|
app.update();
|
||||||
|
if app.world().resource::<GameStateResource>().0.is_won() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<GameStateResource>().0.is_won(),
|
||||||
|
"auto-played line must drive the real game to a win",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!app.world().resource::<SolutionPlayback>().is_active(),
|
||||||
|
"playback must deactivate once the game is won",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_cancels_playback() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(GameStateResource(winnable_state()));
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<SolutionPlayback>()
|
||||||
|
.queue
|
||||||
|
.push_back(KlondikeInstruction::RotateStock);
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.press(KeyCode::Escape);
|
||||||
|
app.update();
|
||||||
|
assert!(
|
||||||
|
!app.world().resource::<SolutionPlayback>().is_active(),
|
||||||
|
"Esc must stop playback",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeat_request_is_ignored_while_active() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(GameStateResource(winnable_state()));
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<SolutionPlayback>()
|
||||||
|
.queue
|
||||||
|
.push_back(KlondikeInstruction::RotateStock);
|
||||||
|
|
||||||
|
request_solution(&mut app);
|
||||||
|
app.update();
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<SolutionSolveTask>().inner.is_none(),
|
||||||
|
"a request during active playback must not spawn a solver task",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,657 @@
|
|||||||
|
//! In-game theme store: browse the sync server's theme catalog and
|
||||||
|
//! install themes without leaving the app.
|
||||||
|
//!
|
||||||
|
//! Settings → Cosmetic → "Browse theme store" fires
|
||||||
|
//! [`crate::events::ThemeStoreOpenRequestEvent`]; this plugin opens a
|
||||||
|
//! modal listing the catalog (fetched on [`AsyncComputeTaskPool`]) and
|
||||||
|
//! handles per-theme Install buttons. An install downloads the archive
|
||||||
|
//! (SHA-256-verified by [`ThemeStoreClient`]), writes it atomically
|
||||||
|
//! into `user_theme_dir()`, and runs it through the same hardened
|
||||||
|
//! [`import_theme`] pipeline as a manually dropped zip — the store
|
||||||
|
//! never bypasses the importer's validation.
|
||||||
|
//!
|
||||||
|
//! Native-only: downloads need `reqwest`/Tokio and the importer is
|
||||||
|
//! filesystem-based; the plugin is gated out on wasm32 alongside
|
||||||
|
//! `SyncPlugin`.
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use solitaire_data::theme_store_client::{ThemeStoreClient, ThemeStoreError};
|
||||||
|
use solitaire_data::{Settings, SyncBackend};
|
||||||
|
use solitaire_sync::ThemeCatalogEntry;
|
||||||
|
|
||||||
|
use crate::assets::user_theme_dir;
|
||||||
|
use crate::events::{InfoToastEvent, ThemeStoreOpenRequestEvent, WarningToastEvent};
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::resources::TokioRuntimeResource;
|
||||||
|
use crate::settings_plugin::{SettingsPanel, SettingsResource};
|
||||||
|
use crate::theme::{ImportError, ThemeRegistry, import_theme, refresh_registry};
|
||||||
|
use crate::ui_modal::{
|
||||||
|
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
||||||
|
spawn_modal_button, spawn_modal_header,
|
||||||
|
};
|
||||||
|
use crate::ui_theme::{
|
||||||
|
BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3,
|
||||||
|
Z_MODAL_PANEL,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Errors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Everything that can go wrong between clicking Install and the theme
|
||||||
|
/// appearing in the picker.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
enum ThemeInstallError {
|
||||||
|
/// Catalog fetch or archive download failed (network, HTTP status,
|
||||||
|
/// size/checksum verification).
|
||||||
|
#[error(transparent)]
|
||||||
|
Download(#[from] ThemeStoreError),
|
||||||
|
/// The verified archive could not be written into the themes dir.
|
||||||
|
#[error("could not save archive: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
/// The downloaded archive failed importer validation.
|
||||||
|
#[error(transparent)]
|
||||||
|
Import(#[from] ImportError),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Components & resources
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Marker on the theme-store modal's scrim root.
|
||||||
|
#[derive(Component)]
|
||||||
|
pub struct ThemeStoreScreen;
|
||||||
|
|
||||||
|
/// Marker on the modal's Close button.
|
||||||
|
#[derive(Component)]
|
||||||
|
struct ThemeStoreCloseButton;
|
||||||
|
|
||||||
|
/// Per-row Install button carrying its catalog entry.
|
||||||
|
#[derive(Component)]
|
||||||
|
struct ThemeStoreInstallButton(ThemeCatalogEntry);
|
||||||
|
|
||||||
|
/// Catalog fetch lifecycle. The modal renders directly from this state
|
||||||
|
/// and is rebuilt (despawn + respawn, mirroring the leaderboard panel)
|
||||||
|
/// whenever it changes.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
enum CatalogState {
|
||||||
|
/// No fetch has run since the modal was last opened.
|
||||||
|
#[default]
|
||||||
|
Idle,
|
||||||
|
Loading,
|
||||||
|
Loaded(Vec<ThemeCatalogEntry>),
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-flight catalog fetch, polled without blocking.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct CatalogTask(Option<Task<Result<Vec<ThemeCatalogEntry>, ThemeStoreError>>>);
|
||||||
|
|
||||||
|
/// Result of one download + import: `Ok(Some(id))` on a fresh install,
|
||||||
|
/// `Ok(None)` when the theme was already installed (id collision) —
|
||||||
|
/// informational, not an error.
|
||||||
|
type InstallResult = Result<Option<String>, ThemeInstallError>;
|
||||||
|
|
||||||
|
/// In-flight download + import. `String` is the theme id, for toasts.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct InstallTask(Option<(String, Task<InstallResult>)>);
|
||||||
|
|
||||||
|
/// Server base URL captured when the modal opens. Catalog
|
||||||
|
/// `download_url`s are server-relative, so installs join them onto
|
||||||
|
/// this. `None` while the modal has never been opened.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct StoreBaseUrl(Option<String>);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Bevy plugin for the in-game theme store. See the module docs.
|
||||||
|
pub struct ThemeStorePlugin;
|
||||||
|
|
||||||
|
impl Plugin for ThemeStorePlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_resource::<CatalogState>()
|
||||||
|
.init_resource::<CatalogTask>()
|
||||||
|
.init_resource::<InstallTask>()
|
||||||
|
.init_resource::<StoreBaseUrl>()
|
||||||
|
.add_message::<ThemeStoreOpenRequestEvent>()
|
||||||
|
.add_message::<InfoToastEvent>()
|
||||||
|
.add_message::<WarningToastEvent>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
handle_open_request,
|
||||||
|
poll_catalog_task,
|
||||||
|
handle_install_buttons,
|
||||||
|
poll_install_task,
|
||||||
|
handle_close_button,
|
||||||
|
)
|
||||||
|
.chain(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Systems
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Resolves the store's server base URL from the player's sync
|
||||||
|
/// backend. The store rides the same self-hosted server as sync;
|
||||||
|
/// without one configured there is nothing to browse.
|
||||||
|
fn store_base_url(settings: &Settings) -> Option<String> {
|
||||||
|
match &settings.sync_backend {
|
||||||
|
SyncBackend::SolitaireServer { url, .. } => Some(url.clone()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the store modal and kicks off the catalog fetch.
|
||||||
|
///
|
||||||
|
/// Mirrors `open_sync_setup_modal`: the Settings button closes the
|
||||||
|
/// settings panel in the same frame it fires the event, and despawns
|
||||||
|
/// are deferred, so the settings scrim is excluded from the
|
||||||
|
/// another-modal-is-open guard.
|
||||||
|
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||||
|
fn handle_open_request(
|
||||||
|
mut events: MessageReader<ThemeStoreOpenRequestEvent>,
|
||||||
|
existing: Query<(), With<ThemeStoreScreen>>,
|
||||||
|
other_modal_scrims: Query<
|
||||||
|
(),
|
||||||
|
(
|
||||||
|
With<ModalScrim>,
|
||||||
|
Without<ThemeStoreScreen>,
|
||||||
|
Without<SettingsPanel>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
rt: Option<Res<TokioRuntimeResource>>,
|
||||||
|
mut catalog_state: ResMut<CatalogState>,
|
||||||
|
mut catalog_task: ResMut<CatalogTask>,
|
||||||
|
mut store_base: ResMut<StoreBaseUrl>,
|
||||||
|
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||||
|
mut commands: Commands,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
) {
|
||||||
|
if events.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
events.clear();
|
||||||
|
if !existing.is_empty() || !other_modal_scrims.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(base_url) = settings.as_deref().and_then(|s| store_base_url(&s.0)) else {
|
||||||
|
warning_toast.write(WarningToastEvent(
|
||||||
|
"Theme store needs a server — connect one in Settings → Sync.".to_string(),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(rt) = rt else {
|
||||||
|
warning_toast.write(WarningToastEvent(
|
||||||
|
"Theme store unavailable — no network runtime.".to_string(),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
store_base.0 = Some(base_url.clone());
|
||||||
|
*catalog_state = CatalogState::Loading;
|
||||||
|
let rt = rt.0.clone();
|
||||||
|
catalog_task.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
|
||||||
|
rt.block_on(async { ThemeStoreClient::new(base_url).fetch_catalog().await })
|
||||||
|
}));
|
||||||
|
|
||||||
|
spawn_store_modal(&mut commands, &catalog_state, None, font_res.as_deref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls the catalog fetch; on completion updates [`CatalogState`] and
|
||||||
|
/// rebuilds the modal if it is still open.
|
||||||
|
fn poll_catalog_task(
|
||||||
|
mut catalog_task: ResMut<CatalogTask>,
|
||||||
|
mut catalog_state: ResMut<CatalogState>,
|
||||||
|
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||||
|
registry: Option<Res<ThemeRegistry>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
) {
|
||||||
|
let Some(task) = catalog_task.0.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(result) = future::block_on(future::poll_once(task)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
catalog_task.0 = None;
|
||||||
|
|
||||||
|
*catalog_state = match result {
|
||||||
|
Ok(entries) => CatalogState::Loaded(entries),
|
||||||
|
Err(e) => {
|
||||||
|
warn!("theme store: catalog fetch failed: {e}");
|
||||||
|
CatalogState::Error(e.to_string())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rebuild_open_modal(
|
||||||
|
&screens,
|
||||||
|
&mut commands,
|
||||||
|
&catalog_state,
|
||||||
|
registry.as_deref(),
|
||||||
|
font_res.as_deref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts a download + import task when an Install button is pressed.
|
||||||
|
/// One install at a time; repeat clicks while busy are ignored.
|
||||||
|
fn handle_install_buttons(
|
||||||
|
interactions: Query<(&Interaction, &ThemeStoreInstallButton), Changed<Interaction>>,
|
||||||
|
rt: Option<Res<TokioRuntimeResource>>,
|
||||||
|
store_base: Res<StoreBaseUrl>,
|
||||||
|
mut install_task: ResMut<InstallTask>,
|
||||||
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
for (interaction, button) in &interactions {
|
||||||
|
if *interaction != Interaction::Pressed || install_task.0.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (Some(rt), Some(base_url)) = (rt.as_ref(), store_base.0.clone()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let entry = button.0.clone();
|
||||||
|
info_toast.write(InfoToastEvent(format!("Downloading '{}'…", entry.name)));
|
||||||
|
let rt = rt.0.clone();
|
||||||
|
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||||
|
let bytes = rt
|
||||||
|
.block_on(async { ThemeStoreClient::new(base_url).download_theme(&entry).await })?;
|
||||||
|
install_verified_archive(&entry.id, &bytes)
|
||||||
|
});
|
||||||
|
install_task.0 = Some((button.0.id.clone(), task));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads land here (already SHA-256-verified): write the archive
|
||||||
|
/// atomically into the user themes dir (`.tmp` + rename, §2.5), then
|
||||||
|
/// run the standard importer. Returns `Ok(None)` when the theme was
|
||||||
|
/// already installed.
|
||||||
|
fn install_verified_archive(id: &str, bytes: &[u8]) -> Result<Option<String>, ThemeInstallError> {
|
||||||
|
let themes_dir = user_theme_dir();
|
||||||
|
std::fs::create_dir_all(&themes_dir)?;
|
||||||
|
let final_path = themes_dir.join(format!("{id}.zip"));
|
||||||
|
let tmp_path = themes_dir.join(format!("{id}.zip.tmp"));
|
||||||
|
std::fs::write(&tmp_path, bytes)?;
|
||||||
|
std::fs::rename(&tmp_path, &final_path)?;
|
||||||
|
|
||||||
|
match import_theme(&final_path) {
|
||||||
|
Ok(theme_id) => Ok(Some(theme_id.as_str().to_string())),
|
||||||
|
Err(ImportError::IdCollision { .. }) => Ok(None),
|
||||||
|
Err(e) => {
|
||||||
|
// Don't leave a zip that fails validation lying around for
|
||||||
|
// the folder-scan flow to re-try on every scan.
|
||||||
|
let _ = std::fs::remove_file(&final_path);
|
||||||
|
Err(e.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls the install task; on completion refreshes the theme registry,
|
||||||
|
/// toasts the outcome, and rebuilds the modal so the row flips to
|
||||||
|
/// "Installed".
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn poll_install_task(
|
||||||
|
mut install_task: ResMut<InstallTask>,
|
||||||
|
mut registry: Option<ResMut<ThemeRegistry>>,
|
||||||
|
catalog_state: Res<CatalogState>,
|
||||||
|
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||||
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||||
|
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||||
|
mut commands: Commands,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
) {
|
||||||
|
let Some((id, task)) = install_task.0.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(result) = future::block_on(future::poll_once(task)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let id = id.clone();
|
||||||
|
install_task.0 = None;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(Some(theme_id)) => {
|
||||||
|
if let Some(registry) = registry.as_deref_mut() {
|
||||||
|
refresh_registry(registry, &user_theme_dir());
|
||||||
|
}
|
||||||
|
info_toast.write(InfoToastEvent(format!(
|
||||||
|
"Theme '{theme_id}' installed — pick it in Settings → Cosmetic."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
info_toast.write(InfoToastEvent(format!("Theme '{id}' already installed.")));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("theme store: install of '{id}' failed: {e}");
|
||||||
|
warning_toast.write(WarningToastEvent(format!("Install failed: {e}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuild_open_modal(
|
||||||
|
&screens,
|
||||||
|
&mut commands,
|
||||||
|
&catalog_state,
|
||||||
|
registry.as_deref(),
|
||||||
|
font_res.as_deref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Despawns the store modal when Close is pressed.
|
||||||
|
fn handle_close_button(
|
||||||
|
interactions: Query<&Interaction, (Changed<Interaction>, With<ThemeStoreCloseButton>)>,
|
||||||
|
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
for interaction in &interactions {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for entity in &screens {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Modal rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Despawn + respawn the modal for every open screen (Bevy despawns
|
||||||
|
/// are deferred, so this is safe within one frame).
|
||||||
|
fn rebuild_open_modal(
|
||||||
|
screens: &Query<Entity, With<ThemeStoreScreen>>,
|
||||||
|
commands: &mut Commands,
|
||||||
|
catalog_state: &CatalogState,
|
||||||
|
registry: Option<&ThemeRegistry>,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
for entity in screens {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
spawn_store_modal(commands, catalog_state, registry, font_res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the store modal for the current catalog state.
|
||||||
|
fn spawn_store_modal(
|
||||||
|
commands: &mut Commands,
|
||||||
|
catalog_state: &CatalogState,
|
||||||
|
registry: Option<&ThemeRegistry>,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
let body_font = TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: TYPE_BODY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let caption_font = TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let scrim = spawn_modal(commands, ThemeStoreScreen, Z_MODAL_PANEL + 1, |card| {
|
||||||
|
spawn_modal_header(card, "Theme Store", font_res);
|
||||||
|
|
||||||
|
match catalog_state {
|
||||||
|
CatalogState::Idle | CatalogState::Loading => {
|
||||||
|
card.spawn((
|
||||||
|
Text::new("Loading catalog…"),
|
||||||
|
body_font.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
CatalogState::Error(msg) => {
|
||||||
|
card.spawn((
|
||||||
|
Text::new(format!("Could not load the catalog: {msg}")),
|
||||||
|
body_font.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
CatalogState::Loaded(entries) if entries.is_empty() => {
|
||||||
|
card.spawn((
|
||||||
|
Text::new("The server has no themes yet."),
|
||||||
|
body_font.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
CatalogState::Loaded(entries) => {
|
||||||
|
for entry in entries {
|
||||||
|
let installed =
|
||||||
|
registry.is_some_and(|registry| registry.find(&entry.id).is_some());
|
||||||
|
spawn_store_row(card, entry, installed, &body_font, &caption_font, font_res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn_modal_actions(card, |actions| {
|
||||||
|
spawn_modal_button(
|
||||||
|
actions,
|
||||||
|
ThemeStoreCloseButton,
|
||||||
|
"Close",
|
||||||
|
None,
|
||||||
|
ButtonVariant::Primary,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
commands.entity(scrim).insert(ScrimDismissible);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One catalog row: name + author/size caption on the left, Install
|
||||||
|
/// button (or "Installed" caption) on the right.
|
||||||
|
fn spawn_store_row(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
entry: &ThemeCatalogEntry,
|
||||||
|
installed: bool,
|
||||||
|
body_font: &TextFont,
|
||||||
|
caption_font: &TextFont,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
parent
|
||||||
|
.spawn((
|
||||||
|
Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
justify_content: JustifyContent::SpaceBetween,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_3,
|
||||||
|
padding: UiRect::vertical(VAL_SPACE_2),
|
||||||
|
border: UiRect::bottom(Val::Px(1.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
|
))
|
||||||
|
.with_children(|row| {
|
||||||
|
row.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|col| {
|
||||||
|
col.spawn((
|
||||||
|
Text::new(entry.name.clone()),
|
||||||
|
body_font.clone(),
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
col.spawn((
|
||||||
|
Text::new(format!(
|
||||||
|
"by {} — {} KB",
|
||||||
|
entry.author,
|
||||||
|
entry.size_bytes.div_ceil(1024)
|
||||||
|
)),
|
||||||
|
caption_font.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
if installed {
|
||||||
|
row.spawn((
|
||||||
|
Text::new("Installed"),
|
||||||
|
caption_font.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
spawn_modal_button(
|
||||||
|
row,
|
||||||
|
ThemeStoreInstallButton(entry.clone()),
|
||||||
|
"Install",
|
||||||
|
None,
|
||||||
|
ButtonVariant::Secondary,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
|
||||||
|
fn headless_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(ThemeStorePlugin);
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings_with_server() -> SettingsResource {
|
||||||
|
SettingsResource(Settings {
|
||||||
|
sync_backend: SyncBackend::SolitaireServer {
|
||||||
|
url: "http://127.0.0.1:1".to_string(),
|
||||||
|
username: "tester".to_string(),
|
||||||
|
avatar_url: None,
|
||||||
|
},
|
||||||
|
..Settings::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn screen_count(app: &mut App) -> usize {
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&ThemeStoreScreen>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_base_url_requires_server_backend() {
|
||||||
|
assert_eq!(store_base_url(&Settings::default()), None);
|
||||||
|
assert_eq!(
|
||||||
|
store_base_url(&settings_with_server().0).as_deref(),
|
||||||
|
Some("http://127.0.0.1:1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_request_without_server_warns_and_spawns_nothing() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
// Default settings → SyncBackend::Local → no store.
|
||||||
|
app.insert_resource(SettingsResource(Settings::default()));
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
||||||
|
.write(ThemeStoreOpenRequestEvent);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(screen_count(&mut app), 0);
|
||||||
|
assert!(
|
||||||
|
!app.world()
|
||||||
|
.resource::<Messages<WarningToastEvent>>()
|
||||||
|
.is_empty(),
|
||||||
|
"player must be told why the store did not open"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_request_without_runtime_warns_and_spawns_nothing() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(settings_with_server());
|
||||||
|
// No TokioRuntimeResource inserted.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
||||||
|
.write(ThemeStoreOpenRequestEvent);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(screen_count(&mut app), 0);
|
||||||
|
assert!(
|
||||||
|
!app.world()
|
||||||
|
.resource::<Messages<WarningToastEvent>>()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_request_with_server_spawns_modal_in_loading_state() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(settings_with_server());
|
||||||
|
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
||||||
|
.write(ThemeStoreOpenRequestEvent);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(screen_count(&mut app), 1);
|
||||||
|
assert!(matches!(
|
||||||
|
*app.world().resource::<CatalogState>(),
|
||||||
|
CatalogState::Loading | CatalogState::Error(_)
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
app.world().resource::<StoreBaseUrl>().0.as_deref(),
|
||||||
|
Some("http://127.0.0.1:1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn second_open_request_does_not_stack_a_second_modal() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(settings_with_server());
|
||||||
|
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
|
||||||
|
for _ in 0..2 {
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
||||||
|
.write(ThemeStoreOpenRequestEvent);
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
assert_eq!(screen_count(&mut app), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_catalog_fetch_rebuilds_modal_with_error_state() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(settings_with_server());
|
||||||
|
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
||||||
|
.write(ThemeStoreOpenRequestEvent);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// 127.0.0.1:1 refuses connections; the fetch task must resolve
|
||||||
|
// to an error within a bounded number of frames and the modal
|
||||||
|
// must survive the rebuild.
|
||||||
|
for _ in 0..200 {
|
||||||
|
app.update();
|
||||||
|
if matches!(
|
||||||
|
*app.world().resource::<CatalogState>(),
|
||||||
|
CatalogState::Error(_)
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
*app.world().resource::<CatalogState>(),
|
||||||
|
CatalogState::Error(_)
|
||||||
|
));
|
||||||
|
assert_eq!(screen_count(&mut app), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,9 +29,15 @@ tower-http = { version = "0.6", features = ["fs"] }
|
|||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
dotenvy = { workspace = true }
|
dotenvy = { workspace = true }
|
||||||
|
# Theme store: scans a directory of theme .zip archives at startup,
|
||||||
|
# reading each archive's theme.ron manifest and hashing the bytes.
|
||||||
|
ron = { workspace = true }
|
||||||
|
zip = { workspace = true }
|
||||||
|
sha2 = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tower = { version = "0.5", features = ["util"] }
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
tempfile = { workspace = true }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod leaderboard;
|
|||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
pub mod replays;
|
pub mod replays;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
|
pub mod theme_store;
|
||||||
|
|
||||||
pub use auth::reset_password;
|
pub use auth::reset_password;
|
||||||
|
|
||||||
@@ -86,6 +87,10 @@ pub struct AppState {
|
|||||||
pub pool: SqlitePool,
|
pub pool: SqlitePool,
|
||||||
/// HS256 signing secret for JWT access and refresh tokens.
|
/// HS256 signing secret for JWT access and refresh tokens.
|
||||||
pub jwt_secret: String,
|
pub jwt_secret: String,
|
||||||
|
/// Theme-store catalog scanned once at startup.
|
||||||
|
/// [`theme_store::ThemeStore::empty`] when the server runs without
|
||||||
|
/// a store directory.
|
||||||
|
pub theme_store: Arc<theme_store::ThemeStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Construct the full Axum [`Router`].
|
/// Construct the full Axum [`Router`].
|
||||||
@@ -125,9 +130,20 @@ pub async fn build_test_pool() -> SqlitePool {
|
|||||||
/// integration tests do not need to set `JWT_SECRET` in the environment.
|
/// integration tests do not need to set `JWT_SECRET` in the environment.
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub fn build_test_router(pool: SqlitePool) -> Router {
|
pub fn build_test_router(pool: SqlitePool) -> Router {
|
||||||
|
build_test_router_with_theme_store(pool, theme_store::ThemeStore::empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`build_test_router`] with a caller-supplied theme store, for
|
||||||
|
/// integration tests exercising the theme endpoints.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn build_test_router_with_theme_store(
|
||||||
|
pool: SqlitePool,
|
||||||
|
theme_store: theme_store::ThemeStore,
|
||||||
|
) -> Router {
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
pool,
|
pool,
|
||||||
jwt_secret: "test_secret_32_chars_minimum_ok!".to_string(),
|
jwt_secret: "test_secret_32_chars_minimum_ok!".to_string(),
|
||||||
|
theme_store: Arc::new(theme_store),
|
||||||
};
|
};
|
||||||
build_router_inner(state, false)
|
build_router_inner(state, false)
|
||||||
}
|
}
|
||||||
@@ -195,6 +211,9 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
|||||||
.route("/api/daily-challenge", get(challenge::daily_challenge))
|
.route("/api/daily-challenge", get(challenge::daily_challenge))
|
||||||
.route("/api/replays/recent", get(replays::recent))
|
.route("/api/replays/recent", get(replays::recent))
|
||||||
.route("/api/replays/{id}", get(replays::get_by_id))
|
.route("/api/replays/{id}", get(replays::get_by_id))
|
||||||
|
.route("/api/themes", get(theme_store::list))
|
||||||
|
.route("/api/themes/{id}/download", get(theme_store::download))
|
||||||
|
.route("/api/themes/{id}/preview", get(theme_store::preview))
|
||||||
.route("/health", get(health))
|
.route("/health", get(health))
|
||||||
.nest_service("/avatars", ServeDir::new("avatars"));
|
.nest_service("/avatars", ServeDir::new("avatars"));
|
||||||
|
|
||||||
|
|||||||
@@ -31,12 +31,13 @@
|
|||||||
//! echo "new_password" | ./solitaire_server --reset-password alice
|
//! echo "new_password" | ./solitaire_server --reset-password alice
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use solitaire_server::{AppState, build_router};
|
use solitaire_server::{AppState, build_router, theme_store};
|
||||||
use sqlx::{SqlitePool, sqlite::SqliteConnectOptions};
|
use sqlx::{SqlitePool, sqlite::SqliteConnectOptions};
|
||||||
use std::{
|
use std::{
|
||||||
io::{self, BufRead},
|
io::{self, BufRead},
|
||||||
net::SocketAddr,
|
net::SocketAddr,
|
||||||
str::FromStr,
|
str::FromStr,
|
||||||
|
sync::Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
const JWT_SECRET_MIN_BYTES: usize = 32;
|
const JWT_SECRET_MIN_BYTES: usize = 32;
|
||||||
@@ -142,7 +143,19 @@ async fn run_server() {
|
|||||||
|
|
||||||
tracing::info!("database ready at {db_url}");
|
tracing::info!("database ready at {db_url}");
|
||||||
|
|
||||||
let state = AppState { pool, jwt_secret };
|
// Theme store: optional; a missing directory just means an empty
|
||||||
|
// catalog. Scanned once — add themes, then restart to publish.
|
||||||
|
let theme_store_dir = std::env::var(theme_store::THEME_STORE_DIR_ENV)
|
||||||
|
.unwrap_or_else(|_| theme_store::DEFAULT_THEME_STORE_DIR.into());
|
||||||
|
let theme_store = Arc::new(theme_store::ThemeStore::scan(std::path::Path::new(
|
||||||
|
&theme_store_dir,
|
||||||
|
)));
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
pool,
|
||||||
|
jwt_secret,
|
||||||
|
theme_store,
|
||||||
|
};
|
||||||
let app = build_router(state);
|
let app = build_router(state);
|
||||||
|
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
//! Theme-store catalog and file-serving endpoints.
|
||||||
|
//!
|
||||||
|
//! The store is filesystem-driven: the operator drops theme `.zip`
|
||||||
|
//! archives (the same format the engine's theme importer consumes)
|
||||||
|
//! into the directory named by the `THEME_STORE_DIR` environment
|
||||||
|
//! variable (default `theme_store/`), plus an optional `<id>.png`
|
||||||
|
//! preview per theme. [`ThemeStore::scan`] runs once at startup —
|
||||||
|
//! adding a theme means dropping the files and restarting the server.
|
||||||
|
//!
|
||||||
|
//! Endpoints (all public, no auth — the catalog is free):
|
||||||
|
//!
|
||||||
|
//! - `GET /api/themes` — [`ThemeCatalogResponse`] JSON
|
||||||
|
//! - `GET /api/themes/{id}/download` — the theme `.zip`
|
||||||
|
//! - `GET /api/themes/{id}/preview` — the preview PNG (404 when absent)
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::{Cursor, Read};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path as UrlPath, State};
|
||||||
|
use axum::http::header;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use solitaire_sync::{ThemeCatalogEntry, ThemeCatalogResponse};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
/// Environment variable naming the theme-store directory.
|
||||||
|
pub const THEME_STORE_DIR_ENV: &str = "THEME_STORE_DIR";
|
||||||
|
|
||||||
|
/// Default theme-store directory, relative to the server working dir —
|
||||||
|
/// same convention as the `avatars/` upload directory.
|
||||||
|
pub const DEFAULT_THEME_STORE_DIR: &str = "theme_store";
|
||||||
|
|
||||||
|
/// Archives larger than this are skipped at scan time with a warning.
|
||||||
|
/// Mirrors the engine importer's `MAX_ARCHIVE_BYTES` (20 MiB) — a zip
|
||||||
|
/// whose *compressed* size already exceeds the client's uncompressed
|
||||||
|
/// limit can never import, so serving it only wastes bandwidth.
|
||||||
|
pub const MAX_STORE_ARCHIVE_BYTES: u64 = 20 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Upper bound on the bytes read out of an archive's `theme.ron`
|
||||||
|
/// entry at scan time, guarding the catalog scan against a
|
||||||
|
/// zip-bombed manifest. Real manifests are a few KB.
|
||||||
|
const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;
|
||||||
|
|
||||||
|
/// `theme.ron`'s outer shape, `meta` block only — the 53 face entries
|
||||||
|
/// are irrelevant to the catalog and are validated client-side by the
|
||||||
|
/// importer at install time.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ManifestMetaOnly {
|
||||||
|
meta: StoreThemeMeta,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirror of the `meta` block of a theme manifest. The canonical
|
||||||
|
/// definition is `solitaire_engine::theme::ThemeMeta`; the server
|
||||||
|
/// cannot depend on the engine crate (it links Bevy), so the shape is
|
||||||
|
/// re-declared here. Keep the two in lockstep.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct StoreThemeMeta {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
author: String,
|
||||||
|
version: String,
|
||||||
|
card_aspect: (u32, u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory catalog built by scanning the theme-store directory once
|
||||||
|
/// at startup. Shared via [`AppState`].
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct ThemeStore {
|
||||||
|
entries: Vec<ThemeCatalogEntry>,
|
||||||
|
zip_paths: HashMap<String, PathBuf>,
|
||||||
|
preview_paths: HashMap<String, PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThemeStore {
|
||||||
|
/// An empty store — used by tests and when the store directory is
|
||||||
|
/// absent (the server runs fine without a theme store).
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scans `dir` for `*.zip` theme archives and builds the catalog.
|
||||||
|
///
|
||||||
|
/// Best-effort per archive: unreadable files, oversized archives,
|
||||||
|
/// missing/malformed manifests, and duplicate ids are skipped with
|
||||||
|
/// a warning rather than failing startup — one bad upload must not
|
||||||
|
/// take the store (or the server) down.
|
||||||
|
pub fn scan(dir: &Path) -> Self {
|
||||||
|
let mut store = Self::default();
|
||||||
|
let read_dir = match std::fs::read_dir(dir) {
|
||||||
|
Ok(read_dir) => read_dir,
|
||||||
|
Err(e) => {
|
||||||
|
info!(
|
||||||
|
"theme store: directory {} not readable ({e}); serving an empty catalog",
|
||||||
|
dir.display()
|
||||||
|
);
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut zips: Vec<PathBuf> = read_dir
|
||||||
|
.flatten()
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.filter(|path| path.extension().is_some_and(|ext| ext == "zip"))
|
||||||
|
.collect();
|
||||||
|
// Deterministic catalog regardless of directory iteration order.
|
||||||
|
zips.sort();
|
||||||
|
|
||||||
|
for zip_path in zips {
|
||||||
|
match scan_archive(&zip_path) {
|
||||||
|
Ok((meta, size_bytes, sha256)) => {
|
||||||
|
if store.zip_paths.contains_key(&meta.id) {
|
||||||
|
warn!(
|
||||||
|
"theme store: skipping {} — duplicate theme id {:?}",
|
||||||
|
zip_path.display(),
|
||||||
|
meta.id
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let preview_path = dir.join(format!("{}.png", meta.id));
|
||||||
|
let preview_url = if preview_path.is_file() {
|
||||||
|
store.preview_paths.insert(meta.id.clone(), preview_path);
|
||||||
|
Some(format!("/api/themes/{}/preview", meta.id))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
store.entries.push(ThemeCatalogEntry {
|
||||||
|
download_url: format!("/api/themes/{}/download", meta.id),
|
||||||
|
preview_url,
|
||||||
|
id: meta.id.clone(),
|
||||||
|
name: meta.name,
|
||||||
|
author: meta.author,
|
||||||
|
version: meta.version,
|
||||||
|
card_aspect: meta.card_aspect,
|
||||||
|
size_bytes,
|
||||||
|
sha256,
|
||||||
|
});
|
||||||
|
store.zip_paths.insert(meta.id, zip_path);
|
||||||
|
}
|
||||||
|
Err(reason) => {
|
||||||
|
warn!("theme store: skipping {} — {reason}", zip_path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
info!("theme store: serving {} theme(s)", store.entries.len());
|
||||||
|
store
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Catalog entries, sorted by display name.
|
||||||
|
pub fn entries(&self) -> &[ThemeCatalogEntry] {
|
||||||
|
&self.entries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads one archive: enforces the size cap, hashes the bytes, and
|
||||||
|
/// extracts + validates the manifest's `meta` block.
|
||||||
|
fn scan_archive(zip_path: &Path) -> Result<(StoreThemeMeta, u64, String), String> {
|
||||||
|
let size_bytes = std::fs::metadata(zip_path)
|
||||||
|
.map_err(|e| format!("cannot stat: {e}"))?
|
||||||
|
.len();
|
||||||
|
if size_bytes > MAX_STORE_ARCHIVE_BYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"{size_bytes} bytes exceeds the {MAX_STORE_ARCHIVE_BYTES}-byte store limit"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = std::fs::read(zip_path).map_err(|e| format!("cannot read: {e}"))?;
|
||||||
|
let sha256 = hex_digest(&bytes);
|
||||||
|
|
||||||
|
let mut archive =
|
||||||
|
zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip archive: {e}"))?;
|
||||||
|
let manifest_bytes = {
|
||||||
|
let entry = archive
|
||||||
|
.by_name("theme.ron")
|
||||||
|
.map_err(|e| format!("no theme.ron in archive: {e}"))?;
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
entry
|
||||||
|
.take(MAX_MANIFEST_BYTES)
|
||||||
|
.read_to_end(&mut buf)
|
||||||
|
.map_err(|e| format!("cannot read theme.ron: {e}"))?;
|
||||||
|
buf
|
||||||
|
};
|
||||||
|
|
||||||
|
let parsed: ManifestMetaOnly =
|
||||||
|
ron::de::from_bytes(&manifest_bytes).map_err(|e| format!("malformed theme.ron: {e}"))?;
|
||||||
|
let meta = parsed.meta;
|
||||||
|
if meta.id.is_empty() {
|
||||||
|
return Err("theme id is empty".to_string());
|
||||||
|
}
|
||||||
|
if meta.id.contains('/') || meta.id.contains('\\') || meta.id.contains("..") {
|
||||||
|
return Err(format!("theme id {:?} is not filesystem-safe", meta.id));
|
||||||
|
}
|
||||||
|
if meta.card_aspect.0 == 0 || meta.card_aspect.1 == 0 {
|
||||||
|
return Err("card_aspect contains a zero".to_string());
|
||||||
|
}
|
||||||
|
Ok((meta, size_bytes, sha256))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lowercase-hex SHA-256 of `bytes`.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/themes` — the full catalog.
|
||||||
|
pub async fn list(State(state): State<AppState>) -> Json<ThemeCatalogResponse> {
|
||||||
|
Json(ThemeCatalogResponse {
|
||||||
|
themes: state.theme_store.entries().to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/themes/{id}/download` — the theme archive bytes.
|
||||||
|
pub async fn download(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
UrlPath(id): UrlPath<String>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let path = state
|
||||||
|
.theme_store
|
||||||
|
.zip_paths
|
||||||
|
.get(&id)
|
||||||
|
.ok_or_else(|| AppError::NotFound("theme not found".into()))?;
|
||||||
|
let bytes = tokio::fs::read(path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("theme archive unreadable: {e}")))?;
|
||||||
|
Ok((
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "application/zip".to_string()),
|
||||||
|
(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
format!("attachment; filename=\"{id}.zip\""),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
bytes,
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/themes/{id}/preview` — the preview PNG, when the store
|
||||||
|
/// ships one for this theme.
|
||||||
|
pub async fn preview(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
UrlPath(id): UrlPath<String>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let path = state
|
||||||
|
.theme_store
|
||||||
|
.preview_paths
|
||||||
|
.get(&id)
|
||||||
|
.ok_or_else(|| AppError::NotFound("theme preview not found".into()))?;
|
||||||
|
let bytes = tokio::fs::read(path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("theme preview unreadable: {e}")))?;
|
||||||
|
Ok(([(header::CONTENT_TYPE, "image/png".to_string())], bytes).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
/// Minimal valid manifest for id/name pairs — the scan only reads
|
||||||
|
/// the `meta` block, so no face entries are needed.
|
||||||
|
fn manifest(id: &str, name: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"(
|
||||||
|
meta: (
|
||||||
|
id: "{id}",
|
||||||
|
name: "{name}",
|
||||||
|
author: "Test Author",
|
||||||
|
version: "1.0.0",
|
||||||
|
card_aspect: (2, 3),
|
||||||
|
),
|
||||||
|
faces: {{}},
|
||||||
|
back: "back.svg",
|
||||||
|
)"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_zip(dir: &Path, file_name: &str, manifest: &str) -> PathBuf {
|
||||||
|
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");
|
||||||
|
writer.write_all(manifest.as_bytes()).expect("write");
|
||||||
|
writer.finish().expect("finish");
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scan_of_missing_dir_yields_empty_catalog() {
|
||||||
|
let store = ThemeStore::scan(Path::new("/nonexistent/theme/store"));
|
||||||
|
assert!(store.entries().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scan_builds_sorted_catalog_with_hashes() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let zebra = write_zip(dir.path(), "b.zip", &manifest("zebra", "Zebra"));
|
||||||
|
write_zip(dir.path(), "a.zip", &manifest("aurora", "Aurora"));
|
||||||
|
|
||||||
|
let store = ThemeStore::scan(dir.path());
|
||||||
|
let entries = store.entries();
|
||||||
|
assert_eq!(entries.len(), 2);
|
||||||
|
// Sorted by display name, not filename.
|
||||||
|
assert_eq!(entries[0].id, "aurora");
|
||||||
|
assert_eq!(entries[1].id, "zebra");
|
||||||
|
assert_eq!(entries[1].download_url, "/api/themes/zebra/download");
|
||||||
|
assert_eq!(entries[1].preview_url, None);
|
||||||
|
|
||||||
|
let zebra_bytes = std::fs::read(&zebra).expect("read zip");
|
||||||
|
assert_eq!(entries[1].sha256, hex_digest(&zebra_bytes));
|
||||||
|
assert_eq!(entries[1].size_bytes, zebra_bytes.len() as u64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scan_picks_up_preview_png_keyed_by_theme_id() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
write_zip(dir.path(), "neon.zip", &manifest("neon", "Neon"));
|
||||||
|
std::fs::write(dir.path().join("neon.png"), b"png-bytes").expect("write png");
|
||||||
|
|
||||||
|
let store = ThemeStore::scan(dir.path());
|
||||||
|
assert_eq!(
|
||||||
|
store.entries()[0].preview_url.as_deref(),
|
||||||
|
Some("/api/themes/neon/preview")
|
||||||
|
);
|
||||||
|
assert!(store.preview_paths.contains_key("neon"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scan_skips_duplicate_ids_and_keeps_first() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
write_zip(dir.path(), "1_first.zip", &manifest("dupe", "First"));
|
||||||
|
write_zip(dir.path(), "2_second.zip", &manifest("dupe", "Second"));
|
||||||
|
|
||||||
|
let store = ThemeStore::scan(dir.path());
|
||||||
|
assert_eq!(store.entries().len(), 1);
|
||||||
|
assert_eq!(store.entries()[0].name, "First");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scan_skips_malformed_and_unsafe_manifests() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
// Not a zip at all.
|
||||||
|
std::fs::write(dir.path().join("junk.zip"), b"not a zip").expect("write");
|
||||||
|
// Path-separator id must be rejected — it would become a URL
|
||||||
|
// path segment and a client install directory name.
|
||||||
|
write_zip(dir.path(), "evil.zip", &manifest("../evil", "Evil"));
|
||||||
|
// Valid one to prove the scan carries on past failures.
|
||||||
|
write_zip(dir.path(), "ok.zip", &manifest("ok", "Ok"));
|
||||||
|
|
||||||
|
let store = ThemeStore::scan(dir.path());
|
||||||
|
assert_eq!(store.entries().len(), 1);
|
||||||
|
assert_eq!(store.entries()[0].id, "ok");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1614,6 +1614,7 @@ async fn auth_rate_limit_returns_429_on_11th_request() {
|
|||||||
let state = solitaire_server::AppState {
|
let state = solitaire_server::AppState {
|
||||||
pool: test_pool().await,
|
pool: test_pool().await,
|
||||||
jwt_secret: TEST_SECRET.to_string(),
|
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);
|
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 {
|
let state = solitaire_server::AppState {
|
||||||
pool: test_pool().await,
|
pool: test_pool().await,
|
||||||
jwt_secret: TEST_SECRET.to_string(),
|
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);
|
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;
|
let resp = post_authed(app, "/api/replays", &token, bad).await;
|
||||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1649,62 +1649,62 @@ function __wbg_get_imports() {
|
|||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 61868, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 61918, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd94d76233321402f);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd94d76233321402f);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7314, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7364, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfc779804ccb0943e);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfc779804ccb0943e);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_3);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_3);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
|
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
|
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7311, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7361, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
|
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7312, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7362, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
|
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
|
||||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7313, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7363, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c);
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -10,5 +10,8 @@ uuid = { workspace = true }
|
|||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ pub mod achievements;
|
|||||||
pub mod merge;
|
pub mod merge;
|
||||||
pub mod progress;
|
pub mod progress;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
|
pub mod theme_store;
|
||||||
|
|
||||||
pub use achievements::AchievementRecord;
|
pub use achievements::AchievementRecord;
|
||||||
pub use merge::{merge, merge_at};
|
pub use merge::{merge, merge_at};
|
||||||
pub use progress::{PlayerProgress, level_for_xp};
|
pub use progress::{PlayerProgress, level_for_xp};
|
||||||
pub use stats::StatsSnapshot;
|
pub use stats::StatsSnapshot;
|
||||||
|
pub use theme_store::{ThemeCatalogEntry, ThemeCatalogResponse};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
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