//! 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 std::collections::HashMap; use bevy::asset::RenderAssetUsages; 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_empty_state, 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), Error(String), } /// In-flight catalog fetch, polled without blocking. #[derive(Resource, Default)] struct CatalogTask(Option, 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, ThemeInstallError>; /// In-flight download + import. `String` is the theme id, for toasts. #[derive(Resource, Default)] struct InstallTask(Option<(String, Task)>); /// 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); /// Decoded preview thumbnails by theme id (Phase H). Session-lifetime: /// previews survive closing and reopening the store, so a revisit /// renders instantly without refetching. #[derive(Resource, Default)] struct PreviewCache(HashMap>); /// One in-flight preview download: raw PNG bytes or the fetch error. type PreviewFetch = Task, ThemeStoreError>>; /// In-flight preview downloads, one per catalog entry that advertises /// a `preview_url` not already in [`PreviewCache`]. `String` is the /// theme id the bytes belong to. #[derive(Resource, Default)] struct PreviewTasks(Vec<(String, PreviewFetch)>); // --------------------------------------------------------------------------- // 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::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() // Esc-close reads keyboard input; register defensively so // the plugin works under MinimalPlugins in tests. .init_resource::>() .add_message::() .add_message::() .add_message::() .add_systems( Update, ( handle_open_request, poll_catalog_task, poll_preview_tasks, 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 { 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, existing: Query<(), With>, other_modal_scrims: Query< (), ( With, Without, Without, ), >, settings: Option>, rt: Option>, mut catalog_state: ResMut, mut catalog_task: ResMut, mut store_base: ResMut, mut warning_toast: MessageWriter, previews: Res, mut commands: Commands, font_res: Option>, ) { 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, &previews, font_res.as_deref(), ); } /// Polls the catalog fetch; on completion updates [`CatalogState`], /// kicks off preview downloads for entries not yet in the cache, and /// rebuilds the modal if it is still open. #[allow(clippy::too_many_arguments)] fn poll_catalog_task( mut catalog_task: ResMut, mut catalog_state: ResMut, screens: Query>, registry: Option>, rt: Option>, store_base: Res, previews: Res, mut preview_tasks: ResMut, mut commands: Commands, font_res: Option>, ) { 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) => { // Fetch previews for anything new. Decorative — failures // just leave the row text-only, so errors only log. if let (Some(rt), Some(base_url)) = (rt.as_ref(), store_base.0.as_deref()) { for entry in entries .iter() .filter(|e| e.preview_url.is_some() && !previews.0.contains_key(&e.id)) { let rt = rt.0.clone(); let base_url = base_url.to_owned(); let entry = entry.clone(); preview_tasks.0.push(( entry.id.clone(), AsyncComputeTaskPool::get().spawn(async move { rt.block_on(async { ThemeStoreClient::new(base_url).fetch_preview(&entry).await }) }), )); } } 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(), &previews, font_res.as_deref(), ); } /// Polls in-flight preview downloads. Each finished PNG is decoded into /// an [`Image`] asset and cached by theme id; the open modal rebuilds /// once per frame that added at least one preview, so thumbnails pop in /// as they arrive. Failures log and leave the row text-only. Skipped /// entirely when `Assets` is absent (`MinimalPlugins` tests). #[allow(clippy::too_many_arguments)] fn poll_preview_tasks( mut preview_tasks: ResMut, mut previews: ResMut, images: Option>>, catalog_state: Res, screens: Query>, registry: Option>, mut commands: Commands, font_res: Option>, ) { if preview_tasks.0.is_empty() { return; } let Some(mut images) = images else { return; }; let mut added = false; preview_tasks.0.retain_mut(|(id, task)| { let Some(result) = future::block_on(future::poll_once(task)) else { return true; // still downloading }; match result { Ok(bytes) => match image::load_from_memory(&bytes) { Ok(dyn_img) => { let handle = images.add(Image::from_dynamic( dyn_img, true, RenderAssetUsages::RENDER_WORLD, )); previews.0.insert(id.clone(), handle); added = true; } Err(e) => warn!("theme store: preview for '{id}' failed to decode: {e}"), }, Err(e) => warn!("theme store: preview fetch for '{id}' failed: {e}"), } false }); if added { rebuild_open_modal( &screens, &mut commands, &catalog_state, registry.as_deref(), &previews, 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>, rt: Option>, store_base: Res, mut install_task: ResMut, mut info_toast: MessageWriter, ) { 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, 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, mut registry: Option>, catalog_state: Res, screens: Query>, mut info_toast: MessageWriter, mut warning_toast: MessageWriter, previews: Res, mut commands: Commands, font_res: Option>, ) { 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(), &previews, font_res.as_deref(), ); } /// Despawns the store modal when Close is pressed or on Esc (Phase C /// dismissal audit). The store only ever stacks over Settings and /// nothing stacks over the store, so it owns Esc whenever it is open /// (Settings' own Esc handler is gated on being topmost). fn handle_close_button( interactions: Query<&Interaction, (Changed, With)>, keys: Res>, screens: Query>, mut commands: Commands, ) { let clicked = interactions.iter().any(|i| *i == Interaction::Pressed); let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty(); if !clicked && !esc { return; } 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>, commands: &mut Commands, catalog_state: &CatalogState, registry: Option<&ThemeRegistry>, previews: &PreviewCache, font_res: Option<&FontResource>, ) { for entity in screens { commands.entity(entity).despawn(); spawn_store_modal(commands, catalog_state, registry, previews, font_res); } } /// Spawns the store modal for the current catalog state. fn spawn_store_modal( commands: &mut Commands, catalog_state: &CatalogState, registry: Option<&ThemeRegistry>, previews: &PreviewCache, 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 => { spawn_empty_state( card, "\u{21BB}", "Loading the catalog\u{2026}", None, font_res, ); } CatalogState::Error(msg) => { spawn_empty_state( card, "!", "Couldn't load the catalog.", Some(msg.as_str()), font_res, ); } CatalogState::Loaded(entries) if entries.is_empty() => { spawn_empty_state( card, "#", "The server has no themes yet.", Some("Themes dropped into the server's theme_store folder appear here."), font_res, ); } 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, previews.0.get(&entry.id), &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); } /// Height of a row's preview thumbnail in logical pixels; the width /// follows the theme's own `card_aspect` so a preview is never /// stretched. const PREVIEW_THUMB_HEIGHT_PX: f32 = 72.0; /// One catalog row: preview thumbnail (when downloaded) + name + /// author/size caption on the left, Install button (or "Installed" /// caption) on the right. #[allow(clippy::too_many_arguments)] fn spawn_store_row( parent: &mut ChildSpawnerCommands, entry: &ThemeCatalogEntry, installed: bool, preview: Option<&Handle>, 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::Row, align_items: AlignItems::Center, column_gap: VAL_SPACE_3, ..default() }) .with_children(|left| { if let Some(handle) = preview { let (aw, ah) = entry.card_aspect; let width = PREVIEW_THUMB_HEIGHT_PX * aw.max(1) as f32 / ah.max(1) as f32; left.spawn(( ImageNode::new(handle.clone()), Node { width: Val::Px(width), height: Val::Px(PREVIEW_THUMB_HEIGHT_PX), ..default() }, )); } left.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::>() .write(ThemeStoreOpenRequestEvent); app.update(); assert_eq!(screen_count(&mut app), 0); assert!( !app.world() .resource::>() .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::>() .write(ThemeStoreOpenRequestEvent); app.update(); assert_eq!(screen_count(&mut app), 0); assert!( !app.world() .resource::>() .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::>() .write(ThemeStoreOpenRequestEvent); app.update(); assert_eq!(screen_count(&mut app), 1); assert!(matches!( *app.world().resource::(), CatalogState::Loading | CatalogState::Error(_) )); assert_eq!( app.world().resource::().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::>() .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::>() .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::Error(_) ) { break; } std::thread::sleep(std::time::Duration::from_millis(10)); } assert!(matches!( *app.world().resource::(), CatalogState::Error(_) )); assert_eq!(screen_count(&mut app), 1); } // ----------------------------------------------------------------------- // Phase H part 2: preview thumbnails // ----------------------------------------------------------------------- fn catalog_entry(id: &str, preview: bool) -> ThemeCatalogEntry { ThemeCatalogEntry { id: id.into(), name: id.into(), author: "Test".into(), version: "1.0.0".into(), card_aspect: (2, 3), size_bytes: 1024, sha256: "00".into(), download_url: format!("/api/themes/{id}/download"), preview_url: preview.then(|| format!("/api/themes/{id}/preview")), } } /// A row whose theme id is in the [`PreviewCache`] renders an /// `ImageNode` thumbnail; a row without one stays text-only. #[test] fn loaded_row_shows_preview_thumbnail_only_when_cached() { let mut app = headless_app(); let cached = catalog_entry("with-preview", true); let uncached = catalog_entry("without-preview", false); let mut cache = PreviewCache::default(); cache .0 .insert(cached.id.clone(), Handle::::default()); let state = CatalogState::Loaded(vec![cached, uncached]); { let world = app.world_mut(); let mut commands = world.commands(); spawn_store_modal(&mut commands, &state, None, &cache, None); } app.update(); let thumbs = app .world_mut() .query::<&ImageNode>() .iter(app.world()) .count(); assert_eq!( thumbs, 1, "exactly the cached entry must render a preview thumbnail" ); } /// The thumbnail width follows the theme's own card aspect so a /// preview never renders stretched. #[test] fn preview_thumbnail_width_follows_card_aspect() { let mut app = headless_app(); let mut entry = catalog_entry("wide", true); entry.card_aspect = (1, 1); // square theme art let mut cache = PreviewCache::default(); cache.0.insert(entry.id.clone(), Handle::::default()); let state = CatalogState::Loaded(vec![entry]); { let world = app.world_mut(); let mut commands = world.commands(); spawn_store_modal(&mut commands, &state, None, &cache, None); } app.update(); let node = app .world_mut() .query_filtered::<&Node, With>() .single(app.world()) .expect("thumbnail node must exist"); assert_eq!(node.height, Val::Px(PREVIEW_THUMB_HEIGHT_PX)); assert_eq!( node.width, Val::Px(PREVIEW_THUMB_HEIGHT_PX), "a 1:1 aspect must yield a square thumbnail" ); } }