Add delta to rust updatemanager

This commit is contained in:
Caelan Sayler
2025-05-14 08:32:16 +01:00
committed by Caelan
parent 6b08ff0a21
commit 96a191ed1b
5 changed files with 192 additions and 42 deletions

View File

@@ -69,6 +69,9 @@ namespace Velopack
/// <summary> If true, UpdateManager should return the latest asset in the feed, even if that version is lower than the current version. </summary>
protected bool ShouldAllowVersionDowngrade { get; }
/// <summary> Sets the maximum number of deltas to consider before falling back to a full update. </summary>
protected int MaximumDeltasBeforeFallback { get; }
/// <summary>
/// Creates a new UpdateManager instance using the specified URL or file path to the releases feed, and the specified channel name.
/// </summary>
@@ -96,6 +99,7 @@ namespace Velopack
Log = Locator.Log;
Channel = options?.ExplicitChannel ?? DefaultChannel;
ShouldAllowVersionDowngrade = options?.AllowVersionDowngrade ?? false;
MaximumDeltasBeforeFallback = options?.MaximumDeltasBeforeFallback ?? 10;
}
/// <inheritdoc cref="CheckForUpdatesAsync()"/>
@@ -185,9 +189,9 @@ namespace Velopack
}
/// <inheritdoc cref="DownloadUpdatesAsync(UpdateInfo, Action{int}, bool, CancellationToken)"/>
public void DownloadUpdates(UpdateInfo updates, Action<int>? progress = null, bool ignoreDeltas = false)
public void DownloadUpdates(UpdateInfo updates, Action<int>? progress = null)
{
DownloadUpdatesAsync(updates, progress, ignoreDeltas)
DownloadUpdatesAsync(updates, progress)
.ConfigureAwait(false).GetAwaiter().GetResult();
}
@@ -201,8 +205,7 @@ namespace Velopack
/// <param name="progress">The progress callback. Will be called with values from 0-100.</param>
/// <param name="ignoreDeltas">Whether to attempt downloading delta's or skip to full package download.</param>
/// <param name="cancelToken">An optional cancellation token if you wish to stop this operation.</param>
public virtual async Task DownloadUpdatesAsync(
UpdateInfo updates, Action<int>? progress = null, bool ignoreDeltas = false, CancellationToken cancelToken = default)
public virtual async Task DownloadUpdatesAsync(UpdateInfo updates, Action<int>? progress = null, CancellationToken cancelToken = default)
{
progress ??= (_ => { });
@@ -252,19 +255,15 @@ namespace Velopack
try {
if (updates.BaseRelease?.FileName != null && deltasCount > 0) {
if (ignoreDeltas) {
Log.Info("Ignoring delta updates (ignoreDeltas parameter)");
if (deltasCount > MaximumDeltasBeforeFallback || deltasSize > targetRelease.Size) {
Log.Info(
$"There are too many delta's ({deltasCount} > {MaximumDeltasBeforeFallback}) or the sum of their size ({deltasSize} > {targetRelease.Size}) is too large. " +
$"Only full update will be available.");
} else {
if (deltasCount > 10 || deltasSize > targetRelease.Size) {
Log.Info(
$"There are too many delta's ({deltasCount} > 10) or the sum of their size ({deltasSize} > {targetRelease.Size}) is too large. " +
$"Only full update will be available.");
} else {
await DownloadAndApplyDeltaUpdates(updates, incompleteFile, progress, cancelToken).ConfigureAwait(false);
IoUtil.MoveFile(incompleteFile, completeFile, true);
Log.Info("Delta update download complete. Package moved to: " + completeFile);
return; // success!
}
await DownloadAndApplyDeltaUpdates(updates, incompleteFile, progress, cancelToken).ConfigureAwait(false);
IoUtil.MoveFile(incompleteFile, completeFile, true);
Log.Info("Delta update download complete. Package moved to: " + completeFile);
return; // success!
}
}
} catch (Exception ex) when (!VelopackRuntimeInfo.InUnitTestRunner) {

View File

@@ -22,5 +22,11 @@
/// without having to reinstall the application.
/// </summary>
public string? ExplicitChannel { get; set; }
/// <summary>
/// Sets the maximum number of deltas to consider before falling back to a full update.
/// The default is 10. Set to a negative number to disable deltas.
/// </summary>
public int? MaximumDeltasBeforeFallback { get; set; }
}
}
}

View File

