1#[cfg(not(target_arch = "wasm32"))]
4use std::path::{Component, Path, PathBuf};
5use std::sync::Arc;
6
7use crate::registry::ServiceRegistry;
8
9#[derive(Clone, Debug, thiserror::Error)]
11pub enum BundledAssetError {
12 #[error("bundled asset `{0}` was not found")]
14 NotFound(String),
15 #[error("could not read bundled asset `{path}`: {message}")]
17 ReadFailed {
18 path: String,
20 message: String,
22 },
23 #[error("invalid bundled asset path `{0}`")]
25 InvalidPath(String),
26 #[error("could not install bundled assets at {path}: {message}")]
28 InstallFailed {
29 path: String,
31 message: String,
33 },
34}
35
36#[cfg(not(target_arch = "wasm32"))]
38#[derive(Clone, Debug, PartialEq, Eq, Hash)]
39pub struct BundledAssetEntry {
40 pub source: PathBuf,
42 pub destination: PathBuf,
44}
45
46#[cfg(not(target_arch = "wasm32"))]
47impl BundledAssetEntry {
48 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 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#[cfg(not(target_arch = "wasm32"))]
68#[derive(Clone, Debug, PartialEq, Eq, Hash)]
69pub struct BundledAssetInstallSpec {
70 pub version: String,
72 pub source_root: PathBuf,
74 pub destination: PathBuf,
76 pub entries: Vec<BundledAssetEntry>,
78}
79
80#[cfg(not(target_arch = "wasm32"))]
81impl BundledAssetInstallSpec {
82 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 pub fn source_root(mut self, source_root: impl Into<PathBuf>) -> Self {
94 self.source_root = source_root.into();
95 self
96 }
97
98 pub fn entry(mut self, entry: BundledAssetEntry) -> Self {
100 self.entries.push(entry);
101 self
102 }
103}
104
105#[cfg(not(target_arch = "wasm32"))]
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108pub enum BundledAssetInstallOutcome {
109 Unavailable,
111 Current,
113 Installed,
115}
116
117pub trait BundledAssets: Send + Sync {
127 fn read(&self, path: &str) -> Result<Vec<u8>, BundledAssetError>;
132
133 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 fn len(&self, path: &str) -> Option<u64> {
148 let _ = path;
149 None
150 }
151}
152
153pub trait BundledAssetReader: Send {
158 fn read_chunk(&mut self) -> Result<Option<Vec<u8>>, BundledAssetError>;
160}
161
162pub struct StreamingAssetReader<R> {
172 source: R,
173 path: String,
174 remaining: Option<u64>,
175}
176
177impl<R: std::io::Read + Send> StreamingAssetReader<R> {
178 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 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
233pub type BundledAssetsRef = Arc<dyn BundledAssets>;
235
236static PLATFORM_BUNDLED_ASSETS: ServiceRegistry<dyn BundledAssets> = ServiceRegistry::new();
237
238pub fn set_platform_bundled_assets(assets: BundledAssetsRef) {
240 PLATFORM_BUNDLED_ASSETS.set(assets);
241}
242
243pub fn clear_platform_bundled_assets() {
245 PLATFORM_BUNDLED_ASSETS.clear();
246}
247
248pub fn bundled_assets() -> Option<BundledAssetsRef> {
250 PLATFORM_BUNDLED_ASSETS.get()
251}
252
253#[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;