lumen_assets/source.rs
1//! Where an asset's bytes come from before a loader sees them.
2//!
3//! A source answers "do you have this path?" with bytes or nothing. The
4//! server asks each source in turn on the main thread, before the load is
5//! handed to a worker; a path no source claims is read from disk by the
6//! loader itself. Bundled apps work because [`BundleSource`] is installed by
7//! default, and an app that keeps its assets somewhere else (embedded in the
8//! binary, a download cache) registers its own with
9//! [`crate::AssetServer::register_source`].
10
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14use crate::bundle::{LumenBundle, parse_lumen_uri};
15
16/// Resolves a requested path to bytes, or declines it.
17///
18/// Sources are consulted on the main thread while the load is being queued,
19/// so `read` should be cheap: an index lookup and a copy, not a network
20/// round trip.
21pub trait AssetSource: Send + Sync + 'static {
22 /// Returns the bytes for `path`, or `None` to let the next source (and
23 /// ultimately the filesystem) answer.
24 fn read(&self, path: &Path) -> Option<Vec<u8>>;
25}
26
27/// The built-in source: bytes served out of registered `.lpak` bundles.
28///
29/// A bundle is reachable two ways. A `lumen://app/<key>` URI addresses it
30/// directly, and once [`Self::set_root`] names the directory the archive was
31/// packed from, an ordinary path under that directory resolves to the
32/// matching key as well, so markup that names `icons/sun.png` works whether
33/// the app ships loose files or an archive. Bundled entries win over files on
34/// disk, which is what lets an app ship the archive alone.
35#[derive(Default, Clone)]
36pub struct BundleSource {
37 bundles: Vec<LumenBundle>,
38 root: Option<PathBuf>,
39}
40
41impl BundleSource {
42 /// Adds a bundle. Bundles are consulted in registration order and the
43 /// first hit wins.
44 pub fn register(&mut self, bundle: LumenBundle) {
45 self.bundles.push(bundle);
46 }
47
48 /// Declares the directory the registered bundles were packed from.
49 pub fn set_root(&mut self, root: impl Into<PathBuf>) {
50 self.root = Some(root.into());
51 }
52
53 /// The registered bundles, in registration order.
54 pub fn bundles(&self) -> &[LumenBundle] {
55 &self.bundles
56 }
57
58 /// Resolves a `lumen://app/<key>` URI. `None` when no bundle holds the
59 /// key or the URI uses another scheme.
60 pub fn read_uri(&self, uri: &str) -> Option<Vec<u8>> {
61 self.read_key(parse_lumen_uri(uri)?)
62 }
63
64 /// First registered bundle holding `key`, if any.
65 fn read_key(&self, key: &str) -> Option<Vec<u8>> {
66 self.bundles.iter().find_map(|b| b.read(key))
67 }
68}
69
70/// The first source chain hit for `path`: the bundles, then every
71/// registered source, in registration order. `None` when nothing claims it
72/// and the filesystem answers. The one lookup behind both the server's
73/// queue-time resolution and [`SourceReader::read`].
74pub(crate) fn read_chain(
75 bundle: &BundleSource,
76 sources: &[Arc<dyn AssetSource>],
77 path: &Path,
78) -> Option<Vec<u8>> {
79 bundle
80 .read(path)
81 .or_else(|| sources.iter().find_map(|s| s.read(path)))
82}
83
84/// A detached reader over the server's byte sources, for code that needs raw
85/// bytes away from the ECS: the bundles, then every registered
86/// [`AssetSource`], then the filesystem. Kind-agnostic - no decoder runs and
87/// no cache is touched - so any plugin or runtime module that loads its own
88/// data (media files, archives, anything it ships with the app) reads
89/// through the same chain the asset pipeline uses instead of the raw
90/// filesystem.
91///
92/// Get one from [`crate::AssetServer::source_reader`]. The reader is a cheap
93/// snapshot: `Send + Sync`, safe to move to another thread, and free to
94/// block there (an archive read is a copy, a filesystem read is I/O). It
95/// does not follow later registrations - a source or bundle registered after
96/// the snapshot is invisible to it - so take a fresh reader per request
97/// rather than holding one for the app's lifetime.
98///
99/// The reader resolves the path it is given. Callers keep the same
100/// conventions the rest of the pipeline uses: resolve app-relative paths
101/// first (`lumen_core::app_paths::resolve`), and pass `lumen://app/...`
102/// URIs through untouched - the bundle source claims those itself.
103#[derive(Clone)]
104pub struct SourceReader {
105 bundle: BundleSource,
106 sources: Vec<Arc<dyn AssetSource>>,
107}
108
109impl SourceReader {
110 pub(crate) fn new(bundle: BundleSource, sources: Vec<Arc<dyn AssetSource>>) -> Self {
111 Self { bundle, sources }
112 }
113
114 /// The bytes for `path`: the first source that claims it, else the
115 /// filesystem. A path nothing holds is the filesystem's `NotFound`
116 /// error, so a missing bundled entry and a missing file report the same
117 /// way.
118 pub fn read(&self, path: &Path) -> std::io::Result<Vec<u8>> {
119 match read_chain(&self.bundle, &self.sources, path) {
120 Some(bytes) => Ok(bytes),
121 None => std::fs::read(path),
122 }
123 }
124}
125
126impl AssetSource for BundleSource {
127 fn read(&self, path: &Path) -> Option<Vec<u8>> {
128 if self.bundles.is_empty() {
129 return None;
130 }
131 let s = path.to_str()?;
132 if s.starts_with("lumen://") {
133 return self.read_uri(s);
134 }
135 let rel = path.strip_prefix(self.root.as_ref()?).ok()?;
136 // Bundle keys are forward-slash joined at pack time, so rebuild
137 // the key the same way rather than trusting the host separator.
138 let key = rel
139 .components()
140 .map(|c| c.as_os_str().to_string_lossy())
141 .collect::<Vec<_>>()
142 .join("/");
143 self.read_key(&key)
144 }
145}