#[cfg(not(target_arch = "wasm32"))]
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use crate::registry::ServiceRegistry;
#[derive(Clone, Debug, thiserror::Error)]
pub enum BundledAssetError {
#[error("bundled asset `{0}` was not found")]
NotFound(String),
#[error("could not read bundled asset `{path}`: {message}")]
ReadFailed {
path: String,
message: String,
},
#[error("invalid bundled asset path `{0}`")]
InvalidPath(String),
#[error("could not install bundled assets at {path}: {message}")]
InstallFailed {
path: String,
message: String,
},
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct BundledAssetEntry {
pub source: PathBuf,
pub destination: PathBuf,
}
#[cfg(not(target_arch = "wasm32"))]
impl BundledAssetEntry {
pub fn new(path: impl Into<PathBuf>) -> Self {
let path = path.into();
Self {
source: path.clone(),
destination: path,
}
}
pub fn mapped(source: impl Into<PathBuf>, destination: impl Into<PathBuf>) -> Self {
Self {
source: source.into(),
destination: destination.into(),
}
}
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct BundledAssetInstallSpec {
pub version: String,
pub source_root: PathBuf,
pub destination: PathBuf,
pub entries: Vec<BundledAssetEntry>,
}
#[cfg(not(target_arch = "wasm32"))]
impl BundledAssetInstallSpec {
pub fn new(version: impl Into<String>, destination: impl Into<PathBuf>) -> Self {
Self {
version: version.into(),
source_root: PathBuf::new(),
destination: destination.into(),
entries: Vec::new(),
}
}
pub fn source_root(mut self, source_root: impl Into<PathBuf>) -> Self {
self.source_root = source_root.into();
self
}
pub fn entry(mut self, entry: BundledAssetEntry) -> Self {
self.entries.push(entry);
self
}
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BundledAssetInstallOutcome {
Unavailable,
Current,
Installed,
}
pub trait BundledAssets: Send + Sync {
fn read(&self, path: &str) -> Result<Vec<u8>, BundledAssetError>;
fn open(&self, path: &str) -> Result<Box<dyn BundledAssetReader>, BundledAssetError> {
Ok(Box::new(StreamingAssetReader::new(
path,
std::io::Cursor::new(self.read(path)?),
)))
}
fn len(&self, path: &str) -> Option<u64> {
let _ = path;
None
}
}
pub trait BundledAssetReader: Send {
fn read_chunk(&mut self) -> Result<Option<Vec<u8>>, BundledAssetError>;
}
pub struct StreamingAssetReader<R> {
source: R,
path: String,
remaining: Option<u64>,
}
impl<R: std::io::Read + Send> StreamingAssetReader<R> {
pub fn new(path: impl Into<String>, source: R) -> Self {
Self {
source,
path: path.into(),
remaining: None,
}
}
pub fn with_length(path: impl Into<String>, source: R, len: u64) -> Self {
Self {
source,
path: path.into(),
remaining: Some(len),
}
}
}
impl<R: std::io::Read + Send> BundledAssetReader for StreamingAssetReader<R> {
fn read_chunk(&mut self) -> Result<Option<Vec<u8>>, BundledAssetError> {
let want = match self.remaining {
Some(0) => return Ok(None),
Some(remaining) => remaining.min(crate::content::DEFAULT_CHUNK_LEN as u64) as usize,
None => crate::content::DEFAULT_CHUNK_LEN,
};
let mut chunk = vec![0u8; want];
let mut filled = 0;
while filled < chunk.len() {
match self.source.read(&mut chunk[filled..]) {
Ok(0) => break,
Ok(read) => filled += read,
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
Err(error) => {
return Err(BundledAssetError::ReadFailed {
path: self.path.clone(),
message: error.to_string(),
});
}
}
}
if filled == 0 {
self.remaining = Some(0);
return Ok(None);
}
chunk.truncate(filled);
if let Some(remaining) = &mut self.remaining {
*remaining -= filled as u64;
}
Ok(Some(chunk))
}
}
pub type BundledAssetsRef = Arc<dyn BundledAssets>;
static PLATFORM_BUNDLED_ASSETS: ServiceRegistry<dyn BundledAssets> = ServiceRegistry::new();
pub fn set_platform_bundled_assets(assets: BundledAssetsRef) {
PLATFORM_BUNDLED_ASSETS.set(assets);
}
pub fn clear_platform_bundled_assets() {
PLATFORM_BUNDLED_ASSETS.clear();
}
pub fn bundled_assets() -> Option<BundledAssetsRef> {
PLATFORM_BUNDLED_ASSETS.get()
}
#[cfg(not(target_arch = "wasm32"))]
pub fn install_bundled_asset_set(
spec: &BundledAssetInstallSpec,
) -> Result<BundledAssetInstallOutcome, BundledAssetError> {
validate_spec(spec)?;
let Some(assets) = bundled_assets() else {
return Ok(BundledAssetInstallOutcome::Unavailable);
};
let stamp = spec.destination.join(".cranpose-assets-version");
let current = std::fs::read_to_string(&stamp).ok();
if current.as_deref() == Some(spec.version.as_str())
&& spec
.entries
.iter()
.all(|entry| spec.destination.join(&entry.destination).is_file())
{
return Ok(BundledAssetInstallOutcome::Current);
}
std::fs::create_dir_all(&spec.destination)
.map_err(|error| install_error(&spec.destination, error))?;
for entry in &spec.entries {
let source = spec.source_root.join(&entry.source);
let source = path_for_bundle(&source)?;
let bytes = assets.read(&source)?;
let target = spec.destination.join(&entry.destination);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(|error| install_error(parent, error))?;
}
replace_file(&target, &bytes)?;
}
replace_file(&stamp, spec.version.as_bytes())?;
Ok(BundledAssetInstallOutcome::Installed)
}
#[cfg(not(target_arch = "wasm32"))]
fn validate_spec(spec: &BundledAssetInstallSpec) -> Result<(), BundledAssetError> {
if spec.version.is_empty() || spec.entries.is_empty() {
return Err(BundledAssetError::InvalidPath(String::new()));
}
validate_relative(&spec.source_root)?;
for entry in &spec.entries {
validate_relative(&entry.source)?;
validate_relative(&entry.destination)?;
}
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn validate_relative(path: &Path) -> Result<(), BundledAssetError> {
if path.as_os_str().is_empty() {
return Ok(());
}
if path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
}) {
return Err(BundledAssetError::InvalidPath(path.display().to_string()));
}
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn path_for_bundle(path: &Path) -> Result<String, BundledAssetError> {
validate_relative(path)?;
let mut result = String::new();
for component in path.components() {
if matches!(component, Component::CurDir) {
continue;
}
if !result.is_empty() {
result.push('/');
}
result.push_str(&component.as_os_str().to_string_lossy());
}
if result.is_empty() {
return Err(BundledAssetError::InvalidPath(path.display().to_string()));
}
Ok(result)
}
#[cfg(not(target_arch = "wasm32"))]
fn replace_file(target: &Path, bytes: &[u8]) -> Result<(), BundledAssetError> {
use std::io::Write;
let file_name = target
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| BundledAssetError::InvalidPath(target.display().to_string()))?;
let temporary = target.with_file_name(format!(".{file_name}.cranpose-part"));
let mut output =
std::fs::File::create(&temporary).map_err(|error| install_error(&temporary, error))?;
output
.write_all(bytes)
.and_then(|()| output.sync_all())
.map_err(|error| install_error(&temporary, error))?;
if !target.exists() {
return std::fs::rename(&temporary, target).map_err(|error| install_error(target, error));
}
let backup = target.with_file_name(format!(".{file_name}.cranpose-backup"));
if backup.exists() {
std::fs::remove_file(&backup).map_err(|error| install_error(&backup, error))?;
}
std::fs::rename(target, &backup).map_err(|error| install_error(target, error))?;
if let Err(error) = std::fs::rename(&temporary, target) {
let _ = std::fs::rename(&backup, target);
return Err(install_error(target, error));
}
std::fs::remove_file(&backup).map_err(|error| install_error(&backup, error))
}
#[cfg(not(target_arch = "wasm32"))]
fn install_error(path: &Path, error: std::io::Error) -> BundledAssetError {
BundledAssetError::InstallFailed {
path: path.display().to_string(),
message: error.to_string(),
}
}
#[cfg(test)]
#[path = "tests/bundled_assets_tests.rs"]
mod tests;