game_toolkit_assets/
lib.rs1#![forbid(unsafe_code)]
26
27use std::collections::{HashMap, HashSet};
28#[cfg(not(target_arch = "wasm32"))]
29use std::path::Component;
30use std::path::{Path, PathBuf};
31#[cfg(not(target_arch = "wasm32"))]
32use std::sync::Mutex;
33#[cfg(not(target_arch = "wasm32"))]
34use std::sync::mpsc::{Receiver, channel};
35
36#[cfg(not(target_arch = "wasm32"))]
37use anyhow::Context;
38use anyhow::Result;
39#[cfg(not(target_arch = "wasm32"))]
40use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
41
42#[cfg(target_arch = "wasm32")]
43mod web;
44
45pub const MANIFEST_FILE: &str = "manifest.txt";
47
48pub struct Assets {
49 root: PathBuf,
50 mem: HashMap<PathBuf, Vec<u8>>,
52 #[cfg(not(target_arch = "wasm32"))]
53 watcher: Option<RecommendedWatcher>,
54 #[cfg(not(target_arch = "wasm32"))]
55 rx: Option<Receiver<PathBuf>>,
56 pending: HashSet<PathBuf>,
57 #[cfg(not(target_arch = "wasm32"))]
59 web_list: Option<WebList>,
60}
61
62#[cfg(not(target_arch = "wasm32"))]
64struct WebList {
65 source: String,
67 hint: &'static str,
68 listed: HashSet<PathBuf>,
69 warned: Mutex<HashSet<PathBuf>>,
70}
71
72pub fn parse_manifest(text: &str) -> Vec<String> {
74 text.lines()
75 .map(str::trim)
76 .filter(|l| !l.is_empty() && !l.starts_with('#'))
77 .map(str::to_owned)
78 .collect()
79}
80
81#[cfg(not(target_arch = "wasm32"))]
83fn normalize(path: &Path) -> PathBuf {
84 path.components()
85 .filter(|c| !matches!(c, Component::CurDir))
86 .collect()
87}
88
89impl Assets {
90 pub fn new(root: impl AsRef<Path>) -> Result<Self> {
94 let root = root.as_ref();
95 #[cfg(not(target_arch = "wasm32"))]
96 let root = root
97 .canonicalize()
98 .with_context(|| format!("asset root {} not found", root.display()))?;
99 #[cfg(target_arch = "wasm32")]
100 let root = root.to_path_buf();
101 #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
102 let mut assets = Self {
103 root,
104 mem: HashMap::new(),
105 #[cfg(not(target_arch = "wasm32"))]
106 watcher: None,
107 #[cfg(not(target_arch = "wasm32"))]
108 rx: None,
109 pending: HashSet::new(),
110 #[cfg(not(target_arch = "wasm32"))]
111 web_list: None,
112 };
113 #[cfg(not(target_arch = "wasm32"))]
114 if cfg!(debug_assertions) {
115 assets.load_web_manifest();
116 }
117 Ok(assets)
118 }
119
120 #[cfg(not(target_arch = "wasm32"))]
122 fn load_web_manifest(&mut self) {
123 let path = self.root.join(MANIFEST_FILE);
124 if let Ok(text) = std::fs::read_to_string(&path) {
125 self.set_web_list(
126 path.display().to_string(),
127 "regenerate the manifest, e.g. `task assets:manifest`",
128 parse_manifest(&text),
129 );
130 }
131 }
132
133 pub fn set_web_preload<I, S>(&mut self, paths: I)
136 where
137 I: IntoIterator<Item = S>,
138 S: AsRef<str>,
139 {
140 #[cfg(not(target_arch = "wasm32"))]
141 if cfg!(debug_assertions) {
142 let paths = paths.into_iter().map(|p| p.as_ref().to_owned()).collect();
143 self.set_web_list(
144 "AppConfig::web_preload".into(),
145 "add it to that list",
146 paths,
147 );
148 }
149 #[cfg(target_arch = "wasm32")]
150 let _ = paths;
151 }
152
153 #[cfg(not(target_arch = "wasm32"))]
154 fn set_web_list(&mut self, source: String, hint: &'static str, paths: Vec<String>) {
155 let listed = paths.iter().map(|p| normalize(&self.resolve(p))).collect();
156 self.web_list = Some(WebList {
157 source,
158 hint,
159 listed,
160 warned: Mutex::new(HashSet::new()),
161 });
162 }
163
164 pub fn check_web_listed(&self, rel: impl AsRef<Path>) {
169 #[cfg(not(target_arch = "wasm32"))]
170 {
171 let Some(list) = &self.web_list else {
172 return;
173 };
174 let p = normalize(&self.resolve(rel.as_ref()));
175 if list.listed.contains(&p) || self.mem.contains_key(&p) || !p.starts_with(&self.root) {
176 return;
177 }
178 let mut warned = list
179 .warned
180 .lock()
181 .unwrap_or_else(std::sync::PoisonError::into_inner);
182 if warned.insert(p) {
183 log::warn!(
184 "asset {} is not listed in {}; it loads natively but will fail on the web ({})",
185 rel.as_ref().display(),
186 list.source,
187 list.hint
188 );
189 }
190 }
191 #[cfg(target_arch = "wasm32")]
192 let _ = rel;
193 }
194
195 pub fn root(&self) -> &Path {
196 &self.root
197 }
198
199 pub fn resolve(&self, rel: impl AsRef<Path>) -> PathBuf {
200 let rel = rel.as_ref();
201 if rel.is_absolute() {
202 rel.to_path_buf()
203 } else {
204 self.root.join(rel)
205 }
206 }
207
208 pub fn read(&self, rel: impl AsRef<Path>) -> Result<Vec<u8>> {
211 let p = self.resolve(rel.as_ref());
212 if let Some(bytes) = self.mem.get(&p) {
213 return Ok(bytes.clone());
214 }
215 #[cfg(not(target_arch = "wasm32"))]
216 {
217 let bytes = std::fs::read(&p).with_context(|| format!("read {}", p.display()))?;
218 self.check_web_listed(rel);
219 Ok(bytes)
220 }
221 #[cfg(target_arch = "wasm32")]
222 {
223 anyhow::bail!(
224 "asset {} was not preloaded; list it in {}/{MANIFEST_FILE}",
225 p.display(),
226 self.root.display()
227 )
228 }
229 }
230
231 pub fn insert(&mut self, rel: impl AsRef<Path>, bytes: impl Into<Vec<u8>>) {
233 let p = self.resolve(rel);
234 self.mem.insert(p, bytes.into());
235 }
236
237 pub fn is_loaded(&self, rel: impl AsRef<Path>) -> bool {
239 self.mem.contains_key(&self.resolve(rel))
240 }
241
242 #[cfg(not(target_arch = "wasm32"))]
245 pub fn watch(&mut self) -> Result<()> {
246 if self.watcher.is_some() {
247 return Ok(());
248 }
249 let (tx, rx) = channel::<PathBuf>();
250 let mut watcher =
251 notify::recommended_watcher(move |res: notify::Result<Event>| match res {
252 Ok(event) => {
253 if matches!(
254 event.kind,
255 EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
256 ) {
257 for p in event.paths {
258 let _ = tx.send(p);
259 }
260 }
261 }
262 Err(e) => log::warn!("notify error: {e}"),
263 })?;
264 watcher.watch(&self.root, RecursiveMode::Recursive)?;
265 self.watcher = Some(watcher);
266 self.rx = Some(rx);
267 Ok(())
268 }
269
270 #[cfg(target_arch = "wasm32")]
273 pub fn watch(&mut self) -> Result<()> {
274 log::info!("asset hot-reload is not available on the web");
275 Ok(())
276 }
277
278 pub fn drain_changes(&mut self) -> Vec<PathBuf> {
282 #[cfg(not(target_arch = "wasm32"))]
283 if let Some(rx) = self.rx.as_ref() {
284 while let Ok(p) = rx.try_recv() {
285 let canon = p.canonicalize().unwrap_or(p);
286 self.pending.insert(canon);
287 }
288 }
289 self.pending.drain().collect()
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn inserted_bytes_shadow_the_filesystem() {
299 let mut assets = Assets::new(".").unwrap();
300 assert!(!assets.is_loaded("Cargo.toml"));
301 assets.insert("Cargo.toml", b"in memory".to_vec());
302 assert!(assets.is_loaded("Cargo.toml"));
303 assert_eq!(assets.read("Cargo.toml").unwrap(), b"in memory");
304 }
305
306 #[test]
307 fn parse_manifest_skips_comments_and_blanks() {
308 let text = "# header\n\nsprites/a.png\n b.wav \n#c.png\n";
309 assert_eq!(parse_manifest(text), ["sprites/a.png", "b.wav"]);
310 }
311
312 #[test]
313 #[cfg(all(not(target_arch = "wasm32"), debug_assertions))]
314 fn web_list_flags_unlisted_files_once() {
315 let mut assets = Assets::new(".").unwrap();
316 assets.set_web_preload(["./Cargo.toml"]);
317 let list = assets
318 .web_list
319 .as_ref()
320 .expect("debug build loads the list");
321 assert!(
322 list.listed
323 .contains(&normalize(&assets.resolve("Cargo.toml")))
324 );
325
326 assets.check_web_listed("Cargo.toml");
327 assets.check_web_listed("src/lib.rs");
328 assets.check_web_listed("src/lib.rs");
329 let warned = assets.web_list.as_ref().unwrap().warned.lock().unwrap();
330 assert_eq!(warned.len(), 1);
331 assert!(warned.contains(&assets.resolve("src/lib.rs")));
332 }
333
334 #[test]
335 fn falls_back_to_disk() {
336 let assets = Assets::new(".").unwrap();
337 assert!(assets.read("Cargo.toml").unwrap().starts_with(b"[package]"));
338 assert!(assets.read("does-not-exist.bin").is_err());
339 }
340}