@@ -127,6 +127,8 @@ pub enum Error
FileNotFound(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Checusum did not match for {0} (expected {1}, actual {2})")]
Checksum(String, String, String),
#[error("Zip error: {0}")]
Zip(#[from] zip::result::ZipError),
#[error("Network error: {0}")]

View File

@@ -531,7 +531,7 @@ pub fn find_latest_full_package(packages_dir: &PathBuf) -> Option<(PathBuf, Mani
info!("Attempting to auto-detect package in: {}", packages_dir);
let mut package: Option<(PathBuf, Manifest)> = None;
let search_glob = format!("{}/*.nupkg", packages_dir);
let search_glob = format!("{}/*-full.nupkg", packages_dir);
if let Ok(paths) = glob::glob(search_glob.as_str()) {
for path in paths.into_iter().flatten() {
trace!("Checking package: '{}'", path.to_string_lossy());

View File

@@ -1,19 +1,20 @@
use semver::Version;
use serde::{Deserialize, Serialize};
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
use std::path::PathBuf;
use std::{
fs,
process::{exit, Command as Process},
sync::mpsc::Sender,
};
use semver::Version;
use serde::{Deserialize, Serialize};
#[cfg(feature = "async")]
use async_std::channel::Sender as AsyncSender;
#[cfg(feature = "async")]
use async_std::task::JoinHandle;
use crate::bundle::Manifest;
use crate::{
locator::{self, LocationContext, VelopackLocator, VelopackLocatorConfig},
sources::UpdateSource,
@@ -82,12 +83,26 @@ pub struct VelopackAsset {
pub struct UpdateInfo {
/// The available version that we are updating to.
pub TargetFullRelease: VelopackAsset,
/// The base release that this update is based on. This is only available if the update is a delta update.
pub BaseRelease: Option<VelopackAsset>,
/// The list of delta updates that can be applied to the base version to get to the target version.
pub DeltasToTarget: Vec<VelopackAsset>,
/// True if the update is a version downgrade or lateral move (such as when switching channels to the same version number).
/// In this case, only full updates are allowed, and any local packages on disk newer than the downloaded version will be
/// deleted.
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
@@ -119,6 +134,9 @@ pub struct UpdateOptions {
/// allows you to explicitly switch channels, for example if the user wished to switch back to the 'stable' channel
/// without having to reinstall the application.
pub ExplicitChannel: Option<String>,
/// Sets the maximum number of deltas to consider before falling back to a full update.
/// The default is 10. Set to a negative number (eg. -1) to disable deltas.
pub MaximumDeltasBeforeFallback: i32,
}
/// Provides functionality for checking for updates, downloading updates, and applying updates to the current application.
@@ -177,7 +195,11 @@ impl UpdateManager {
} else {
locator::auto_locate_app_manifest(LocationContext::FromCurrentExe)?
};
Ok(UpdateManager { options: options.unwrap_or_default(), source, locator })
let mut options = options.unwrap_or_default();
if options.MaximumDeltasBeforeFallback == 0 {
options.MaximumDeltasBeforeFallback = 10;
}
Ok(UpdateManager { options, source, locator })
}
fn get_practical_channel(&self) -> String {
@@ -220,22 +242,26 @@ impl UpdateManager {
let packages_dir = self.locator.get_packages_dir();
if let Some((path, manifest)) = locator::find_latest_full_package(&packages_dir) {
if manifest.version > self.locator.get_manifest_version() {
return Some(VelopackAsset {
PackageId: manifest.id,
Version: manifest.version.to_string(),
Type: "Full".to_string(),
FileName: path.file_name().unwrap().to_string_lossy().to_string(),
SHA1: util::calculate_file_sha1(&path).unwrap_or_default(),
SHA256: util::calculate_file_sha256(&path).unwrap_or_default(),
Size: path.metadata().map(|m| m.len()).unwrap_or(0),
NotesMarkdown: manifest.release_notes,
NotesHtml: manifest.release_notes_html,
});
return Some(self.local_manifest_to_asset(&manifest, &path));
}
}
None
}
fn local_manifest_to_asset(&self, manifest: &Manifest, path: &PathBuf) -> 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: util::calculate_file_sha1(&path).unwrap_or_default(),
SHA256: util::calculate_file_sha256(&path).unwrap_or_default(),
Size: path.metadata().map(|m| m.len()).unwrap_or(0),
NotesMarkdown: manifest.release_notes.clone(),
NotesHtml: manifest.release_notes_html.clone(),
}
}
/// Get a list of available remote releases from the package source.
pub fn get_release_feed(&self) -> Result<VelopackAssetFeed, Error> {
let channel = self.get_practical_channel();
@@ -266,9 +292,9 @@ impl UpdateManager {
return Ok(UpdateCheck::RemoteIsEmpty);
}
let mut latest: Option<VelopackAsset> = None;
let mut latest: Option<&VelopackAsset> = None;
let mut latest_version: Version = Version::parse("0.0.0")?;
for asset in assets {
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.to_string());
@@ -291,21 +317,66 @@ impl UpdateManager {
if remote_version > app_version {
info!("Found newer remote release available ({} -> {}).", app_version, remote_version);
Ok(UpdateCheck::UpdateAvailable(UpdateInfo { TargetFullRelease: remote_asset, IsDowngrade: false }))
Ok(UpdateCheck::UpdateAvailable(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(UpdateInfo { TargetFullRelease: remote_asset, IsDowngrade: true }))
Ok(UpdateCheck::UpdateAvailable(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(UpdateInfo { TargetFullRelease: remote_asset, IsDowngrade: true }))
Ok(UpdateCheck::UpdateAvailable(UpdateInfo::new_full(remote_asset.clone(), true)))
} else {
Ok(UpdateCheck::NoUpdateAvailable)
}
}
fn create_delta_update_strategy(
&self,
velopack_asset_feed: &Vec<VelopackAsset>,
latest_remote: (&VelopackAsset, Version),
) -> UpdateInfo {
let packages_dir = self.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 = self.local_manifest_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)
}
/// Checks for updates, returning None if there are none available. If there are updates available, this method will return an
/// UpdateInfo object containing the latest available release, and any delta updates that can be applied if they are available.
#[cfg(feature = "async")]
@@ -327,7 +398,7 @@ impl UpdateManager {
fs::create_dir_all(packages_dir)?;
let final_target_file = packages_dir.join(name);
let partial_file = packages_dir.join(format!("{}.partial", 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.to_string_lossy());
@@ -336,6 +407,7 @@ impl UpdateManager {
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<String>) {
@@ -355,12 +427,26 @@ impl UpdateManager {
find_files_to_delete(&old_nupkg_pattern, &mut to_delete);
find_files_to_delete(&old_partial_pattern, &mut to_delete);
self.source.download_release_entry(&update.TargetFullRelease, &partial_file.to_string_lossy(), progress)?;
info!("Successfully placed file: '{}'", partial_file.to_string_lossy());
if update.BaseRelease.is_some() && !update.DeltasToTarget.is_empty() {
info!("Beginning delta update process.");
if let Err(e) = self.download_and_apply_delta_updates(update, &partial_file, progress.clone()) {
error!("Error downloading delta updates: {}", e);
info!("Falling back to full update...");
self.source.download_release_entry(&update.TargetFullRelease, &partial_file.to_string_lossy(), progress)?;
self.verify_package_checksum(&partial_file, &update.TargetFullRelease)?;
}
} else {
self.source.download_release_entry(&update.TargetFullRelease, &partial_file.to_string_lossy(), progress)?;
self.verify_package_checksum(&partial_file, &update.TargetFullRelease)?;
}
info!("Successfully downloaded file: '{}'", partial_file.to_string_lossy());
info!("Renaming partial file to final target: '{}'", final_target_file.to_string_lossy());
fs::rename(&partial_file, &final_target_file)?;
find_files_to_delete(&delta_pattern, &mut to_delete);
// extract new Update.exe on Windows only
#[cfg(target_os = "windows")]
match crate::bundle::load_bundle_from_file(&final_target_file) {
@@ -384,6 +470,63 @@ impl UpdateManager {
Ok(())
}
fn download_and_apply_delta_updates(
&self,
update: &UpdateInfo,
target_file: &PathBuf,
progress: Option<Sender<i16>>,
) -> Result<(), Error> {
let packages_dir = self.locator.get_packages_dir();
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.source.download_release_entry(&delta, &partial_file.to_string_lossy(), 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);
}
}
let mut args: Vec<String> =
["patch", "--old", &update.BaseRelease.as_ref().unwrap().FileName, "--output"].iter().map(|s| s.to_string()).collect();
args.push(target_file.to_string_lossy().to_string());
for delta in update.DeltasToTarget.iter() {
args.push("--delta".to_string());
let path = packages_dir.join(&delta.FileName);
args.push(path.to_string_lossy().to_string());
}
info!("Applying {} patches to {}.", update.DeltasToTarget.len(), target_file.to_string_lossy());
let output = std::process::Command::new(self.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::Generic(error_message.to_string()));
}
if let Some(progress) = &progress {
let _ = progress.send(100);
}
Ok(())
}
fn verify_package_checksum(&self, file: &PathBuf, asset: &VelopackAsset) -> Result<(), Error> {
let sha1 = util::calculate_file_sha1(file)?;
if !sha1.eq_ignore_ascii_case(&asset.SHA1) {
error!("SHA1 checksum mismatch for file '{}': expected '{}', got '{}'", file.to_string_lossy(), asset.SHA1, sha1);
return Err(Error::Checksum(file.to_string_lossy().to_string(), asset.SHA1.clone(), sha1));
}
Ok(())
}
/// Downloads the specified updates to the local app packages directory. Progress is reported back to the caller via an optional Sender.
/// This function will acquire a global update lock so may fail if there is already another update operation in progress.
/// - If the update contains delta packages and the delta feature is enabled
@@ -462,7 +605,7 @@ impl UpdateManager {
}
/// This will launch the Velopack updater and optionally wait for a program to exit gracefully.
/// This method is unsafe because it does not necessarily wait for any / the correct process to exit
/// This method is unsafe because it does not necessarily wait for any / the correct process to exit
/// before applying updates. The `wait_exit_then_apply_updates` method is recommended for most use cases.
pub fn unsafe_apply_updates<A, C, S>(
&self,