using System;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using System.Xml.Serialization;
namespace AlwaysUpToDate
{
///
/// Provides automatic update checking, downloading, verification, extraction, and application restart.
/// Periodically polls a remote XML manifest for new versions and manages the full update lifecycle.
///
public class Updater : IDisposable
{
///
/// Represents the method that will handle update progress notifications.
///
/// The current phase of the update process.
/// The total number of items to process in this step, or if unknown.
/// The number of items processed so far in this step.
/// The progress percentage (0–100), or if is unknown.
public delegate void UpdaterChangedHandler(UpdateStep step, long itemsProcessed, long? totalItems, double? progressPercentage);
///
/// Occurs when progress is made during any phase of the update process.
///
public event UpdaterChangedHandler ProgressChanged;
///
/// Represents the method that will handle notifications when a non-mandatory update is available.
///
/// The version string of the available update.
/// An optional URL pointing to the changelog, or if not provided.
public delegate void UpdateAvailableHandler(string version, string changelogUrl);
///
/// Occurs when a non-mandatory update is available. Call to begin downloading.
///
public event UpdateAvailableHandler UpdateAvailable;
///
/// Represents the method that will handle notifications when no update is available.
///
public delegate void NoUpdateAvailableHandler();
///
/// Occurs when the remote manifest version is not newer than the current assembly version.
///
public event NoUpdateAvailableHandler NoUpdateAvailable;
///
/// Represents the method that will handle exceptions raised during the update process.
///
/// The exception that occurred.
public delegate void ExceptionHandler(Exception exception);
///
/// Occurs when an exception is caught during update checking, downloading, extraction, or verification.
///
public event ExceptionHandler OnException;
///
/// Represents the method that will handle notifications when the update process begins.
///
/// The version string of the update being installed.
public delegate void UpdateStartedHandler(string version);
///
/// Occurs once when the update download begins, before the first event.
///
public event UpdateStartedHandler UpdateStarted;
private static readonly XmlSerializer manifestSerializer = new XmlSerializer(typeof(UpdateManifest));
private readonly HttpClient httpClient = new HttpClient();
private readonly System.Timers.Timer updateTimer = new System.Timers.Timer();
private readonly string updateInfoUrl;
private readonly string installPath;
private string updateUrl;
private UpdateItem pendingUpdateItem;
private int updating;
private bool disposed;
///
public Updater(TimeSpan interval, Uri updateInfoUri, bool onlyUpdateOnce = false) : this(interval, updateInfoUri?.ToString(), "./", onlyUpdateOnce)
{
}
///
public Updater(TimeSpan interval, Uri updateInfoUri, string installPath = "./", bool onlyUpdateOnce = false) : this(interval, updateInfoUri?.ToString(), installPath, onlyUpdateOnce)
{
}
///
public Updater(TimeSpan interval, string updateInfoUrl, bool onlyUpdateOnce = false) : this(interval, updateInfoUrl, "./", onlyUpdateOnce)
{
}
///
/// Initializes a new instance of the class.
///
/// The interval between automatic update checks. Use to disable periodic checks.
/// The URL of the remote XML update manifest.
/// The local directory where the update will be extracted. Defaults to the current directory.
/// If , performs a single update check on without subscribing to the periodic timer.
/// or is .
public Updater(TimeSpan interval, string updateInfoUrl, string installPath = "./", bool onlyUpdateOnce = false)
{
this.updateInfoUrl = updateInfoUrl ?? throw new ArgumentNullException(nameof(updateInfoUrl));
this.installPath = installPath ?? throw new ArgumentNullException(nameof(installPath));
if (interval.TotalMilliseconds > 0)
{
updateTimer.Interval = interval.TotalMilliseconds;
}
if (!onlyUpdateOnce)
{
updateTimer.Elapsed += UpdateTimer_Elapsed;
}
}
///
/// Starts the updater. Performs an immediate update check and, if a periodic interval was configured, begins recurring checks.
///
/// The updater has been disposed.
public void Start()
{
ThrowIfDisposed();
if (updateTimer.Interval > 0)
{
updateTimer.Start();
}
UpdateTimer_Elapsed(null, null);
}
///
/// Stops periodic update checking. Does not cancel an update that is already in progress.
///
/// The updater has been disposed.
public void Stop()
{
ThrowIfDisposed();
updateTimer.Stop();
}
///
/// Downloads and installs the available update. This method is typically called from the handler.
/// If an update is already in progress or no update URL is available, the call is ignored.
///
/// A task that represents the asynchronous update operation.
/// The updater has been disposed.
public async Task UpdateAsync()
{
ThrowIfDisposed();
if (!string.IsNullOrWhiteSpace(updateUrl) && Interlocked.CompareExchange(ref updating, 1, 0) == 0)
{
await DownloadFile();
}
}
private async void UpdateTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
{
if (Interlocked.CompareExchange(ref updating, 0, 0) != 0)
{
return;
}
try
{
using HttpResponseMessage response = await httpClient.GetAsync(updateInfoUrl);
_ = response.EnsureSuccessStatusCode();
string xmlManifest = await response.Content.ReadAsStringAsync();
UpdateManifest manifest = (UpdateManifest)manifestSerializer.Deserialize(new StringReader(xmlManifest));
TargetOS currentOS = GetCurrentOS();
TargetArchitecture currentArch = GetCurrentArchitecture();
UpdateItem updateItem = manifest.Items?.FirstOrDefault(i => i.OS == currentOS && i.Architecture == currentArch)
?? manifest.Items?.FirstOrDefault(i => i.OS == currentOS && i.Architecture == TargetArchitecture.Any);
if (updateItem == null)
{
return;
}
Assembly entryAssembly = Assembly.GetEntryAssembly();
if (entryAssembly == null)
{
return;
}
Version assemblyVersion = entryAssembly.GetName().Version;
if (!Version.TryParse(updateItem.Version, out Version version))
{
return;
}
if (version > assemblyVersion && Interlocked.CompareExchange(ref updating, 0, 0) == 0)
{
updateUrl = updateItem.DownloadUrl;
pendingUpdateItem = updateItem;
if (!updateItem.IsMandatory)
{
UpdateAvailable?.Invoke(updateItem.Version, updateItem.ChangelogUrl);
}
else
{
await UpdateAsync();
}
}
else
{
NoUpdateAvailable?.Invoke();
}
}
catch (Exception ex)
{
OnException?.Invoke(ex);
}
}
private static TargetOS GetCurrentOS()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return TargetOS.Windows;
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
return TargetOS.MacOS;
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
return TargetOS.Linux;
}
throw new PlatformNotSupportedException();
}
private static TargetArchitecture GetCurrentArchitecture()
{
return RuntimeInformation.ProcessArchitecture switch
{
Architecture.X86 => TargetArchitecture.X86,
Architecture.X64 => TargetArchitecture.X64,
Architecture.Arm => TargetArchitecture.Arm,
Architecture.Arm64 => TargetArchitecture.Arm64,
_ => TargetArchitecture.Any,
};
}
private async Task DownloadFile()
{
try
{
updateTimer.Stop();
_ = Interlocked.Exchange(ref updating, 1);
UpdateStarted?.Invoke(pendingUpdateItem?.Version);
TriggerProgressChanged(UpdateStep.Downloading, null, 0);
using HttpResponseMessage response = await httpClient.GetAsync(updateUrl);
_ = response.EnsureSuccessStatusCode();
using Stream contentStream = await response.Content.ReadAsStreamAsync();
await ProcessContentStream(response.Content.Headers.ContentLength, contentStream);
}
catch (Exception ex)
{
_ = Interlocked.Exchange(ref updating, 0);
OnException?.Invoke(ex);
}
}
private void ExtractZipFile()
{
try
{
string zipPath = Path.Join(installPath, "Update.zip");
TriggerProgressChanged(UpdateStep.VerifyingChecksum, null, 0);
if (!VerifyChecksum(zipPath))
{
File.Delete(zipPath);
_ = Interlocked.Exchange(ref updating, 0);
throw new InvalidOperationException("Checksum verification failed for the downloaded update.");
}
TriggerProgressChanged(UpdateStep.VerifyingChecksum, 1, 1);
string executablePath = Process.GetCurrentProcess().MainModule?.FileName ?? Assembly.GetEntryAssembly()?.Location;
if (string.IsNullOrEmpty(executablePath))
{
throw new InvalidOperationException("Unable to determine the executable path.");
}
using (ZipArchive zipArchive = ZipFile.OpenRead(zipPath))
{
string fullInstallPath = Path.GetFullPath(installPath + Path.DirectorySeparatorChar);
long totalEntries = zipArchive.Entries.Count;
long processedEntries = 0;
foreach (ZipArchiveEntry entry in zipArchive.Entries)
{
string destinationPath = Path.GetFullPath(Path.Join(installPath, entry.FullName));
if (!destinationPath.StartsWith(fullInstallPath, StringComparison.OrdinalIgnoreCase))
{
processedEntries++;
continue;
}
if (File.Exists(Path.Join(installPath, entry.FullName)))
{
string moveName = Path.GetFileNameWithoutExtension(entry.FullName) + "_OLD_" + Path.GetExtension(entry.FullName);
int counter = 1;
while (File.Exists(Path.Join(installPath, moveName)))
{
moveName = Path.GetFileNameWithoutExtension(entry.FullName) + "_OLD_" + counter + Path.GetExtension(entry.FullName);
counter++;
}
File.Move(Path.Join(installPath, entry.FullName), Path.Join(installPath, moveName));
}
if (entry.FullName.EndsWith('/'))
{
if (!Directory.Exists(Path.Join(installPath, entry.FullName)))
{
_ = Directory.CreateDirectory(Path.Join(installPath, entry.FullName));
}
}
else
{
entry.ExtractToFile(Path.Join(installPath, entry.FullName), true);
}
processedEntries++;
TriggerProgressChanged(UpdateStep.Extracting, totalEntries, processedEntries);
}
}
string[] allFiles = Directory.GetFiles(installPath, "*.*", SearchOption.AllDirectories);
string[] oldFiles = Array.FindAll(allFiles, f => f.Contains("_OLD_"));
long totalOldFiles = oldFiles.Length;
long deletedFiles = 0;
foreach (string filePath in oldFiles)
{
try
{
File.Delete(filePath);
}
catch (Exception ex)
{
Debug.WriteLine(ex);
}
deletedFiles++;
TriggerProgressChanged(UpdateStep.CleaningUp, totalOldFiles, deletedFiles);
}
File.Delete(zipPath);
TriggerProgressChanged(UpdateStep.Restarting, null, 0);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
Process.Start("chmod", $"+x \"{executablePath}\"")?.WaitForExit();
}
_ = Process.Start(new ProcessStartInfo(executablePath) { UseShellExecute = false });
Environment.Exit(0);
}
catch (Exception ex)
{
_ = Interlocked.Exchange(ref updating, 0);
OnException?.Invoke(ex);
}
}
private async Task ProcessContentStream(long? totalDownloadSize, Stream contentStream)
{
try
{
long totalBytesRead = 0;
int readCount = 0;
byte[] buffer = new byte[8192];
bool isMoreToRead = true;
using (FileStream fileStream = new FileStream(Path.Join(installPath, "Update.zip"), FileMode.Create, FileAccess.Write, FileShare.None, 8192, true))
{
do
{
int bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length);
if (bytesRead == 0)
{
isMoreToRead = false;
TriggerProgressChanged(UpdateStep.Downloading, totalDownloadSize, totalBytesRead);
continue;
}
await fileStream.WriteAsync(buffer, 0, bytesRead);
totalBytesRead += bytesRead;
readCount += 1;
if (readCount >= 10)
{
readCount = 0;
TriggerProgressChanged(UpdateStep.Downloading, totalDownloadSize, totalBytesRead);
}
}
while (isMoreToRead);
}
ExtractZipFile();
}
catch (Exception ex)
{
OnException?.Invoke(ex);
}
}
private bool VerifyChecksum(string filePath)
{
UpdateItem item = pendingUpdateItem;
if (item?.Checksum == null || string.IsNullOrWhiteSpace(item.Checksum.Value))
{
return true;
}
using HashAlgorithm algorithm = CreateHashAlgorithm(item.Checksum.Algorithm);
using FileStream stream = File.OpenRead(filePath);
byte[] hash = algorithm.ComputeHash(stream);
string hashString = BitConverter.ToString(hash).Replace("-", "");
return string.Equals(hashString, item.Checksum.Value, StringComparison.OrdinalIgnoreCase);
}
private static HashAlgorithm CreateHashAlgorithm(HashAlgorithmType algorithm)
{
return algorithm switch
{
HashAlgorithmType.MD5 => MD5.Create(),
HashAlgorithmType.SHA256 => SHA256.Create(),
HashAlgorithmType.SHA512 => SHA512.Create(),
HashAlgorithmType.SHA1 => SHA1.Create(),
_ => throw new NotSupportedException($"Hash algorithm '{algorithm}' is not supported."),
};
}
private void TriggerProgressChanged(UpdateStep step, long? totalSize, long totalProcessed)
{
double? progressPercentage = null;
if (totalSize.HasValue && totalSize.Value > 0)
{
progressPercentage = Math.Round((double)totalProcessed / totalSize.Value * 100, 2);
}
ProgressChanged?.Invoke(step, totalProcessed, totalSize, progressPercentage);
}
private void ThrowIfDisposed()
{
if (disposed)
{
throw new ObjectDisposedException(nameof(Updater));
}
}
///
/// Releases all resources used by the .
///
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
///
/// Releases the unmanaged resources used by the and optionally releases the managed resources.
///
/// to release both managed and unmanaged resources; to release only unmanaged resources.
protected virtual void Dispose(bool disposing)
{
if (!disposed)
{
if (disposing)
{
updateTimer.Dispose();
httpClient.Dispose();
}
disposed = true;
}
}
}
}