Skip to main content

cranpose_services/
bundled_assets.rs

1//! Read-only assets packaged with the application.
2
3#[cfg(not(target_arch = "wasm32"))]
4use std::path::{Component, Path, PathBuf};
5use std::sync::Arc;
6
7use crate::registry::ServiceRegistry;
8
9/// Error reading an asset from the application bundle.
10#[derive(Clone, Debug, thiserror::Error)]
11pub enum BundledAssetError {
12    /// The requested asset does not exist.
13    #[error("bundled asset `{0}` was not found")]
14    NotFound(String),
15    /// The platform could not read the asset.
16    #[error("could not read bundled asset `{path}`: {message}")]
17    ReadFailed {
18        /// Bundle-relative asset path.
19        path: String,
20        /// Platform error.
21        message: String,
22    },
23    /// The installation declaration contains an unsafe or empty path.
24    #[error("invalid bundled asset path `{0}`")]
25    InvalidPath(String),
26    /// Files could not be installed into application storage.
27    #[error("could not install bundled assets at {path}: {message}")]
28    InstallFailed {
29        /// Destination path being changed.
30        path: String,
31        /// Filesystem error.
32        message: String,
33    },
34}
35
36/// One bundle-relative file in a declarative asset installation.
37#[cfg(not(target_arch = "wasm32"))]
38#[derive(Clone, Debug, PartialEq, Eq, Hash)]
39pub struct BundledAssetEntry {
40    /// Path below the set's bundle source root.
41    pub source: PathBuf,
42    /// Path below the set's installation directory.
43    pub destination: PathBuf,
44}
45
46#[cfg(not(target_arch = "wasm32"))]
47impl BundledAssetEntry {
48    /// Creates an entry that keeps the same relative path at the destination.
49    pub fn new(path: impl Into<PathBuf>) -> Self {
50        let path = path.into();
51        Self {
52            source: path.clone(),
53            destination: path,
54        }
55    }
56
57    /// Creates an entry whose installed relative path differs from its bundle path.
58    pub fn mapped(source: impl Into<PathBuf>, destination: impl Into<PathBuf>) -> Self {
59        Self {
60            source: source.into(),
61            destination: destination.into(),
62        }
63    }
64}
65
66/// A versioned set copied from the application bundle into writable storage.
67#[cfg(not(target_arch = "wasm32"))]
68#[derive(Clone, Debug, PartialEq, Eq, Hash)]
69pub struct BundledAssetInstallSpec {
70    /// Version written only after every entry is installed.
71    pub version: String,
72    /// Optional bundle-relative prefix shared by every source entry.
73    pub source_root: PathBuf,
74    /// Writable directory containing the installed entries and version stamp.
75    pub destination: PathBuf,
76    /// Files belonging to this set.
77    pub entries: Vec<BundledAssetEntry>,
78}
79
80#[cfg(not(target_arch = "wasm32"))]
81impl BundledAssetInstallSpec {
82    /// Starts an installation declaration.
83    pub fn new(version: impl Into<String>, destination: impl Into<PathBuf>) -> Self {
84        Self {
85            version: version.into(),
86            source_root: PathBuf::new(),
87            destination: destination.into(),
88            entries: Vec::new(),
89        }
90    }
91
92    /// Sets the shared bundle-relative source prefix.
93    pub fn source_root(mut self, source_root: impl Into<PathBuf>) -> Self {
94        self.source_root = source_root.into();
95        self
96    }
97
98    /// Adds one file to the set.
99    pub fn entry(mut self, entry: BundledAssetEntry) -> Self {
100        self.entries.push(entry);
101        self
102    }
103}
104
105/// Result of installing a bundled asset set.
106#[cfg(not(target_arch = "wasm32"))]
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108pub enum BundledAssetInstallOutcome {
109    /// This host has no application-bundle reader.
110    Unavailable,
111    /// The requested version and every declared file were already present.
112    Current,
113    /// Files were copied and the requested version was committed.
114    Installed,
115}
116
117/// Access to files packaged in the app bundle.
118///
119/// Reads are synchronous, which is what a packaged file on a native platform
120/// is: Android's asset manager, an application bundle and a directory beside
121/// the executable all answer without waiting on a network. A browser has no
122/// such file — its resources arrive over HTTP — so the web registers no backend
123/// and [`bundled_assets`] answers `None` there. An application that ships
124/// resources to the web fetches them through [`crate::http`], which is async
125/// because that is what the medium is.
126pub trait BundledAssets: Send + Sync {
127    /// Reads one bundle-relative asset in full.
128    ///
129    /// Use [`open`](BundledAssets::open) for anything that should not be held
130    /// in memory all at once — a bundled model, a video, a database seed.
131    fn read(&self, path: &str) -> Result<Vec<u8>, BundledAssetError>;
132
133    /// Opens one bundle-relative asset for chunked reading.
134    ///
135    /// The default reads the whole asset and hands it back a chunk at a time,
136    /// which is honest for a backend that can only produce the bytes at once.
137    /// Backends with a real streaming API — Android's `AssetManager`, a file in
138    /// an application bundle — override it and never materialise the asset.
139    fn open(&self, path: &str) -> Result<Box<dyn BundledAssetReader>, BundledAssetError> {
140        Ok(Box::new(StreamingAssetReader::new(
141            path,
142            std::io::Cursor::new(self.read(path)?),
143        )))
144    }
145
146    /// The asset's byte length without reading it, when the backend knows it.
147    fn len(&self, path: &str) -> Option<u64> {
148        let _ = path;
149        None
150    }
151}
152
153/// Chunked reader over one bundled asset.
154///
155/// Synchronous and `Send` so a worker thread can drain it into a model loader
156/// or a database without involving the UI thread.
157pub trait BundledAssetReader: Send {
158    /// Reads the next chunk, or `None` at end of asset.
159    fn read_chunk(&mut self) -> Result<Option<Vec<u8>>, BundledAssetError>;
160}
161
162/// A [`BundledAssetReader`] over any byte stream.
163///
164/// Every backend that streams an asset ends at the same loop: fill a chunk,
165/// stop at the end, name the asset rather than its resolved location when the
166/// read fails. What differs is only what was opened — a file beside the
167/// executable, a file in an application bundle, a span of an Android package,
168/// or bytes already in hand — and whether the asset ends where its stream
169/// does. The loop lives here once; a backend supplies the stream and, when
170/// its asset is a span of something larger, how many bytes of it are its own.
171pub struct StreamingAssetReader<R> {
172    source: R,
173    path: String,
174    remaining: Option<u64>,
175}
176
177impl<R: std::io::Read + Send> StreamingAssetReader<R> {
178    /// A reader over a stream whose end is the asset's end.
179    pub fn new(path: impl Into<String>, source: R) -> Self {
180        Self {
181            source,
182            path: path.into(),
183            remaining: None,
184        }
185    }
186
187    /// A reader over `len` bytes of a stream that continues past the asset —
188    /// an Android package's file descriptor addresses the whole package, so
189    /// the asset's end is a count rather than end of file.
190    pub fn with_length(path: impl Into<String>, source: R, len: u64) -> Self {
191        Self {
192            source,
193            path: path.into(),
194            remaining: Some(len),
195        }
196    }
197}
198
199impl<R: std::io::Read + Send> BundledAssetReader for StreamingAssetReader<R> {
200    fn read_chunk(&mut self) -> Result<Option<Vec<u8>>, BundledAssetError> {
201        let want = match self.remaining {
202            Some(0) => return Ok(None),
203            Some(remaining) => remaining.min(crate::content::DEFAULT_CHUNK_LEN as u64) as usize,
204            None => crate::content::DEFAULT_CHUNK_LEN,
205        };
206        let mut chunk = vec![0u8; want];
207        let mut filled = 0;
208        while filled < chunk.len() {
209            match self.source.read(&mut chunk[filled..]) {
210                Ok(0) => break,
211                Ok(read) => filled += read,
212                Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
213                Err(error) => {
214                    return Err(BundledAssetError::ReadFailed {
215                        path: self.path.clone(),
216                        message: error.to_string(),
217                    });
218                }
219            }
220        }
221        if filled == 0 {
222            self.remaining = Some(0);
223            return Ok(None);
224        }
225        chunk.truncate(filled);
226        if let Some(remaining) = &mut self.remaining {
227            *remaining -= filled as u64;
228        }
229        Ok(Some(chunk))
230    }
231}
232
233/// Shared bundled-assets service.
234pub type BundledAssetsRef = Arc<dyn BundledAssets>;
235
236static PLATFORM_BUNDLED_ASSETS: ServiceRegistry<dyn BundledAssets> = ServiceRegistry::new();
237
238/// Installs a platform bundled-assets reader.
239pub fn set_platform_bundled_assets(assets: BundledAssetsRef) {
240    PLATFORM_BUNDLED_ASSETS.set(assets);
241}
242
243/// Removes the platform reader.
244pub fn clear_platform_bundled_assets() {
245    PLATFORM_BUNDLED_ASSETS.clear();
246}
247
248/// Returns the platform reader, if this host has an application bundle.
249pub fn bundled_assets() -> Option<BundledAssetsRef> {
250    PLATFORM_BUNDLED_ASSETS.get()
251}
252
253/// Installs a declarative set without exposing bundle APIs or partial-file
254/// handling to the application.
255///
256/// Each file is replaced through a sibling temporary file. The version stamp
257/// is committed last, so an interrupted run is retried on the next call and is
258/// never mistaken for a current installation.
259#[cfg(not(target_arch = "wasm32"))]
260pub fn install_bundled_asset_set(
261    spec: &BundledAssetInstallSpec,
262) -> Result<BundledAssetInstallOutcome, BundledAssetError> {
263    validate_spec(spec)?;
264    let Some(assets) = bundled_assets() else {
265        return Ok(BundledAssetInstallOutcome::Unavailable);
266    };
267    let stamp = spec.destination.join(".cranpose-assets-version");
268    let current = std::fs::read_to_string(&stamp).ok();
269    if current.as_deref() == Some(spec.version.as_str())
270        && spec
271            .entries
272            .iter()
273            .all(|entry| spec.destination.join(&entry.destination).is_file())
274    {
275        return Ok(BundledAssetInstallOutcome::Current);
276    }
277
278    std::fs::create_dir_all(&spec.destination)
279        .map_err(|error| install_error(&spec.destination, error))?;
280    for entry in &spec.entries {
281        let source = spec.source_root.join(&entry.source);
282        let source = path_for_bundle(&source)?;
283        let bytes = assets.read(&source)?;
284        let target = spec.destination.join(&entry.destination);
285        if let Some(parent) = target.parent() {
286            std::fs::create_dir_all(parent).map_err(|error| install_error(parent, error))?;
287        }
288        replace_file(&target, &bytes)?;
289    }
290    replace_file(&stamp, spec.version.as_bytes())?;
291    Ok(BundledAssetInstallOutcome::Installed)
292}
293
294#[cfg(not(target_arch = "wasm32"))]
295fn validate_spec(spec: &BundledAssetInstallSpec) -> Result<(), BundledAssetError> {
296    if spec.version.is_empty() || spec.entries.is_empty() {
297        return Err(BundledAssetError::InvalidPath(String::new()));
298    }
299    validate_relative(&spec.source_root)?;
300    for entry in &spec.entries {
301        validate_relative(&entry.source)?;
302        validate_relative(&entry.destination)?;
303    }
304    Ok(())
305}
306
307#[cfg(not(target_arch = "wasm32"))]
308fn validate_relative(path: &Path) -> Result<(), BundledAssetError> {
309    if path.as_os_str().is_empty() {
310        return Ok(());
311    }
312    if path.components().any(|component| {
313        matches!(
314            component,
315            Component::ParentDir | Component::RootDir | Component::Prefix(_)
316        )
317    }) {
318        return Err(BundledAssetError::InvalidPath(path.display().to_string()));
319    }
320    Ok(())
321}
322
323#[cfg(not(target_arch = "wasm32"))]
324fn path_for_bundle(path: &Path) -> Result<String, BundledAssetError> {
325    validate_relative(path)?;
326    let mut result = String::new();
327    for component in path.components() {
328        if matches!(component, Component::CurDir) {
329            continue;
330        }
331        if !result.is_empty() {
332            result.push('/');
333        }
334        result.push_str(&component.as_os_str().to_string_lossy());
335    }
336    if result.is_empty() {
337        return Err(BundledAssetError::InvalidPath(path.display().to_string()));
338    }
339    Ok(result)
340}
341
342#[cfg(not(target_arch = "wasm32"))]
343fn replace_file(target: &Path, bytes: &[u8]) -> Result<(), BundledAssetError> {
344    use std::io::Write;
345
346    let file_name = target
347        .file_name()
348        .and_then(|name| name.to_str())
349        .ok_or_else(|| BundledAssetError::InvalidPath(target.display().to_string()))?;
350    let temporary = target.with_file_name(format!(".{file_name}.cranpose-part"));
351    let mut output =
352        std::fs::File::create(&temporary).map_err(|error| install_error(&temporary, error))?;
353    output
354        .write_all(bytes)
355        .and_then(|()| output.sync_all())
356        .map_err(|error| install_error(&temporary, error))?;
357    if !target.exists() {
358        return std::fs::rename(&temporary, target).map_err(|error| install_error(target, error));
359    }
360
361    let backup = target.with_file_name(format!(".{file_name}.cranpose-backup"));
362    if backup.exists() {
363        std::fs::remove_file(&backup).map_err(|error| install_error(&backup, error))?;
364    }
365    std::fs::rename(target, &backup).map_err(|error| install_error(target, error))?;
366    if let Err(error) = std::fs::rename(&temporary, target) {
367        let _ = std::fs::rename(&backup, target);
368        return Err(install_error(target, error));
369    }
370    std::fs::remove_file(&backup).map_err(|error| install_error(&backup, error))
371}
372
373#[cfg(not(target_arch = "wasm32"))]
374fn install_error(path: &Path, error: std::io::Error) -> BundledAssetError {
375    BundledAssetError::InstallFailed {
376        path: path.display().to_string(),
377        message: error.to_string(),
378    }
379}
380
381#[cfg(test)]
382#[path = "tests/bundled_assets_tests.rs"]
383mod tests;