bones_asset/
io.rs

1use std::path::PathBuf;
2
3use anyhow::Context;
4use async_channel::Sender;
5use bones_utils::{default, futures::future::Boxed as BoxedFuture, HashMap};
6use path_absolutize::Absolutize;
7
8use crate::{AssetLocRef, ChangedAsset};
9
10/// [`AssetIo`] is a trait that is implemented for backends capable of loading all the games assets
11/// and returning the raw bytes stored in asset files.
12pub trait AssetIo: Sync + Send {
13    /// List the names of the non-core asset pack folders that are installed.
14    ///
15    /// These names, are not necessarily the names of the pack, but the names of the folders that
16    /// they are located in. These names can be used to load files from the pack in the
17    /// [`load_file()`][Self::load_file] method.
18    fn enumerate_packs(&self) -> BoxedFuture<anyhow::Result<Vec<String>>>;
19
20    /// Get the binary contents of an asset.
21    ///
22    /// The `pack_folder` is the name of a folder returned by
23    /// [`enumerate_packs()`][Self::enumerate_packs], or [`None`] to refer to the core pack.
24    fn load_file(&self, loc: AssetLocRef) -> BoxedFuture<anyhow::Result<Vec<u8>>>;
25
26    /// Subscribe to asset changes.
27    ///
28    /// Returns `true` if this [`AssetIo`] implementation supports watching for changes.
29    fn watch(&self, change_sender: Sender<ChangedAsset>) -> bool {
30        let _ = change_sender;
31        false
32    }
33}
34
35/// [`AssetIo`] implementation that loads from the filesystem.
36#[cfg(not(target_arch = "wasm32"))]
37pub struct FileAssetIo {
38    /// The directory to load the core asset pack.
39    pub core_dir: PathBuf,
40    /// The directory to load the asset packs from.
41    pub packs_dir: PathBuf,
42    /// Filesystem watcher if enabled.
43    pub watcher: bones_utils::parking_lot::Mutex<Option<Box<dyn notify::Watcher + Sync + Send>>>,
44}
45
46#[cfg(not(target_arch = "wasm32"))]
47impl FileAssetIo {
48    /// Create a new [`FileAssetIo`].
49    pub fn new(core_dir: &std::path::Path, packs_dir: &std::path::Path) -> Self {
50        let cwd = std::env::current_dir().unwrap();
51        let core_dir = cwd.join(core_dir);
52        let packs_dir = cwd.join(packs_dir);
53        Self {
54            core_dir: core_dir.clone(),
55            packs_dir: packs_dir.clone(),
56            watcher: bones_utils::parking_lot::Mutex::new(None),
57        }
58    }
59}
60
61#[cfg(not(target_arch = "wasm32"))]
62impl AssetIo for FileAssetIo {
63    fn enumerate_packs(&self) -> BoxedFuture<anyhow::Result<Vec<String>>> {
64        if !self.packs_dir.exists() {
65            return Box::pin(async { Ok(Vec::new()) });
66        }
67
68        let packs_dir = self.packs_dir.clone();
69        Box::pin(async move {
70            // List the folders in the asset packs dir.
71            let dirs = std::fs::read_dir(&packs_dir)?
72                .map(|entry| {
73                    let entry = entry?;
74                    let name = entry
75                        .file_name()
76                        .to_str()
77                        .expect("non-unicode filename")
78                        .to_owned();
79                    Ok::<_, std::io::Error>(name)
80                })
81                .filter(|x| {
82                    x.as_ref()
83                        .map(|name| packs_dir.join(name).is_dir())
84                        .unwrap_or(true)
85                })
86                .collect::<Result<Vec<_>, _>>()?;
87
88            Ok(dirs)
89        })
90    }
91
92    fn load_file(&self, loc: AssetLocRef) -> BoxedFuture<anyhow::Result<Vec<u8>>> {
93        let packs_dir = self.packs_dir.clone();
94        let core_dir = self.core_dir.clone();
95        let loc = loc.to_owned();
96
97        // TODO: Load files asynchronously.
98        Box::pin(async move {
99            let base_dir = match loc.pack {
100                Some(folder) => packs_dir.join(folder),
101                None => core_dir.clone(),
102            };
103            // Make sure absolute paths are relative to pack.
104            let path = loc.path.absolutize_from("/").unwrap();
105            let path = path.strip_prefix("/").unwrap();
106            let path = base_dir.join(path);
107            std::fs::read(&path).with_context(|| format!("Could not load file: {path:?}"))
108        })
109    }
110
111    fn watch(&self, sender: Sender<ChangedAsset>) -> bool {
112        use notify::{RecursiveMode, Result, Watcher};
113
114        let core_dir_ = self.core_dir.clone();
115        let packs_dir_ = self.packs_dir.clone();
116        notify::recommended_watcher(move |res: Result<notify::Event>| match res {
117            Ok(event) => match &event.kind {
118                notify::EventKind::Create(_) | notify::EventKind::Modify(_) => {
119                    for path in event.paths {
120                        let (path, pack) = if let Ok(path) = path.strip_prefix(&core_dir_) {
121                            (path, None)
122                        } else if let Ok(path) = path.strip_prefix(&packs_dir_) {
123                            let pack = path.iter().next().unwrap().to_str().unwrap().to_string();
124                            let path = path.strip_prefix(&pack).unwrap();
125                            (path, Some(pack))
126                        } else {
127                            continue;
128                        };
129                        sender
130                            .send_blocking(ChangedAsset::Loc(crate::AssetLoc {
131                                path: path.into(),
132                                pack,
133                            }))
134                            .unwrap();
135                    }
136                }
137                _ => (),
138            },
139            Err(e) => tracing::error!("watch error: {e:?}"),
140        })
141        .and_then(|mut w| {
142            if self.core_dir.exists() {
143                w.watch(&self.core_dir, RecursiveMode::Recursive)?;
144            }
145            if self.packs_dir.exists() {
146                w.watch(&self.packs_dir, RecursiveMode::Recursive)?;
147            }
148
149            *self.watcher.lock() = Some(Box::new(w) as _);
150            Ok(())
151        })
152        .map_err(|e| {
153            tracing::error!("watch error: {e:?}");
154        })
155        .map(|_| true)
156        .unwrap_or(false)
157    }
158}
159
160/// Asset IO implementation that loads assets from a URL.
161pub struct WebAssetIo {
162    /// The base URL to load assets from.
163    pub asset_url: String,
164}
165
166impl WebAssetIo {
167    /// Create a new [`WebAssetIo`] with the given URL as the core pack root URL.
168    pub fn new(asset_url: &str) -> Self {
169        Self {
170            asset_url: asset_url.into(),
171        }
172    }
173}
174
175impl AssetIo for WebAssetIo {
176    fn enumerate_packs(&self) -> BoxedFuture<anyhow::Result<Vec<String>>> {
177        Box::pin(async move { Ok(default()) })
178    }
179
180    fn load_file(&self, loc: AssetLocRef) -> BoxedFuture<anyhow::Result<Vec<u8>>> {
181        let loc = loc.to_owned();
182        let asset_url = self.asset_url.clone();
183        Box::pin(async move {
184            if loc.pack.is_some() {
185                return Err(anyhow::format_err!("Cannot load asset packs on WASM yet"));
186            }
187            let url = format!(
188                "{}{}",
189                asset_url,
190                loc.path.absolutize_from("/").unwrap().to_str().unwrap()
191            );
192            let (sender, receiver) = async_channel::bounded(1);
193            let req = ehttp::Request::get(&url);
194            ehttp::fetch(req, move |resp| {
195                sender.send_blocking(resp.map(|resp| resp.bytes)).unwrap();
196            });
197            let result = receiver
198                .recv()
199                .await
200                .unwrap()
201                .map_err(|e| anyhow::format_err!("{e}"))
202                .with_context(|| format!("Could not download file: {url}"))?;
203
204            Ok(result)
205        })
206    }
207}
208
209/// Dummy [`AssetIo`] implementation used for debugging or as a placeholder.
210pub struct DummyIo {
211    core: HashMap<PathBuf, Vec<u8>>,
212    packs: HashMap<String, HashMap<PathBuf, Vec<u8>>>,
213}
214
215impl DummyIo {
216    /// Initialize a new [`DummyIo`] from an iterator of `(string_path, byte_data)` items.
217    pub fn new<'a, I: IntoIterator<Item = (&'a str, Vec<u8>)>>(core: I) -> Self {
218        Self {
219            core: core
220                .into_iter()
221                .map(|(p, d)| (PathBuf::from(p), d))
222                .collect(),
223            packs: Default::default(),
224        }
225    }
226}
227
228impl AssetIo for DummyIo {
229    fn enumerate_packs(&self) -> BoxedFuture<anyhow::Result<Vec<String>>> {
230        let packs = self.packs.keys().cloned().collect();
231        Box::pin(async { Ok(packs) })
232    }
233
234    fn load_file(&self, loc: AssetLocRef) -> BoxedFuture<anyhow::Result<Vec<u8>>> {
235        let err = || {
236            anyhow::format_err!(
237                "File not found: `{:?}` in pack `{:?}`",
238                loc.path,
239                loc.pack.unwrap_or("[core]")
240            )
241        };
242        let data = (|| {
243            if let Some(pack_folder) = loc.pack {
244                self.packs
245                    .get(pack_folder)
246                    .ok_or_else(err)?
247                    .get(loc.path)
248                    .cloned()
249                    .ok_or_else(err)
250            } else {
251                self.core.get(loc.path).cloned().ok_or_else(err)
252            }
253        })();
254        Box::pin(async move { data })
255    }
256}