757c35e4a0
user_theme_dir() panicked on desktop when dirs::data_dir() returned None (broken $HOME / $XDG_*). Return an empty path instead — exactly what the wasm32 branch already did — so theme discovery reports 'no user themes' and the bundled default keeps working. Warns once with the set_user_theme_dir() workaround. CLAUDE.md §2.3: no panic! in runtime logic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
152 lines
6.3 KiB
Rust
152 lines
6.3 KiB
Rust
//! Per-platform resolution of the user-themes directory.
|
|
//!
|
|
//! The path is determined exactly once and exposed via
|
|
//! [`user_theme_dir`]. The base directory comes from
|
|
//! [`solitaire_data::data_dir`] (desktop: `dirs::data_dir()`;
|
|
//! Android: the hardcoded `/data/data/<package>/files` sandbox
|
|
//! path). Mobile entry points may still override the path via
|
|
//! [`set_user_theme_dir`] when they need to point at a non-default
|
|
//! location (e.g. tests, custom AssetManager wiring).
|
|
//!
|
|
//! # 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<PathBuf> = 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 = "ferrous_solitaire";
|
|
|
|
/// Sub-folder under [`APP_DIR_NAME`] dedicated to user themes.
|
|
const THEME_DIR_NAME: &str = "themes";
|
|
|
|
/// Sets the user-themes directory at runtime — escape hatch for
|
|
/// embedders or tests that need to override the platform default.
|
|
///
|
|
/// 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.
|
|
///
|
|
/// Mostly unnecessary now that [`solitaire_data::data_dir`] handles
|
|
/// every supported target — the override is kept for tests and for
|
|
/// embedders that want a non-default location (e.g. a sandboxed
|
|
/// AssetManager root on a future iOS port).
|
|
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.
|
|
///
|
|
/// When [`solitaire_data::data_dir`] returns `None` (broken `$HOME` /
|
|
/// `$XDG_*` on desktop; always on wasm32, which has no filesystem) this
|
|
/// returns an empty path — callers treat that as "no user themes" and
|
|
/// the bundled default theme still works. A warning naming the
|
|
/// [`set_user_theme_dir`] workaround is logged once. Android always
|
|
/// resolves.
|
|
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. Delegates
|
|
/// to [`solitaire_data::data_dir`] which encapsulates the
|
|
/// per-target shape (desktop: `dirs::data_dir()`; android: the
|
|
/// hardcoded `/data/data/<package>/files` sandbox path).
|
|
///
|
|
/// When the resolver returns `None` — always on wasm32 (no
|
|
/// filesystem), or a broken `$HOME` / `$XDG_*` configuration on
|
|
/// desktop — this degrades to an empty path, which downstream theme
|
|
/// scanning treats as "no user themes"; the bundled default theme is
|
|
/// unaffected. CLAUDE.md §2.3 forbids panicking here: losing custom
|
|
/// themes must not take the whole game down with it.
|
|
fn detected_platform_data_dir() -> PathBuf {
|
|
solitaire_data::data_dir().unwrap_or_else(|| {
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
use std::sync::Once;
|
|
static WARN_ONCE: Once = Once::new();
|
|
WARN_ONCE.call_once(|| {
|
|
bevy::log::warn!(
|
|
"user_theme_dir(): platform data directory is unavailable; \
|
|
user themes are disabled. 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()."
|
|
);
|
|
});
|
|
}
|
|
PathBuf::new()
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn user_theme_dir_for_appends_ferrous_solitaire_themes() {
|
|
let dir = user_theme_dir_for(PathBuf::from("/tmp/data"));
|
|
assert_eq!(
|
|
dir,
|
|
PathBuf::from("/tmp/data/ferrous_solitaire/themes"),
|
|
"user dir must nest under ferrous_solitaire/themes"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn user_theme_dir_for_handles_empty_root() {
|
|
let dir = user_theme_dir_for(PathBuf::new());
|
|
assert_eq!(dir, PathBuf::from("ferrous_solitaire/themes"));
|
|
}
|
|
|
|
#[test]
|
|
fn detected_data_dir_yields_a_path_with_a_parent() {
|
|
// On every supported target the platform resolver
|
|
// (`solitaire_data::data_dir`) returns a usable directory:
|
|
// desktop targets via `dirs::data_dir()` (the test machine
|
|
// already has a `$HOME` for it to discover), Android via
|
|
// the hardcoded `/data/data/<package>/files` sandbox path.
|
|
// We don't pin the exact value because it depends on the
|
|
// user's `$HOME` on desktop, 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.
|
|
}
|