using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NuGet.Versioning;
using Velopack.Compression;
using Velopack.Locators;
using Velopack.NuGet;
using Velopack.Sources;
namespace Velopack
{
///
/// Provides functionality for checking for updates, downloading updates, and applying updates to the current application.
///
public partial class UpdateManager
{
/// The currently installed application Id. This would be what you set when you create your release.
public virtual string? AppId => Locator.AppId;
/// True if this application is currently installed, and is able to download/check for updates.
public virtual bool IsInstalled => Locator.CurrentlyInstalledVersion != null;
///
public virtual bool IsPortable => Locator.IsPortable;
/// True if there is a local update prepared that requires a call to to be applied.
public virtual bool IsUpdatePendingRestart {
get {
var latestLocal = Locator.GetLatestLocalFullPackage();
if (latestLocal != null && CurrentVersion != null && latestLocal.Version > CurrentVersion)
return true;
return false;
}
}
/// The currently installed app version when you created your release. Null if this is not a currently installed app.
public virtual SemanticVersion? CurrentVersion => Locator.CurrentlyInstalledVersion;
/// The update source to use when checking for/downloading updates.
protected IUpdateSource Source { get; }
/// The logger to use for diagnostic messages.
protected ILogger Log { get; }
/// The locator to use when searching for local file paths.
protected IVelopackLocator Locator { get; }
/// The channel to use when searching for packages.
protected string Channel { get; }
/// The default channel to search for packages in, if one was not provided by the user.
protected string DefaultChannel => Locator?.Channel ?? VelopackRuntimeInfo.SystemOs.GetOsShortName();
/// If true, an explicit channel was provided by the user, and it's different than the default channel.
protected bool IsNonDefaultChannel => Locator?.Channel != null && Channel != DefaultChannel;
/// If true, UpdateManager should return the latest asset in the feed, even if that version is lower than the current version.
protected bool ShouldAllowVersionDowngrade { get; }
///
/// Creates a new UpdateManager instance using the specified URL or file path to the releases feed, and the specified channel name.
///
/// A basic URL or file path to use when checking for updates.
/// Override / configure default update behaviors.
/// The logger to use for diagnostic messages. If one was provided to but is null here,
/// it will be cached and used again.
/// This should usually be left null. Providing an allows you to mock up certain application paths.
/// For example, if you wanted to test that updates are working in a unit test, you could provide an instance of .
public UpdateManager(string urlOrPath, UpdateOptions? options = null, ILogger? logger = null, IVelopackLocator? locator = null)
: this(CreateSimpleSource(urlOrPath), options, logger, locator)
{
}
///
/// Creates a new UpdateManager instance using the specified URL or file path to the releases feed, and the specified channel name.
///
/// The source describing where to search for updates. This can be a custom source, if you are integrating with some private resource,
/// or it could be one of the predefined sources. (eg. or , etc).
/// Override / configure default update behaviors.
/// The logger to use for diagnostic messages. If one was provided to but is null here,
/// it will be cached and used again.
/// This should usually be left null. Providing an allows you to mock up certain application paths.
/// For example, if you wanted to test that updates are working in a unit test, you could provide an instance of .
public UpdateManager(IUpdateSource source, UpdateOptions? options = null, ILogger? logger = null, IVelopackLocator? locator = null)
{
if (source == null) {
throw new ArgumentNullException(nameof(source));
}
Source = source;
Log = logger ?? VelopackApp.DefaultLogger ?? NullLogger.Instance;
Locator = locator ?? VelopackLocator.GetDefault(Log);
Channel = options?.ExplicitChannel ?? DefaultChannel;
ShouldAllowVersionDowngrade = options?.AllowVersionDowngrade ?? false;
}
///
public UpdateInfo? CheckForUpdates()
{
return CheckForUpdatesAsync()
.ConfigureAwait(false).GetAwaiter().GetResult();
}
///
/// Checks for updates, returning null 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.
///
/// Null if no updates, otherwise containing the version of the latest update available.
public virtual async Task CheckForUpdatesAsync()
{
EnsureInstalled();
var installedVer = CurrentVersion!;
var betaId = Locator.GetOrCreateStagedUserId();
var latestLocalFull = Locator.GetLatestLocalFullPackage();
Log.Debug("Retrieving latest release feed.");
var feedObj = await Source.GetReleaseFeed(Log, Channel, betaId, latestLocalFull).ConfigureAwait(false);
var feed = feedObj.Assets;
var latestRemoteFull = feed.Where(r => r.Type == VelopackAssetType.Full).MaxBy(x => x.Version).FirstOrDefault();
if (latestRemoteFull == null) {
Log.Info("No remote full releases found.");
return null;
}
// there's a newer version available, easy.
if (latestRemoteFull.Version > installedVer) {
Log.Info($"Found newer remote release available ({installedVer} -> {latestRemoteFull.Version}).");
return CreateDeltaUpdateStrategy(feed, latestLocalFull, latestRemoteFull);
}
// if the remote version is < than current version and downgrade is enabled
if (latestRemoteFull.Version < installedVer && ShouldAllowVersionDowngrade) {
Log.Info($"Latest remote release is older than current, and downgrade is enabled ({installedVer} -> {latestRemoteFull.Version}).");
return new UpdateInfo(latestRemoteFull, true);
}
// if the remote version is the same as current version, and downgrade is enabled,
// and we're searching for a different channel than current
if (ShouldAllowVersionDowngrade && IsNonDefaultChannel) {
if (VersionComparer.Compare(latestRemoteFull.Version, installedVer, VersionComparison.Version) == 0) {
Log.Info($"Latest remote release is the same version of a different channel, and downgrade is enabled ({installedVer}: {DefaultChannel} -> {Channel}).");
return new UpdateInfo(latestRemoteFull, true);
}
}
Log.Info($"No updates, remote version ({latestRemoteFull.Version}) is not newer than current version ({installedVer}) and / or downgrade is not enabled.");
return null;
}
///
/// Given a feed of releases, and the latest local full release, and the latest remote full release, this method will return a delta
/// update strategy to be used by .
///
protected virtual UpdateInfo CreateDeltaUpdateStrategy(VelopackAsset[] feed, VelopackAsset? latestLocalFull, VelopackAsset latestRemoteFull)
{
if (latestLocalFull == null) {
// TODO: for now, we're not trying to handle the case of building delta updates on top of an installation directory,
// but we can look at this in the future. Until then, Windows (installer) is the only thing which ships with a complete .nupkg
// so in all other cases, Velopack needs to download one full release before it can start using delta's.
Log.Info("There is no local/base package available for this update, so delta updates will be disabled.");
return new UpdateInfo(latestRemoteFull, false);
}
EnsureInstalled();
var installedVer = CurrentVersion!;
var matchingRemoteDelta = feed.Where(r => r.Type == VelopackAssetType.Delta && r.Version == latestRemoteFull.Version).FirstOrDefault();
if (matchingRemoteDelta == null) {
Log.Info($"Unable to find any delta matching version {latestRemoteFull.Version}, so delta updates will be disabled.");
return new UpdateInfo(latestRemoteFull, false);
}
// if we have a local full release, we try to apply delta's from that version to target version.
SemanticVersion deltaFromVer = latestLocalFull.Version;
var deltas = feed.Where(r => r.Type == VelopackAssetType.Delta && r.Version > deltaFromVer && r.Version <= latestRemoteFull.Version).ToArray();
Log.Debug($"Found {deltas.Length} delta release(s) between {deltaFromVer} and {latestRemoteFull.Version}.");
return new UpdateInfo(latestRemoteFull, false, latestLocalFull, deltas);
}
///
public void DownloadUpdates(UpdateInfo updates, Action? progress = null, bool ignoreDeltas = false)
{
DownloadUpdatesAsync(updates, progress, ignoreDeltas)
.ConfigureAwait(false).GetAwaiter().GetResult();
}
///
/// Downloads the specified updates to the local app packages directory. If the update contains delta packages and ignoreDeltas=false,
/// this method will attempt to unpack and prepare them. If there is no delta update available, or there is an error preparing delta
/// packages, this method will fall back to downloading the full version of the update. This function will acquire a global update lock
/// so may fail if there is already another update operation in progress.
///
/// The updates to download. Should be retrieved from .
/// The progress callback. Will be called with values from 0-100.
/// Whether to attempt downloading delta's or skip to full package download.
/// An optional cancellation token if you wish to stop this operation.
public virtual async Task DownloadUpdatesAsync(
UpdateInfo updates, Action? progress = null, bool ignoreDeltas = false, CancellationToken cancelToken = default)
{
progress ??= (_ => { });
// the progress delegate may very likely invoke into the client main thread for UI updates, so
// let's try to reduce the spam. report only on even numbers and only if the progress has changed.
int lastProgress = 0;
void reportProgress(int x)
{
int result = (int) (Math.Round(x / 2d, MidpointRounding.AwayFromZero) * 2d);
if (result != lastProgress) {
lastProgress = result;
progress(result);
}
}
if (updates == null) {
throw new ArgumentNullException(nameof(updates));
}
var targetRelease = updates.TargetFullRelease;
if (targetRelease == null) {
throw new ArgumentException("Must pass a valid UpdateInfo object with a non-null TargetFullRelease", nameof(updates));
}
EnsureInstalled();
using var _mut = AcquireUpdateLock();
var appTempDir = Locator.AppTempDir!;
var appPackageDir = Locator.PackagesDir!;
var completeFile = Path.Combine(appPackageDir, Path.GetFileName(targetRelease.FileName));
var incompleteFile = completeFile + ".partial";
try {
// if the package already exists on disk, we can skip the download.
if (File.Exists(completeFile)) {
Log.Info($"Package already exists on disk: '{completeFile}', verifying checksum...");
try {
VerifyPackageChecksum(targetRelease);
Log.Info("Package checksum verified, skipping download.");
return;
} catch (ChecksumFailedException ex) {
Log.Warn(ex, $"Checksum failed for file '{completeFile}'. Deleting and starting over.");
}
}
var deltasSize = updates.DeltasToTarget.Sum(x => x.Size);
var deltasCount = updates.DeltasToTarget.Count();
try {
if (updates.BaseRelease?.FileName != null && deltasCount > 0) {
if (ignoreDeltas) {
Log.Info("Ignoring delta updates (ignoreDeltas parameter)");
} 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 {
using var _1 = Utility.GetTempDirectory(out var deltaStagingDir, appTempDir);
string basePackagePath = Path.Combine(appPackageDir, Path.GetFileName(updates.BaseRelease.FileName));
if (!File.Exists(basePackagePath))
throw new Exception($"Unable to find base package {basePackagePath} for delta update.");
EasyZip.ExtractZipToDirectory(Log, basePackagePath, deltaStagingDir);
reportProgress(10);
await DownloadAndApplyDeltaUpdates(deltaStagingDir, updates, x => reportProgress(Utility.CalculateProgress(x, 10, 80)), cancelToken)
.ConfigureAwait(false);
reportProgress(80);
Log.Info("Delta updates completed, creating final update package.");
File.Delete(incompleteFile);
await EasyZip.CreateZipFromDirectoryAsync(Log, incompleteFile, deltaStagingDir, x => reportProgress(Utility.CalculateProgress(x, 80, 100)),
cancelToken: cancelToken).ConfigureAwait(false);
File.Delete(completeFile);
File.Move(incompleteFile, completeFile);
Log.Info("Delta release preparations complete. Package moved to: " + completeFile);
reportProgress(100);
return; // success!
}
}
}
} catch (Exception ex) when (!VelopackRuntimeInfo.InUnitTestRunner) {
Log.Warn(ex, "Unable to apply delta updates, falling back to full update.");
}
Log.Info($"Downloading full release ({targetRelease.FileName})");
File.Delete(incompleteFile);
await Source.DownloadReleaseEntry(Log, targetRelease, incompleteFile, reportProgress, cancelToken).ConfigureAwait(false);
Log.Info("Verifying package checksum...");
VerifyPackageChecksum(targetRelease, incompleteFile);
Utility.MoveFile(incompleteFile, completeFile, true);
Log.Info("Full release download complete. Package moved to: " + completeFile);
reportProgress(100);
} finally {
if (VelopackRuntimeInfo.IsWindows && !cancelToken.IsCancellationRequested) {
try {
var updateExe = Locator.UpdateExePath!;
Log.Info("Extracting new Update.exe to " + updateExe);
var zip = new ZipPackage(completeFile, loadUpdateExe: true);
if (zip.UpdateExeBytes == null) {
Log.Error("Update.exe not found in package, skipping extraction.");
} else {
await Utility.RetryAsync(async () => {
using var ms = new MemoryStream(zip.UpdateExeBytes);
using var fs = File.Create(updateExe);
await ms.CopyToAsync(fs).ConfigureAwait(false);
}).ConfigureAwait(false);
}
} catch (Exception ex) {
Log.Error(ex, "Failed to extract new Update.exe");
}
}
CleanPackagesExcept(completeFile);
}
}
///
/// Given a folder containing the extracted base package, and a list of delta updates, downloads and applies the
/// delta updates to the base package.
///
/// A folder containing the application files to apply the delta's to.
/// An update object containing one or more delta's
/// A callback reporting process of delta application progress (from 0-100).
/// A token to use to cancel the request.
protected virtual async Task DownloadAndApplyDeltaUpdates(string extractedBasePackage, UpdateInfo updates, Action progress, CancellationToken cancelToken)
{
var releasesToDownload = updates.DeltasToTarget.OrderBy(d => d.Version).ToArray();
var appTempDir = Locator.AppTempDir!;
var appPackageDir = Locator.PackagesDir!;
var updateExe = Locator.UpdateExePath!;
// downloading accounts for 0%-50% of progress
double current = 0;
double toIncrement = 100.0 / releasesToDownload.Count();
await releasesToDownload.ForEachAsync(async x => {
var targetFile = Path.Combine(appPackageDir, x.FileName);
double component = 0;
Log.Debug($"Downloading delta version {x.Version}");
await Source.DownloadReleaseEntry(Log, x, targetFile, p => {
lock (progress) {
current -= component;
component = toIncrement / 100.0 * p;
var progressOfStep = (int) Math.Round(current += component);
progress(Utility.CalculateProgress(progressOfStep, 0, 50));
}
}, cancelToken).ConfigureAwait(false);
VerifyPackageChecksum(x);
cancelToken.ThrowIfCancellationRequested();
Log.Debug($"Download complete for delta version {x.Version}");
}).ConfigureAwait(false);
Log.Info("All delta packages downloaded and verified, applying them to the base now. The delta staging dir is: " + extractedBasePackage);
// applying deltas accounts for 50%-100% of progress
double progressStepSize = 100d / releasesToDownload.Length;
var builder = new DeltaUpdateExe(Log, appTempDir, updateExe);
for (var i = 0; i < releasesToDownload.Length; i++) {
cancelToken.ThrowIfCancellationRequested();
var rel = releasesToDownload[i];
double baseProgress = i * progressStepSize;
var packageFile = Path.Combine(appPackageDir, rel.FileName);
builder.ApplyDeltaPackageFast(extractedBasePackage, packageFile, x => {
var progressOfStep = (int) (baseProgress + (progressStepSize * (x / 100d)));
progress(Utility.CalculateProgress(progressOfStep, 50, 100));
});
}
progress(100);
}
///
/// Removes any incomplete files (.partial) and packages (.nupkg) from the packages directory that does not match
/// the provided asset. If assetToKeep is null, all packages will be deleted.
///
protected virtual void CleanPackagesExcept(string? assetToKeep)
{
try {
Log.Info("Cleaning up incomplete and delta packages from packages directory.");
var appPackageDir = Locator.PackagesDir!;
foreach (var l in Directory.EnumerateFiles(appPackageDir, "*.nupkg").ToArray()) {
try {
if (assetToKeep != null && Utility.FullPathEquals(l, assetToKeep)) {
continue;
}
Utility.DeleteFileOrDirectoryHard(l);
Log.Trace(l + " deleted.");
} catch (Exception ex) {
Log.Warn(ex, "Failed to delete partial package: " + l);
}
}
foreach (var l in Directory.EnumerateFiles(appPackageDir, "*.partial").ToArray()) {
try {
Utility.DeleteFileOrDirectoryHard(l);
Log.Trace(l + " deleted.");
} catch (Exception ex) {
Log.Warn(ex, "Failed to delete partial package: " + l);
}
}
} catch (Exception ex) {
Log.Warn(ex, "Failed to clean up incomplete and delta packages.");
}
}
///
/// Check a package checksum against the one in the release entry, and throws if the checksum does not match.
///
/// The entry to check
/// Optional file path, if not specified the package will be loaded from %pkgdir%/release.OriginalFilename.
protected internal virtual void VerifyPackageChecksum(VelopackAsset release, string? filePathOverride = null)
{
var targetPackage = filePathOverride == null
? new FileInfo(Path.Combine(Locator.PackagesDir!, release.FileName))
: new FileInfo(filePathOverride);
if (!targetPackage.Exists) {
throw new ChecksumFailedException(targetPackage.FullName, "File doesn't exist.");
}
if (targetPackage.Length != release.Size) {
throw new ChecksumFailedException(targetPackage.FullName, $"Size doesn't match ({targetPackage.Length} != {release.Size}).");
}
var hash = Utility.CalculateFileSHA1(targetPackage.FullName);
if (!hash.Equals(release.SHA1, StringComparison.OrdinalIgnoreCase)) {
throw new ChecksumFailedException(targetPackage.FullName, $"SHA1 doesn't match ({release.SHA1} != {hash}).");
}
}
///
/// Throws an exception if the current application is not installed.
///
protected virtual void EnsureInstalled()
{
if (AppId == null || !IsInstalled)
throw new Exception("Cannot perform this operation in an application which is not installed.");
}
///
/// Acquires a globally unique mutex/lock for the current application, to avoid concurrent install/uninstall/update operations.
///
protected virtual Mutex AcquireUpdateLock()
{
var mutexId = $"velopack-{AppId}";
bool created = false;
Mutex? mutex = null;
try {
mutex = new Mutex(false, mutexId, out created);
} catch (Exception ex) {
Log.Warn(ex, "Unable to acquire global mutex/lock.");
created = false;
}
if (mutex == null || !created) {
throw new Exception("Cannot perform this operation while another install/unistall operation is in progress.");
}
return mutex;
}
private static IUpdateSource CreateSimpleSource(string urlOrPath)
{
if (String.IsNullOrWhiteSpace(urlOrPath)) {
throw new ArgumentException("Must pass a valid URL or file path to UpdateManager", nameof(urlOrPath));
}
if (Utility.IsHttpUrl(urlOrPath)) {
return new SimpleWebSource(urlOrPath, Utility.CreateDefaultDownloader());
} else {
return new SimpleFileSource(new DirectoryInfo(urlOrPath));
}
}
}
}