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

Phase 1+2 of the theme-store roadmap: a free catalog served by
solitaire_server and an in-app browse/install flow, making custom
themes installable on Android for the first time (the manual
drop-a-zip flow can't reach the app-private themes dir there).

- solitaire_sync: ThemeCatalogEntry/ThemeCatalogResponse wire types
  (additive module; SyncPayload and SyncProvider untouched)
- solitaire_server: THEME_STORE_DIR scan at startup (meta-only
  theme.ron parse, sha256, 20 MiB cap, best-effort per archive);
  public GET /api/themes, /api/themes/{id}/download, /{id}/preview;
  compose volume + README_SERVER docs
- solitaire_data: ThemeStoreClient — catalog fetch + download with
  mandatory size/sha256 verification before bytes are released
- solitaire_engine: ThemeStorePlugin — 'Browse theme store' button in
  Settings → Cosmetic, modal catalog (leaderboard-style rebuild),
  download on AsyncComputeTaskPool, atomic .tmp+rename write into
  user_theme_dir, then the existing hardened import_theme pipeline and
  an in-place registry refresh

New deps: sha2 (workspace, server+data); ron/zip reused in server;
serde_json added to solitaire_sync dev-deps for DTO round-trip tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-07 13:12:41 -07:00
parent 018b69285d
commit d87397b382
23 changed files with 1718 additions and 3 deletions
@@ -407,6 +407,10 @@ pub(super) fn handle_settings_buttons(
| SettingsButton::DeleteAccount => {
// Handled by `handle_sync_buttons`.
}
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::OpenThemeStore => {
// Handled by `handle_sync_buttons`.
}
SettingsButton::Done => {
screen.0 = false;
}
@@ -423,6 +427,9 @@ pub(super) fn handle_sync_buttons(
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
#[cfg(not(target_arch = "wasm32"))] mut open_theme_store: MessageWriter<
crate::events::ThemeStoreOpenRequestEvent,
>,
mut screen: ResMut<SettingsScreen>,
) {
for (interaction, button) in &interaction_query {
@@ -439,6 +446,13 @@ pub(super) fn handle_sync_buttons(
screen.0 = false;
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 => {
logout_sync.write(SyncLogoutRequestEvent);
}
@@ -250,6 +250,9 @@ enum SettingsButton {
/// Scan `user_theme_dir()` for new `.zip` files and import each one.
#[cfg(not(target_arch = "wasm32"))]
ScanThemes,
/// Open the in-game theme-store modal (closes Settings first).
#[cfg(not(target_arch = "wasm32"))]
OpenThemeStore,
SyncNow,
/// Open the sync-server Connect modal (shown when backend = Local).
ConnectSync,
@@ -313,6 +316,8 @@ impl SettingsButton {
SettingsButton::SelectTheme(_) => 85,
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::ScanThemes => 86,
#[cfg(not(target_arch = "wasm32"))]
SettingsButton::OpenThemeStore => 87,
// Sync section
SettingsButton::SyncNow => 90,
SettingsButton::ConnectSync => 91,
@@ -371,6 +376,7 @@ impl Plugin for SettingsPlugin {
.add_message::<SyncLogoutRequestEvent>()
.add_message::<DeleteAccountRequestEvent>()
.add_message::<ToggleSettingsRequestEvent>()
.add_message::<crate::events::ThemeStoreOpenRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<MouseWheel>()
.add_message::<TouchInput>()
@@ -239,6 +239,8 @@ pub(super) fn spawn_settings_panel(
}
#[cfg(not(target_arch = "wasm32"))]
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) ---
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(
parent: &mut ChildSpawnerCommands,
label: &str,