Skip to main content

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}