bones_asset/
io.rs

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