use std::path::Path;
use std::path::PathBuf;
use semver::Version;
use serde::Deserialize;
use serde::Serialize;
use crate::CodePushError;
use crate::manifest::hash_bytes;
use crate::manifest::read_container;
use crate::store::ReleaseStore;
pub trait Transport {
fn fetch(&mut self, url: &str) -> Result<Vec<u8>, CodePushError>;
}
#[derive(Debug, Default)]
pub struct FileTransport;
impl Transport for FileTransport {
fn fetch(&mut self, url: &str) -> Result<Vec<u8>, CodePushError> {
let path = url.strip_prefix("file://").unwrap_or(url);
std::fs::read(path)
.map_err(|error| CodePushError::Transport(format!("could not read {path}: {error}")))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum UpdatePlan {
UpToDate,
Available(Box<UpdateOffer>),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UpdateOffer {
pub manifest: crate::manifest::BundleManifest,
pub download_url: String,
pub delta_url: Option<String>,
pub delta_base_hash: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SyncReport {
pub updated: bool,
pub version: Option<Version>,
pub used_delta: bool,
}
pub struct CodePushClient<T: Transport> {
transport: T,
base_url: String,
app: String,
channel: String,
current_version: Version,
current_build: u64,
current_container: Vec<u8>,
}
impl<T: Transport> CodePushClient<T> {
pub fn new(
transport: T,
base_url: impl Into<String>,
app: impl Into<String>,
channel: impl Into<String>,
current_version: Version,
current_build: u64,
current_container: Vec<u8>,
) -> Self {
Self {
transport,
base_url: base_url.into().trim_end_matches('/').to_owned(),
app: app.into(),
channel: channel.into(),
current_version,
current_build,
current_container,
}
}
pub fn check(&mut self) -> Result<UpdatePlan, CodePushError> {
let url = format!(
"{}/{}/{}/check?version={}&build={}",
self.base_url, self.app, self.channel, self.current_version, self.current_build
);
let body = self.transport.fetch(&url)?;
serde_json::from_slice(&body).map_err(CodePushError::Serialize)
}
pub fn sync(&mut self, staging_dir: &Path) -> Result<SyncReport, CodePushError> {
let plan = self.check()?;
let UpdatePlan::Available(offer) = plan else {
return Ok(SyncReport {
updated: false,
version: None,
used_delta: false,
});
};
let mut used_delta = false;
let container_bytes = if let (Some(delta_url), Some(base_hash)) =
(offer.delta_url.as_ref(), offer.delta_base_hash.as_ref())
{
let delta_bytes = self.transport.fetch(delta_url)?;
let delta: crate::delta::Delta = serde_json::from_slice(&delta_bytes)?;
let current_hash = hash_bytes(&self.current_container);
if delta.base_hash != *base_hash || delta.base_hash != current_hash {
return Err(CodePushError::DeltaBaseMismatch {
expected: base_hash.clone(),
actual: hash_bytes(&self.current_container),
});
}
let patched = crate::delta::apply_delta(&self.current_container, &delta)?;
used_delta = true;
patched
} else {
self.transport.fetch(&offer.download_url)?
};
let parsed = read_container(&container_bytes)?;
if parsed.manifest.bundle_hash() != offer.manifest.bundle_hash() {
return Err(CodePushError::HashMismatch {
path: "<container>".to_owned(),
expected: offer.manifest.bundle_hash(),
actual: parsed.manifest.bundle_hash(),
});
}
stage(staging_dir, &parsed)?;
self.current_container = container_bytes;
self.current_version = parsed.manifest.version.clone();
self.current_build = parsed.manifest.build;
Ok(SyncReport {
updated: true,
version: Some(parsed.manifest.version),
used_delta,
})
}
pub fn current_version(&self) -> &Version {
&self.current_version
}
}
fn stage(
staging_dir: &Path,
container: &crate::manifest::Container,
) -> Result<PathBuf, CodePushError> {
std::fs::create_dir_all(staging_dir)?;
for (path, content) in &container.files {
let target = staging_dir.join(path);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&target, content)?;
}
Ok(staging_dir.to_path_buf())
}
impl ReleaseStore {
pub fn plan_update(
&self,
app: &str,
channel: &str,
current: &Version,
current_build: u64,
) -> Result<UpdatePlan, crate::store::StoreError> {
let Some(latest) = self.latest(app, channel)? else {
return Ok(UpdatePlan::UpToDate);
};
let newer = latest.manifest.version > *current
|| (latest.manifest.version == *current && latest.manifest.build > current_build);
if !newer {
return Ok(UpdatePlan::UpToDate);
}
let records = self.list(app, channel)?;
let previous = records.get(1);
let runs_previous = previous.is_some_and(|record| {
record.manifest.version == *current && record.manifest.build == current_build
});
let delta = latest
.delta_from_previous
.as_ref()
.filter(|_| runs_previous);
Ok(UpdatePlan::Available(Box::new(UpdateOffer {
manifest: latest.manifest.clone(),
download_url: format!(
"/{app}/{channel}/download/{}/{}",
latest.manifest.version, latest.manifest.build
),
delta_url: delta.map(|_| format!("/{app}/{channel}/delta/{current}/{current_build}")),
delta_base_hash: delta.map(|delta| delta.base_hash.clone()),
})))
}
}