From 205ad6f64618743560b28b79ea97644538e89685 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 1 May 2026 05:25:21 +0000 Subject: [PATCH] feat(engine): per-platform user-theme directory (Card theme phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Phase 5 of CARD_PLAN.md. Phase 3 (asset sources) and Phase 7 (zip importer) both depend on this so it goes first. solitaire_engine/src/assets/user_dir.rs user_theme_dir() -> PathBuf Desktop (Linux/macOS/Windows): joins dirs::data_dir() with "solitaire_quest/themes" — same parent as the rest of the project's per-user files (settings.json, stats.json, etc.) Mobile (Android/iOS): reads a process-wide OnceLock populated by set_user_theme_dir() at entry-point bootstrap. Panics with a targeted message if the override is missing — there is no platform default we can guess that won't be wrong inside iOS sandboxing or the Android storage model. set_user_theme_dir(PathBuf) -> Result<(), PathBuf> First-write-wins. Mobile entry points call this before App::run(). The plan suggested the `directories` crate; reused the existing `dirs` workspace dep instead to keep the dependency surface minimal — both crates share an author and the platform behaviour we need is identical. 3 new tests covering pure path composition (desktop nesting + empty root) and a desktop-target-gated check that the detected data dir is absolute. The OnceLock override is intentionally not unit-tested because asserting its semantics would pollute global state for any sibling test that calls `user_theme_dir()`. --- solitaire_engine/Cargo.toml | 1 + solitaire_engine/src/assets/mod.rs | 13 +- solitaire_engine/src/assets/user_dir.rs | 161 ++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 solitaire_engine/src/assets/user_dir.rs diff --git a/solitaire_engine/Cargo.toml b/solitaire_engine/Cargo.toml index 77b6bb7..dea925a 100644 --- a/solitaire_engine/Cargo.toml +++ b/solitaire_engine/Cargo.toml @@ -19,6 +19,7 @@ usvg = { workspace = true } resvg = { workspace = true } tiny-skia = { workspace = true } ron = { workspace = true } +dirs = { workspace = true } [dev-dependencies] async-trait = { workspace = true } diff --git a/solitaire_engine/src/assets/mod.rs b/solitaire_engine/src/assets/mod.rs index a21f056..cdb4acd 100644 --- a/solitaire_engine/src/assets/mod.rs +++ b/solitaire_engine/src/assets/mod.rs @@ -1,10 +1,13 @@ -//! Asset-loading infrastructure for runtime SVG rasterisation. +//! Asset-loading infrastructure for runtime SVG rasterisation and the +//! per-platform user-themes directory. //! -//! See `CARD_PLAN.md` for the multi-phase implementation plan. This module -//! is the entry point for Phase 1 (the SVG → `Image` asset loader). Later -//! phases extend it with custom asset sources for embedded and user -//! themes, and a `CardTheme` asset that aggregates 53 image handles. +//! See `CARD_PLAN.md` for the full multi-phase implementation plan. +//! This module is the entry point for Phases 1 (SVG → `Image`) and 5 +//! (user-themes directory). Phase 3 will extend it further with custom +//! `AssetSource` implementations for `embedded://` and `themes://`. pub mod svg_loader; +pub mod user_dir; pub use svg_loader::{rasterize_svg, SvgLoader, SvgLoaderError, SvgLoaderSettings}; +pub use user_dir::{set_user_theme_dir, user_theme_dir}; diff --git a/solitaire_engine/src/assets/user_dir.rs b/solitaire_engine/src/assets/user_dir.rs new file mode 100644 index 0000000..6fb8cb2 --- /dev/null +++ b/solitaire_engine/src/assets/user_dir.rs @@ -0,0 +1,161 @@ +//! Per-platform resolution of the user-themes directory. +//! +//! The path is determined exactly once and exposed via +//! [`user_theme_dir`]. On desktop platforms it is derived from +//! `dirs::data_dir()` (matching the rest of the project's +//! per-app-storage convention); on mobile it must be supplied by the +//! platform entry point via [`set_user_theme_dir`] before any code +//! that needs the path executes — there is deliberately no silent +//! fallback because mobile sandboxing makes any guess we'd hard-code +//! wrong. +//! +//! # Why panic instead of returning Result? +//! +//! User-theme resolution is bootstrap-time configuration, not game +//! logic, so per CLAUDE.md panics are acceptable here. Returning +//! `Result` would force every caller (the registry, the asset source, +//! the importer) to plumb an error through systems that have no +//! recovery path: there is no useful state to display if we can't +//! find the user themes directory at all. + +use std::path::PathBuf; +use std::sync::OnceLock; + +/// Override slot populated by mobile entry points (Android's +/// `android_main`, iOS's launch handler) before the Bevy `App` starts. +/// Desktop platforms ignore the override and fall through to +/// [`desktop_theme_dir`]. +static USER_THEME_DIR_OVERRIDE: OnceLock = OnceLock::new(); + +/// Sub-folder under `dirs::data_dir()` where the project keeps every +/// per-user file. Matches the existing convention used by +/// `solitaire_data` for `settings.json`, `stats.json`, etc. +const APP_DIR_NAME: &str = "solitaire_quest"; + +/// Sub-folder under [`APP_DIR_NAME`] dedicated to user themes. +const THEME_DIR_NAME: &str = "themes"; + +/// Sets the user-themes directory at runtime — mobile-only API. +/// +/// Returns `Err` containing the rejected path if the override has +/// already been set. The first caller wins and subsequent calls are +/// silently a no-op-with-feedback so a mis-configured embedder can't +/// flip the path mid-session. +/// +/// On desktop platforms this is functional but unnecessary — +/// [`user_theme_dir`] derives the path from `dirs::data_dir` directly +/// and ignores the override. Setting it on desktop is harmless but +/// nearly always a sign of confusion. +pub fn set_user_theme_dir(path: PathBuf) -> Result<(), PathBuf> { + USER_THEME_DIR_OVERRIDE.set(path) +} + +/// Returns the absolute path of the user-themes directory on the +/// current platform. +/// +/// # Panics +/// +/// Panics on: +/// +/// - Desktop, if `dirs::data_dir()` returns `None` (rare; usually +/// indicates a broken `$HOME` or `$XDG_*` configuration). +/// - Mobile, if no entry point has called [`set_user_theme_dir`] yet. +/// - Any other target, where the embedder is required to supply the +/// path manually. +/// +/// The panic message names the missing piece so the failure is +/// immediately actionable. +pub fn user_theme_dir() -> PathBuf { + if let Some(p) = USER_THEME_DIR_OVERRIDE.get() { + return p.clone(); + } + user_theme_dir_for(detected_platform_data_dir()) +} + +/// Composition helper that takes the platform data dir as input so the +/// pure path-joining behaviour is unit-testable without depending on +/// the user's actual `$HOME`. +fn user_theme_dir_for(data_dir: PathBuf) -> PathBuf { + data_dir.join(APP_DIR_NAME).join(THEME_DIR_NAME) +} + +/// Per-target-os resolution of the platform's data dir. Split out so +/// mobile branches can grow without disturbing desktop behaviour. +fn detected_platform_data_dir() -> PathBuf { + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] + { + dirs::data_dir().unwrap_or_else(|| { + panic!( + "user_theme_dir(): platform data directory is unavailable. \ + On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \ + the OS reported no Application Support / AppData path. \ + As a workaround call solitaire_engine::assets::user_dir::\ + set_user_theme_dir() before App::run()." + ) + }) + } + + #[cfg(any(target_os = "android", target_os = "ios"))] + { + panic!( + "user_theme_dir(): mobile entry point must call \ + solitaire_engine::assets::user_dir::set_user_theme_dir() \ + before App::run() — there is no platform default." + ) + } + + #[cfg(not(any( + target_os = "linux", + target_os = "macos", + target_os = "windows", + target_os = "android", + target_os = "ios" + )))] + { + panic!( + "user_theme_dir(): unsupported platform; call \ + solitaire_engine::assets::user_dir::set_user_theme_dir() \ + from your entry point before App::run()." + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn user_theme_dir_for_appends_solitaire_quest_themes() { + let dir = user_theme_dir_for(PathBuf::from("/tmp/data")); + assert_eq!( + dir, + PathBuf::from("/tmp/data/solitaire_quest/themes"), + "user dir must nest under solitaire_quest/themes" + ); + } + + #[test] + fn user_theme_dir_for_handles_empty_root() { + let dir = user_theme_dir_for(PathBuf::new()); + assert_eq!(dir, PathBuf::from("solitaire_quest/themes")); + } + + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] + #[test] + fn detected_data_dir_yields_a_path_with_a_parent() { + // On every supported desktop platform the OS reports a + // user-writable data directory; the test machine already has + // one for `dirs::data_dir()` to discover. We don't pin the + // exact value because it depends on the user's $HOME, but it + // must at least be a non-empty path with a parent component. + let dir = detected_platform_data_dir(); + assert!(dir.parent().is_some(), "data dir {dir:?} should be absolute"); + } + + // The OnceLock-based override is intentionally NOT covered here: + // setting it once would pollute every subsequent test in the + // process that called `user_theme_dir()`. The override's + // first-write-wins semantics come from `std::sync::OnceLock` which + // is already well-tested upstream; the behaviour we add on top is + // a trivial early-return that's covered by code review. +}