use semver::Version;
use serde::{Deserialize, Serialize};
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::{fs, process::exit, sync::mpsc::Sender};
use crate::{
bundle::Manifest,
constants,
locator::{self, LocationContext, VelopackLocator, VelopackLocatorConfig},
misc,
sources::UpdateSource,
Error,
};
pub enum ApplyWaitMode {
NoWait,
WaitCurrentProcess,
WaitPid(u32),
}
define_struct_case_insensitive! {
#[allow(non_snake_case)]
#[derive(Serialize, Debug, Clone, Default)]
pub struct VelopackAssetFeed {
pub Assets: Vec<VelopackAsset>,
}
}
impl VelopackAssetFeed {
pub fn find(&self, release_name: &str) -> Option<&VelopackAsset> {
self.Assets.iter().find(|x| x.FileName.eq_ignore_ascii_case(release_name))
}
}
define_struct_case_insensitive! {
#[allow(non_snake_case)]
#[derive(Serialize, Debug, Clone, Default)]
pub struct VelopackAsset {
pub PackageId: String,
pub Version: String,
pub Type: String,
pub FileName: String,
pub SHA1: String,
pub SHA256: String,
pub Size: u64,
pub NotesMarkdown: String,
pub NotesHtml: String,
}
}
#[allow(non_snake_case)]
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
#[serde(default)]
pub struct UpdateInfo {
pub TargetFullRelease: VelopackAsset,
pub BaseRelease: Option<VelopackAsset>,
pub DeltasToTarget: Vec<VelopackAsset>,
pub IsDowngrade: bool,
}
impl UpdateInfo {
pub(crate) fn new_full(target: VelopackAsset, is_downgrade: bool) -> UpdateInfo {
UpdateInfo {
TargetFullRelease: target,
BaseRelease: None,
DeltasToTarget: Vec::new(),
IsDowngrade: is_downgrade,
}
}
pub(crate) fn new_delta(target: VelopackAsset, base: VelopackAsset, deltas: Vec<VelopackAsset>) -> UpdateInfo {
UpdateInfo {
TargetFullRelease: target,
BaseRelease: Some(base),
DeltasToTarget: deltas,
IsDowngrade: false,
}
}
}
impl AsRef<VelopackAsset> for UpdateInfo {
fn as_ref(&self) -> &VelopackAsset {
&self.TargetFullRelease
}
}
impl AsRef<VelopackAsset> for VelopackAsset {
fn as_ref(&self) -> &VelopackAsset {
self
}
}
#[allow(non_snake_case)]
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
#[serde(default)]
pub struct UpdateOptions {
pub AllowVersionDowngrade: bool,
pub ExplicitChannel: Option<String>,
pub MaximumDeltasBeforeFallback: i32,
}
struct UpdateManagerInner {
options: UpdateOptions,
source: Box<dyn UpdateSource>,
locator: VelopackLocator,
}
#[derive(Clone)]
pub struct UpdateManager {
inner: Arc<UpdateManagerInner>,
}
pub enum UpdateCheck {
RemoteIsEmpty,
NoUpdateAvailable,
UpdateAvailable(Box<UpdateInfo>),
}
impl UpdateManager {
pub fn new<T: UpdateSource + 'static>(
source: T,
options: Option<UpdateOptions>,
locator: Option<VelopackLocatorConfig>,
) -> Result<UpdateManager, Error> {
UpdateManager::new_boxed(Box::new(source), options, locator)
}
pub fn new_boxed(
source: Box<dyn UpdateSource>,
options: Option<UpdateOptions>,
locator: Option<VelopackLocatorConfig>,
) -> Result<UpdateManager, Error> {
let locator = if let Some(config) = locator {
warn!("Using explicit locator configuration, ignoring auto-locate.");
VelopackLocator::new(&config)?
} else {
locator::auto_locate_app_manifest(LocationContext::FromCurrentExe)?
};
let mut options = options.unwrap_or_default();
if options.MaximumDeltasBeforeFallback == 0 {
options.MaximumDeltasBeforeFallback = 10;
}
Ok(UpdateManager {
inner: Arc::new(UpdateManagerInner { options, source, locator }),
})
}
fn get_practical_channel(&self) -> String {
let options_channel = self.inner.options.ExplicitChannel.as_deref();
let app_channel = self.inner.locator.get_manifest_channel();
let mut channel = options_channel.unwrap_or(&app_channel).to_string();
if channel.is_empty() {
warn!("Channel is empty, using default.");
channel = constants::DEFAULT_CHANNEL_NAME.to_owned();
}
info!(
"Chosen channel for updates: {:?} (explicit={:?}, memorized={:?})",
channel, options_channel, app_channel
);
channel
}
pub(crate) fn get_locator(&self) -> &VelopackLocator {
&self.inner.locator
}
pub fn get_current_version_as_string(&self) -> String {
self.inner.locator.get_manifest_version_full_string()
}
pub fn get_current_version(&self) -> Version {
self.inner.locator.get_manifest_version()
}
pub fn get_app_id(&self) -> String {
self.inner.locator.get_manifest_id()
}
pub fn get_is_portable(&self) -> bool {
self.inner.locator.get_is_portable()
}
pub fn get_update_pending_restart(&self) -> Option<VelopackAsset> {
let packages_dir = self.inner.locator.get_packages_dir();
if let Some((path, manifest)) = locator::find_latest_full_package(&packages_dir) {
if manifest.version > self.inner.locator.get_manifest_version() {
return Some(local_path_to_asset(&manifest, &path));
}
}
None
}
pub fn get_release_feed(&self) -> Result<VelopackAssetFeed, Error> {
let channel = self.get_practical_channel();
let staged_user_id = self.inner.locator.get_staged_user_id();
self.inner
.source
.get_release_feed(&channel, &self.inner.locator.get_manifest(), staged_user_id.as_str())
}
pub fn check_for_updates(&self) -> Result<UpdateCheck, Error> {
let allow_downgrade = self.inner.options.AllowVersionDowngrade;
let app_channel = self.inner.locator.get_manifest_channel();
let app_version = self.inner.locator.get_manifest_version();
let feed = self.get_release_feed()?;
let assets = feed.Assets;
let practical_channel = self.get_practical_channel();
let is_non_default_channel = practical_channel != app_channel;
if assets.is_empty() {
return Ok(UpdateCheck::RemoteIsEmpty);
}
let mut latest: Option<&VelopackAsset> = None;
let mut latest_version: Version = Version::parse("0.0.0")?;
for asset in &assets {
if let Ok(sv) = Version::parse(&asset.Version) {
if asset.Type.eq_ignore_ascii_case("Full") {
debug!("Found full release: {} ({}).", asset.FileName, sv);
if latest.is_none() || (sv > latest_version) {
latest = Some(asset);
latest_version = sv;
}
}
}
}
if latest.is_none() {
return Ok(UpdateCheck::RemoteIsEmpty);
}
let remote_version = latest_version;
let remote_asset = latest.unwrap();
debug!("Latest remote release: {} ({}).", remote_asset.FileName, remote_version);
if remote_version > app_version {
info!("Found newer remote release available ({} -> {}).", app_version, remote_version);
Ok(UpdateCheck::UpdateAvailable(Box::new(
self.create_delta_update_strategy(&assets, (remote_asset, remote_version)),
)))
} else if remote_version < app_version && allow_downgrade {
info!(
"Found older remote release available and downgrade is enabled ({} -> {}).",
app_version, remote_version
);
Ok(UpdateCheck::UpdateAvailable(Box::new(UpdateInfo::new_full(remote_asset.clone(), true))))
} else if remote_version == app_version && allow_downgrade && is_non_default_channel {
info!(
"Latest remote release is the same version of a different channel, and downgrade is enabled ({} -> {}, {} -> {}).",
app_version, remote_version, app_channel, practical_channel
);
Ok(UpdateCheck::UpdateAvailable(Box::new(UpdateInfo::new_full(remote_asset.clone(), true))))
} else {
Ok(UpdateCheck::NoUpdateAvailable)
}
}
fn create_delta_update_strategy(&self, velopack_asset_feed: &[VelopackAsset], latest_remote: (&VelopackAsset, Version)) -> UpdateInfo {
let packages_dir = self.inner.locator.get_packages_dir();
let latest_local = locator::find_latest_full_package(&packages_dir);
if latest_local.is_none() {
info!("There is no local/base package available for this update, so delta updates will be disabled.");
return UpdateInfo::new_full(latest_remote.0.clone(), false);
}
let (latest_local_path, latest_local_manifest) = latest_local.unwrap();
let local_asset = local_path_to_asset(&latest_local_manifest, &latest_local_path);
let assets_and_versions: Vec<(&VelopackAsset, Version)> = velopack_asset_feed
.iter()
.filter_map(|asset| Version::parse(&asset.Version).ok().map(|ver| (asset, ver)))
.collect();
let matching_latest_delta = assets_and_versions
.iter()
.find(|(asset, version)| asset.Type.eq_ignore_ascii_case("Delta") && version == &latest_remote.1);
if matching_latest_delta.is_none() {
info!(
"No matching delta update found for release {}, so deltas will be disabled.",
latest_remote.1
);
return UpdateInfo::new_full(latest_remote.0.clone(), false);
}
let mut remotes_greater_than_local = assets_and_versions
.iter()
.filter(|(asset, _version)| asset.Type.eq_ignore_ascii_case("Delta"))
.filter(|(_asset, version)| version > &latest_local_manifest.version && version <= &latest_remote.1)
.collect::<Vec<_>>();
remotes_greater_than_local.sort_by(|a, b| a.1.cmp(&b.1));
let remotes_greater_than_local = remotes_greater_than_local.iter().map(|obj| obj.0.clone()).collect::<Vec<VelopackAsset>>();
info!(
"Found {} delta updates between {} and {}.",
remotes_greater_than_local.len(),
latest_local_manifest.version,
latest_remote.1
);
UpdateInfo::new_delta(latest_remote.0.clone(), local_asset, remotes_greater_than_local)
}
pub fn download_updates(&self, update: &UpdateInfo, progress: Option<Sender<i16>>) -> Result<(), Error> {
let _mutex = &self.inner.locator.try_get_exclusive_lock()?;
let name = &update.TargetFullRelease.FileName;
let packages_dir = &self.inner.locator.get_packages_dir();
fs::create_dir_all(packages_dir)?;
let final_target_file = packages_dir.join(name);
let partial_file = final_target_file.with_extension("partial");
if final_target_file.exists() {
info!("Package already exists on disk, skipping download: '{:?}'", final_target_file);
return Ok(());
}
let old_nupkg_pattern = format!("{}/*.nupkg", packages_dir.to_string_lossy());
let old_partial_pattern = format!("{}/*.partial", packages_dir.to_string_lossy());
let delta_pattern = format!("{}/*-delta.nupkg", packages_dir.to_string_lossy());
let mut to_delete = Vec::new();
fn find_files_to_delete(pattern: &str, to_delete: &mut Vec<PathBuf>) {
match glob::glob(pattern) {
Ok(paths) => {
for path in paths.into_iter().flatten() {
to_delete.push(path);
}
}
Err(e) => {
error!("Error while searching for packages to clean: {}", e);
}
}
}
find_files_to_delete(&old_nupkg_pattern, &mut to_delete);
find_files_to_delete(&old_partial_pattern, &mut to_delete);
if update.BaseRelease.is_some() && !update.DeltasToTarget.is_empty() {
info!("Beginning delta update process.");
if self.download_and_apply_delta_updates(update, &partial_file, progress.clone()).is_err() {
info!("Falling back to full update...");
self.inner
.source
.download_release_entry(&update.TargetFullRelease, &partial_file, progress)?;
self.verify_package_checksum(&partial_file, &update.TargetFullRelease)?;
info!("Successfully downloaded file: '{:?}'", partial_file);
}
} else {
self.inner
.source
.download_release_entry(&update.TargetFullRelease, &partial_file, progress)?;
self.verify_package_checksum(&partial_file, &update.TargetFullRelease)?;
info!("Successfully downloaded file: '{:?}'", partial_file);
}
info!("Renaming partial file to final target: '{:?}'", final_target_file);
fs::rename(&partial_file, &final_target_file)?;
find_files_to_delete(&delta_pattern, &mut to_delete);
#[cfg(target_os = "windows")]
match crate::bundle::load_bundle_from_file(&final_target_file) {
Ok(bundle) => {
info!("Bundle loaded successfully.");
let update_exe_path = self.inner.locator.get_update_path();
if let Err(e) = bundle.extract_zip_predicate_to_path(|f| f.ends_with("Squirrel.exe"), update_exe_path) {
error!("Error extracting Update.exe from bundle: {}", e);
}
}
Err(e) => {
error!("Error loading bundle: {}", e);
}
}
for path in to_delete {
info!("Deleting up old package: '{:?}'", path);
let _ = fs::remove_file(&path);
}
Ok(())
}
fn download_and_apply_delta_updates(&self, update: &UpdateInfo, output_file: &PathBuf, progress: Option<Sender<i16>>) -> Result<(), Error> {
let packages_dir = self.inner.locator.get_packages_dir();
let base_release_path = packages_dir.join(&update.BaseRelease.as_ref().unwrap().FileName);
let mut args: Vec<OsString> = vec![
"patch".into(),
"--old".into(),
base_release_path.clone().into(),
"--output".into(),
output_file.clone().into(),
];
for (i, delta) in update.DeltasToTarget.iter().enumerate() {
let delta_file = packages_dir.join(&delta.FileName);
let partial_file = delta_file.with_extension("partial");
info!("Downloading delta package: '{}'", &delta.FileName);
self.inner.source.download_release_entry(delta, &partial_file, None)?;
self.verify_package_checksum(&partial_file, delta)?;
fs::rename(&partial_file, &delta_file)?;
debug!("Successfully downloaded file: '{}'", &delta.FileName);
if let Some(progress) = &progress {
let _ = progress.send(((i as f64 / update.DeltasToTarget.len() as f64) * 70.0) as i16);
}
args.push("--delta".into());
args.push(delta_file.into());
}
info!("Applying {} patches to {:?}.", update.DeltasToTarget.len(), output_file);
if let Some(progress) = &progress {
let _ = progress.send(70);
}
let output = std::process::Command::new(self.inner.locator.get_update_path()).args(args).output()?;
if output.status.success() {
info!("Successfully applied delta updates.");
} else {
let error_message = String::from_utf8_lossy(&output.stderr);
error!("Error applying delta updates: {}", error_message);
return Err(Error::Io(std::io::Error::other("Process exited with non-zero status")));
}
if let Some(progress) = &progress {
let _ = progress.send(100);
}
Ok(())
}
fn verify_package_checksum(&self, file: &Path, asset: &VelopackAsset) -> Result<(), Error> {
let file_size = file.metadata()?.len();
if file_size != asset.Size {
error!("File size mismatch for file '{:?}': expected {}, got {}", file, asset.Size, file_size);
return Err(Error::SizeInvalid(file.to_path_buf(), asset.Size, file_size));
}
let (sha1, sha256) = misc::calculate_sha1_sha256(file)?;
if !asset.SHA256.is_empty() {
if !sha256.eq_ignore_ascii_case(&asset.SHA256) {
error!(
"SHA256 checksum mismatch for file '{:?}': expected '{}', got '{}'",
file, asset.SHA256, sha256
);
return Err(Error::ChecksumInvalid(file.to_path_buf(), asset.SHA256.clone(), sha256));
}
} else if !sha1.eq_ignore_ascii_case(&asset.SHA1) {
error!("SHA1 checksum mismatch for file '{:?}': expected '{}', got '{}'", file, asset.SHA1, sha1);
return Err(Error::ChecksumInvalid(file.to_path_buf(), asset.SHA1.clone(), sha1));
}
Ok(())
}
pub fn apply_updates_and_restart<A>(&self, to_apply: A) -> Result<(), Error>
where
A: AsRef<VelopackAsset>,
{
self.wait_exit_then_apply_updates(to_apply, false, true, Vec::<String>::new())?;
exit(0);
}
pub fn apply_updates_and_restart_with_args<A, C, S>(&self, to_apply: A, restart_args: C) -> Result<(), Error>
where
A: AsRef<VelopackAsset>,
S: AsRef<OsStr>,
C: IntoIterator<Item = S>,
{
self.wait_exit_then_apply_updates(to_apply, false, true, restart_args)?;
exit(0);
}
pub fn apply_updates_and_exit<A>(&self, to_apply: A) -> Result<(), Error>
where
A: AsRef<VelopackAsset>,
{
self.wait_exit_then_apply_updates(to_apply, false, false, Vec::<String>::new())?;
exit(0);
}
pub fn wait_exit_then_apply_updates<A, C, S>(&self, to_apply: A, silent: bool, restart: bool, restart_args: C) -> Result<(), Error>
where
A: AsRef<VelopackAsset>,
S: AsRef<OsStr>,
C: IntoIterator<Item = S>,
{
self.unsafe_apply_updates(to_apply, silent, ApplyWaitMode::WaitCurrentProcess, restart, restart_args)?;
Ok(())
}
pub fn unsafe_apply_updates<A, C, S>(
&self,
to_apply: A,
silent: bool,
wait_mode: ApplyWaitMode,
restart: bool,
restart_args: C,
) -> Result<(), Error>
where
A: AsRef<VelopackAsset>,
S: AsRef<OsStr>,
C: IntoIterator<Item = S>,
{
let to_apply = to_apply.as_ref();
let pkg_path = self.inner.locator.get_packages_dir().join(&to_apply.FileName);
if !pkg_path.exists() {
error!("Package does not exist on disk: '{:?}'", &pkg_path);
return Err(Error::FileNotFound(pkg_path));
}
let mut args: Vec<OsString> = Vec::new();
args.push("apply".into());
args.push("--package".into());
args.push(pkg_path.into());
match wait_mode {
ApplyWaitMode::NoWait => {}
ApplyWaitMode::WaitCurrentProcess => {
args.push("--waitPid".into());
args.push(format!("{}", std::process::id()).into());
}
ApplyWaitMode::WaitPid(pid) => {
args.push("--waitPid".into());
args.push(format!("{}", pid).into());
}
}
if silent {
args.push("--silent".into());
}
if !restart {
args.push("--norestart".into());
}
args.push("--root".into());
args.push(self.inner.locator.get_root_dir().into());
let restart_args: Vec<OsString> = restart_args.into_iter().map(|item| item.as_ref().to_os_string()).collect();
if !restart_args.is_empty() {
args.push("--".into());
for arg in restart_args {
args.push(arg);
}
}
let update_path = self.inner.locator.get_update_path();
crate::process::run_process(&update_path, args, update_path.parent(), false, None)?;
Ok(())
}
}
pub(crate) fn local_path_to_asset(manifest: &Manifest, path: &Path) -> VelopackAsset {
VelopackAsset {
PackageId: manifest.id.clone(),
Version: manifest.version.to_string(),
Type: "Full".to_string(),
FileName: path.file_name().unwrap().to_string_lossy().to_string(),
SHA1: String::new(),
SHA256: String::new(),
Size: path.metadata().map(|m| m.len()).unwrap_or(0),
NotesMarkdown: manifest.release_notes.clone(),
NotesHtml: manifest.release_notes_html.clone(),
}
}