rosace_core/asset.rs
1//! Cross-platform asset resolution (A6). One place that maps a logical asset
2//! name (`"logo.png"`) to loadable bytes, so `ImageWidget::asset`,
3//! `FontCache::from_asset`, and any future theme-from-asset loader all agree on
4//! *where assets live* — and so hot-reload has a single cache to invalidate.
5//!
6//! The **API is identical on every platform**; only the *root* differs, and the
7//! host sets it once at launch via [`set_root`]:
8//! - desktop dev / `rsc dev` / `rsc run`: the project's `assets/` dir
9//! (cwd-relative — the default, so nothing to set);
10//! - desktop release: `assets/` beside the executable (host may override);
11//! - iOS / Android: the app bundle's resources dir (FFI host sets it);
12//! - web: served under `/assets/` — the wasm loader fetches bytes (wired with
13//! the web asset step; the path API still resolves for URL building).
14//!
15//! `rsc.toml`'s `[assets] dirs = ["assets"]` declares what gets bundled; this
16//! module is the runtime that reads them back.
17
18use std::path::PathBuf;
19use std::sync::{Mutex, OnceLock};
20
21/// A compile-time asset **handle** — the typed, typo-proof way to refer to a
22/// bundled asset. The `assets` module generated from your `assets/` dir (by
23/// `rosace-asset-codegen` in `build.rs`) is full of `const Asset`s:
24/// `assets::LOGO`, `assets::icons::HOME`. Passing `assets::LGO` won't compile,
25/// and your editor autocompletes the real names.
26///
27/// It's a thin newtype over the logical name, so it costs nothing at runtime
28/// and interops with the raw-string escape hatch through [`AssetRef`].
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30pub struct Asset {
31 name: &'static str,
32}
33
34impl Asset {
35 /// Build a handle from a logical name. `const` so generated code can make
36 /// these as `const` items.
37 pub const fn new(name: &'static str) -> Self {
38 Self { name }
39 }
40
41 /// The logical name this handle points at (e.g. `"icons/home.png"`).
42 pub const fn name(&self) -> &str {
43 self.name
44 }
45}
46
47/// Anything usable as an asset reference: a typed [`Asset`] handle (the blessed,
48/// checked form) **or** a raw `&str`/`String` (the escape hatch, for names only
49/// known at runtime — e.g. a user-picked file). Every loader takes
50/// `impl AssetRef`, so both forms work at the same call site:
51///
52/// ```ignore
53/// Image::asset(assets::LOGO) // typed, typo-proof
54/// Image::asset("logo.png") // dynamic escape hatch
55/// ```
56pub trait AssetRef {
57 /// The logical asset name to resolve.
58 fn asset_name(&self) -> &str;
59}
60
61impl AssetRef for Asset {
62 fn asset_name(&self) -> &str { self.name }
63}
64impl AssetRef for &Asset {
65 fn asset_name(&self) -> &str { self.name }
66}
67impl AssetRef for &str {
68 fn asset_name(&self) -> &str { self }
69}
70impl AssetRef for String {
71 fn asset_name(&self) -> &str { self.as_str() }
72}
73impl AssetRef for &String {
74 fn asset_name(&self) -> &str { self.as_str() }
75}
76
77fn root_slot() -> &'static Mutex<Option<PathBuf>> {
78 static ROOT: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
79 ROOT.get_or_init(|| Mutex::new(None))
80}
81
82/// Point asset resolution at a directory. Mobile FFI hosts call this at launch
83/// with the app bundle's resources path; desktop release can point it beside
84/// the executable. Desktop dev needs no call — the default (`assets/`) is right.
85pub fn set_root(path: impl Into<PathBuf>) {
86 *root_slot().lock().unwrap() = Some(path.into());
87}
88
89/// The directory assets resolve against. Resolution order:
90/// 1. an explicit [`set_root`] override (mobile FFI hosts set the bundle path);
91/// 2. `./assets` if it exists — the dev case (`rsc dev`/`rsc run` from the
92/// project root);
93/// 3. release bundle locations relative to the executable, so a Finder-launched
94/// `.app` (whose cwd is `/`) or an installed binary still finds its assets:
95/// - macOS `.app`: `<exe>/../Resources/assets`,
96/// - Windows/Linux: `assets/` beside the executable;
97/// 4. otherwise the cwd-relative `assets` default (nothing bundled yet).
98///
99/// The bundlers in `rsc package`/`rsc run` copy `assets/` into exactly these
100/// locations, so the copy side and this resolve side stay in lockstep.
101pub fn root() -> PathBuf {
102 if let Some(p) = root_slot().lock().unwrap().clone() {
103 return p;
104 }
105
106 let cwd_assets = PathBuf::from("assets");
107 if cwd_assets.is_dir() {
108 return cwd_assets;
109 }
110
111 if let Ok(exe) = std::env::current_exe() {
112 if let Some(dir) = exe.parent() {
113 let candidates = [dir.join("../Resources/assets"), dir.join("assets")];
114 for c in candidates {
115 if c.is_dir() {
116 return c;
117 }
118 }
119 }
120 }
121
122 cwd_assets
123}
124
125/// Resolve an asset (typed handle or raw name) to a filesystem path under the
126/// asset root. `resolve(assets::icons::HOME)` → `<root>/icons/home.png`.
127pub fn resolve(asset: impl AssetRef) -> PathBuf {
128 root().join(asset.asset_name())
129}
130
131/// Read an asset's bytes, or `None` if it can't be found or read. This is the
132/// single load primitive every typed loader (image, font, data) builds on, so
133/// they all share one resolution + one hot-reload story.
134pub fn bytes(asset: impl AssetRef) -> Option<Vec<u8>> {
135 std::fs::read(resolve(asset)).ok()
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 #[test]
143 fn resolves_under_the_root_and_honours_an_override() {
144 // Default root is cwd-relative `assets/`.
145 assert_eq!(resolve("logo.png"), PathBuf::from("assets").join("logo.png"));
146
147 // A host override (mobile bundle path) redirects resolution.
148 set_root("/bundle/Resources");
149 assert_eq!(resolve("logo.png"), PathBuf::from("/bundle/Resources/logo.png"));
150 assert_eq!(resolve("f/x.ttf"), PathBuf::from("/bundle/Resources/f/x.ttf"));
151
152 // Restore the default so other tests see cwd-relative resolution.
153 *root_slot().lock().unwrap() = None;
154 }
155}