//! 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), 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); // --------------------------------------------------------------------------- // 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::() // 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, 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, 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, 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, mut catalog_state: ResMut, screens: Query>, registry: Option>, 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) => 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>, 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, 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(), 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>, 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::>() .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); } }