Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / desktop / WinForms

.NET Silent ClickOnce Installer for Winform & WPF in C# & VB

5.00/5 (18 votes)
12 Oct 2023CPOL22 min read 29.6K   780  
.NET compliant Silent ClickOnce Update Background Worker Service for Winform, WPF & Console in C# & VB
In .NET Core 3.1, Microsoft added the missing Click-Once support that was in the .NET Framework. This article covers how to implement, troubleshoot & test locally, plus release to production/live MVC web server for installation and silent updating for Winform, WPF & Console in C# & VB.
Important: If you are using .NET Framework 4.8+ or earlier, please read this previous article Silent ClickOnce Installer for Winform & WPF in C# & VB.

UPDATE: 25th April 2023 - v1.10

Added C# & VB sample/proof-of-concept Console application + RetroConsole (prototype) library for advanced Console rendering - see Retro Console in the Preview section.

Contents

Introduction

Microsoft, and 3rd party companies, have a number of different Installer Framework systems. Many of these require partial or full manual interaction, some like ClickOnce can automate this process. This article will cover ClickOnce for .NET Core 3.1+.

Definition

What is ClickOnce? Microsoft defines it as:

Quote:

ClickOnce is a deployment technology that enables you to create self-updating Windows-based applications that can be installed and run with minimal user interaction. You can publish a ClickOnce application in three different ways: from a Web page, from a network file share, or from media such as a CD-ROM. ... Microsoft Docs[^]

Overview

Many applications utilize a similar mechanism to manage the currency of their applications and communicate with their users when it is time to restart to apply an update. Google Chrome, Microsoft Edge, and Discord, just to name a few.

Below is a screenshot of Discord's update notifier:

Image 1

And here is the update notifier for Google Chrome:

Image 2

Microsoft's implementation of ClickOnce is a little awkward. The update check happens at the start of the application with a rather unwanted checking for updates window before the application starts.

Image 3

We will address this.

Benefits

The key benefits of using ClickOnce for Windows Desktop application are:

  • easy to publish
  • easy to install
  • automatic update system

The aim of this article is to remove that awkward window, and in the background of the application, silently monitor for any updates. If an update is found, prepare the update and notify the application/user that an update is ready, then the application can either automatically update or allow the user to choose when to update. Lastly, as a developer, have full control over minor versus major/mandatory update policies.

So, the key aims can be summarised as follows:

  • WinForms and WPF support (Console app implementation possible)
  • Removal of the default Microsoft update check before the application runs
  • A background service to manage the monitoring and notification
  • API to allow customization of workflow
  • API exposing all properties
  • StatusBar control that can be dropped into an app to get started quickly
  • Logging Framework integration
    • Real-time LogView control included as a sample to visualise the logs for debugging

Preview

Let's look at what we will achieve in this article, a silent update service that runs in the background of the application and notifies when an update is ready. This article covers both a minimal non-Dependency Injection example and another that supports Dependency Injection with a sample implementation showing an update process using a StatusBar. There will be code in both C# and VB.NET.

First, a minimal implementation where the app responds to an update notification indicates the version available and enables the Update button. Once clicked, the app automatically applies the update and restarts, reflecting the updated (published) version number.

Image 4

Next are WinForms (VB) and WPF (C#) sample applications using the LogViewControl plus ServiceProperties debugging tools (controls), and the StatusBar control for communicating with the User.

First, a VB.NET Winforms sample application:

Image 5

Notes

  • The sample Statusbar control has a check heartbeat indicator for every ping of the server
  • In the Properties tab, we can see all of the information that the ClickOnceService provides, including the installed and remote versions, where the application is installed (with Copy button), and where the service is looking for updates, very handy for debugging any issues
  • Using the LogViewer control, we can see the conversation of the client with the server when an update becomes available.

Second a C# WPF sample application with custom LogViewer colors:

Image 6

Notes

  • Here, we can see that all logging is being captured, even the Trace information from the HttpClient with header information.

Retro Console (NEW!)

ClickOnce is not just for WinForms and WPF applications. Just for fun, I've put together C# and VB sample applications, using my RetroConsole prototype library. The Console application mimics the Winforms and WPF sample applications with Property and Log views.

Image 7

Publishing, installing and running the Console application is no different to the Winforms and WPF versions. The same ClickOnceUpdateService class is being used to manage silent updating and notification. This sample console application is just a proof-of-concept that you can use as an example if you are looking to use ClickOnce in your own console application(s).

Prerequisites

The projects for this article were built with the following in mind:

  • .NET Core 7.03
  • C# 11.0 and Visual Basic 16.0
  • Built with Visual Studio 2022 v17.4.5 / JetBrains Rider 2022.3.2
  • For publishing, you will need to create a test certificate
  • For development hosting and testing, you will need to configure your HOSTS file

NOTE: For the last 2 points, instructions are provided below.

The following Nuget Packages were used:

ClickOnceUpdateService Core

This is the core service that does all of the work. The service is implemented as an asynchronous background task that will only interact with any app if there is an update ready on a hosting server. The service also exposes a number of information properties and action methods. The methods give full control over how the update is processed. All activity is logged using the Microsoft Logging Framework.

The implementation of the service is made up of two parts:

  1. ClickOnceUpdateOptions Configuration Options class
    • Path to remote server hosting of updated
    • Retry interval for checking for updates
  2. ClickOnceUpdateService Core Background Service class:

ClickOnceUpdateOptions Class

This is just a simple Options class:

C#
public sealed class ClickOnceUpdateOptions
{
    public string? PublishingPath { get; set; }

    public int RetryInterval { get; set; } = 1000;
}
VB.NET
Public NotInheritable Class ClickOnceUpdateOptions

    Public Property PublishingPath As String

    Public Property RetryInterval As Integer = 1000

End Class

This class can be set manually, or settings in an external appsettings.json file:

JavaScript
{
  "ClickOnce":
  {
    "PublishingPath": "http://silentupdater.net:5218/Installer/WinformsApp/",
    "RetryInterval": 1000
  }
}

ClickOnceUpdateService Class

This is the core that does all of the work. As mentioned in the previous section, it supports both Dependency Injection (DI) or manual usage.

As the service is communicating with a remote host, the HttpClient is required. For manual usage, the IHttpClientFactory is internally initialised and used. This ensures that no resources are exhausted when using HttpClient.

Optional logging is also fully supported for both DI and manual usage.

The service gives you as the developer, and your user, full control over how the update process is done. The sample applications will download and prepare for updates.

You have the option to notify the user and give them the choice to download updates. The service also supports the identification of critical updates that can, if required, allow you as the developer to override the user's choice of when to update.

IClickOnceUpdateService Interface

C#
public interface IClickOnceUpdateService : IHostedService
{
    /// <summary>
    /// The full application name
    /// </summary>
    string? ApplicationName { get; }

    /// <summary>
    /// The path to where the application was installed
    /// </summary>
    string? ApplicationPath { get; }

    /// <summary>
    /// Was the application installed   
    /// </summary>
    bool IsNetworkDeployment { get; }

    /// <summary>
    /// The path to the stored application data
    /// </summary>
    string DataDirectory { get; }

    /// <summary>
    /// Is there an update ready 
    /// </summary>
    bool IsUpdatingReady { get; }

    /// <summary>
    /// Current installed version is lower that the remote minimum version required 
    /// </summary>
    bool IsMandatoryUpdate { get; }

    /// <summary>
    /// Server path to installation files &amp; manifest
    /// </summary>
    string PublishingPath { get; }

    /// <summary>
    /// How often in milliseconds to check for updates (minimum 1000ms / 1 second) 
    /// </summary>
    int RetryInterval { get; }
    
    /// <summary>
    /// Found an update and has begun preparing
    /// </summary>
    event UpdateDetectedEventHandler? UpdateDetected;

    /// <summary>
    /// Update is ready and a restart is required
    /// </summary>
    event UpdateReadyEventHandler? UpdateReady;

    /// <summary>
    /// An update check is in progress
    /// </summary>
    event UpdateCheckEventHandler? UpdateCheck;

    /// <summary>
    /// Get the current installed version 
    /// </summary>
    /// <returns><see cref="T:System.Version" /></returns>
    Task<Version> CurrentVersionAsync();

    /// <summary>
    /// Get the remote server version
    /// </summary>
    /// <returns><see cref="T:System.Version" /></returns>
    Task<Version> ServerVersionAsync();

    /// <summary>
    /// Manually check if there is a newer version
    /// </summary>
    /// <returns><see langword="true" /> if there is a newer version available
    /// </returns>
    Task<bool> UpdateAvailableAsync();

    /// <summary>
    /// Prepare to update the application 
    /// by downloading the new setup to do the updating
    /// </summary>
    /// <returns><see langword="true" /> if successful</returns>
    Task<bool> PrepareForUpdatingAsync();

    /// <summary>
    /// Start the update process
    /// </summary>
    /// <returns>A task that represents the asynchronous execute operation.</returns>
    Task ExecuteUpdateAsync();
}
VB.NET
Public Interface IClickOnceUpdateService : Inherits IHostedService

    ''' <summary>
    ''' The full application name
    ''' </summary>
    ReadOnly Property ApplicationName As String

    ''' <summary>
    ''' The path to where the application was installed
    ''' </summary>
    ReadOnly Property ApplicationPath As String

    ''' <summary>
    ''' Was the application installed   
    ''' </summary>
    ReadOnly Property IsNetworkDeployment As Boolean

    ''' <summary>
    ''' The path to the stored application data
    ''' </summary>
    ReadOnly Property DataDirectory As String

    ''' <summary>
    ''' Is there an update ready 
    ''' </summary>
    ReadOnly Property IsUpdatingReady As Boolean

    ''' <summary>
    ''' Current installed version Is lower that the remote minimum version required 
    ''' </summary>
    ReadOnly Property IsMandatoryUpdate As Boolean

    ''' <summary>
    ''' Server path to installation files &amp; manifest
    ''' </summary>
    ReadOnly Property PublishingPath As String

    ''' <summary>
    ''' How often in milliseconds to check for updates (minimum 1000ms / 1 second) 
    ''' </summary>
    ReadOnly Property RetryInterval As Integer

    ''' <summary>
    ''' Found an update And has begun preparing
    ''' </summary>
    Event UpdateDetected As UpdateDetectedEventHandler

    ''' <summary>
    ''' Update Is ready And a restart Is required
    ''' </summary>
    Event UpdateReady As UpdateReadyEventHandler

    ''' <summary>
    ''' An update check Is in progress
    ''' </summary>
    Event UpdateCheck As UpdateCheckEventHandler

    ''' <summary>
    ''' Get the current installed version 
    ''' </summary>
    ''' <returns><see cref="T:System.Version" /></returns>
    Function CurrentVersionAsync() As Task(Of Version)

    ''' <summary>
    ''' Get the remote server version
    ''' </summary>
    ''' <returns><see cref="T:System.Version" /></returns>
    Function ServerVersionAsync() As Task(Of Version)

    ''' <summary>
    ''' Manually check if there Is a newer version
    ''' </summary>
    ''' <returns><see langword="true" /> 
    ''' if there Is a newer version available</returns>
    Function UpdateAvailableAsync() As Task(Of Boolean)

    ''' <summary>
    ''' Prepare to update the application 
    ''' by downloading the New setup to do the updating
    ''' </summary>
    ''' <returns><see langword="true" /> if successful</returns>
    Function PrepareForUpdatingAsync() As Task(Of Boolean)

    ''' <summary>
    ''' Start the update process
    ''' </summary>
    ''' <returns>A task that represents the asynchronous execute operation.</returns>
    Function ExecuteUpdateAsync() As Task

End Interface

ClickOnceUpdateService Class

The implementation of the ClickOnceUpdateService utilizes both local and remote manifests to expose key information and monitor and prepare the updates. The process for the update is flexible and only applies when the ExecuteUpdateAsync is called. This gives you, as the developer complete control over the process, and also allows the user to check when and how the update occurs.

C#
public delegate void UpdateDetectedEventHandler(object? sender, EventArgs e);
public delegate void UpdateReadyEventHandler(object? sender, EventArgs e);
public delegate void UpdateCheckEventHandler(object? sender, EventArgs e);

public sealed class ClickOnceUpdateService : BackgroundService, IClickOnceUpdateService
{
    #region Constructors

    public ClickOnceUpdateService(
        IOptions<ClickOnceUpdateOptions> options,
        IHttpClientFactory httpClientFactory,
        ILogger<ClickOnceUpdateService> logger)
    {
        _options = options.Value;
        _httpClientFactory = httpClientFactory;
        _logger = logger;

        Initialize();
    }

    public ClickOnceUpdateService(
        ClickOnceUpdateOptions options,
        ILogger<ClickOnceUpdateService>? logger = null)
    {
        _options = options;
        _logger = logger!;

        // not using DI ... new up manually
        CreateHttpClient();

        Initialize();
    }

    #endregion

    #region Fields

    #region Injected

    private readonly ClickOnceUpdateOptions _options;
    private IHttpClientFactory? _httpClientFactory;
    private readonly ILogger _logger;

    #endregion

    public const string SectionKey = "ClickOnce";
    public const string HttpClientKey = nameof(ClickOnceUpdateService) + "_httpclient";

    private static readonly EventId EventId = new(id: 0x1A4, name: "ClickOnce");

    private bool _isNetworkDeployment;
    private string? _applicationName;
    private string? _applicationPath;
    private string? _dataDirectory;
    private InstallFrom _installFrom;

    private bool _isProcessing;

    #region Cached

    private Version? _minimumServerVersion;
    private Version? _currentVersion;
    private Version? _serverVersion;
    private string? _setupPath;

    #endregion

    #endregion

    #region Properties

    /// <summary>
    /// The full application name
    /// </summary>
    public string? ApplicationName => _applicationName;

    /// <summary>
    /// The path to where the application was installed
    /// </summary>
    public string? ApplicationPath => _applicationPath;

    /// <summary>
    /// Was the application installed   
    /// </summary>
    public bool IsNetworkDeployment => _isNetworkDeployment;
    
    /// <summary>
    /// The path to the stored application data
    /// </summary>
    public string DataDirectory => _dataDirectory ?? string.Empty;

    /// <summary>
    /// Is there an update ready 
    /// </summary>
    public bool IsUpdatingReady { get; private set; }

    /// <summary>
    /// Current installed version is lower that the remote minimum version required 
    /// </summary>
    public bool IsMandatoryUpdate => IsUpdatingReady && 
                                    _minimumServerVersion is not null &&
                                    _currentVersion is not null &&
                                    _minimumServerVersion > _currentVersion;
    /// <summary>
    /// Server path to installation files &amp; manifest
    /// </summary>
    public string PublishingPath => _options.PublishingPath ?? "";

    /// <summary>
    /// How often in milliseconds to check for updates (minimum 1000ms / 1 second) 
    /// </summary>
    public int RetryInterval => _options.RetryInterval;

    /// <summary>
    /// Found an update and has begun preparing
    /// </summary>
    public event UpdateDetectedEventHandler? UpdateDetected;
    
    /// <summary>
    /// Update is ready and a restart is required
    /// </summary>
    public event UpdateReadyEventHandler? UpdateReady;
    
    /// <summary>
    /// An update check is in progress
    /// </summary>
    public event UpdateCheckEventHandler? UpdateCheck;

    #endregion

    #region BackgroundService

    /// <inheritdoc />
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            _logger.Emit(EventId, LogLevel.Information, "Waiting");

            // wait for a pre-determined interval
            await Task.Delay(_options.RetryInterval, 
                             stoppingToken).ConfigureAwait(false);
            
            if (stoppingToken.IsCancellationRequested)
                break;

            // heartbeat logging
            _logger.Emit(EventId, LogLevel.Information, "Checking for an update");

            // health check tick
            OnUpdateCheck();

            try
            {
                // Stop checking if there is an update (already logged)
                if (await CheckHasUpdateAsync().ConfigureAwait(false))
                    break;
            }
            catch (ClickOnceDeploymentException)
            {
                // already handled, ignore and continue
            }
            catch (HttpRequestException ex)
            {
                // website appears to be offline / can't find setup. Log and continue
                _logger.Emit(EventId, LogLevel.Error, ex.Message, ex);
            }
            catch (Exception ex)
            {
                // we hit a major issue, log & shut down
                _logger.LogError(EventId, ex.Message, ex);

                break;
            }
        }

        _logger.Emit(EventId, LogLevel.Information, "Stopped");
    }

    /// <inheritdoc />
    public override async Task StartAsync(CancellationToken cancellationToken)
    {
        await Task.Yield();

        _logger.Emit(EventId, LogLevel.Information, "Starting");

        // safe guard against self-DDoS .. do not want to spam own web server
        if (_options.RetryInterval < 1000)
            _options.RetryInterval = 1000;

        await base.StartAsync(cancellationToken).ConfigureAwait(false);
    }

    /// <inheritdoc />
    public override async Task StopAsync(CancellationToken cancellationToken)
    {
        _logger.Emit(EventId, LogLevel.Information, "Stopping");
        await base.StopAsync(cancellationToken).ConfigureAwait(false);
    }

    #endregion

    #region Methods

    #region Partial 'ApplicationDeployment' implementation

    #region Public

    /// <summary>
    /// Get the current installed version 
    /// </summary>
    /// <returns><see cref="T:System.Version" /></returns>
    public async Task<Version> CurrentVersionAsync()
    {
        if (!IsNetworkDeployment)
            throw GenerateExceptionAndLogIt("Not deployed by network!");

        if (string.IsNullOrEmpty(_applicationName))
            throw GenerateExceptionAndLogIt("Application name is empty!");

        if (_currentVersion is not null)
            return _currentVersion;

        string path = Path.Combine
                      (_applicationPath!, $"{_applicationName}.exe.manifest");

        if (!File.Exists(path))
            throw GenerateExceptionAndLogIt
            ($"Can't find manifest file at path {path}");

        _logger.Emit(EventId, LogLevel.Debug, $"Looking for local manifest: {path}");

        string fileContent = await File.ReadAllTextAsync(path).ConfigureAwait(false);

        XDocument xmlDoc = XDocument.Parse(fileContent, LoadOptions.None);
        XNamespace nsSys = "urn:schemas-microsoft-com:asm.v1";
        XElement? xmlElement = xmlDoc.Descendants(nsSys + "assemblyIdentity")
                                     .FirstOrDefault();

        if (xmlElement == null)
            throw GenerateExceptionAndLogIt($"Invalid manifest document for {path}");

        string? version = xmlElement.Attribute("version")?.Value;

        if (string.IsNullOrEmpty(version))
            throw GenerateExceptionAndLogIt("Local version info is empty!");

        _currentVersion = new Version(version);
        return _currentVersion;
    }

    /// <summary>
    /// Get the remote server version
    /// </summary>
    /// <returns><see cref="T:System.Version" /></returns>
    public async Task<Version> ServerVersionAsync()
    {
        if (_installFrom == InstallFrom.Web)
        {
            try
            {
                using HttpClient client = HttpClientFactory(
                    new Uri(_options.PublishingPath!));

                _logger.Emit(EventId, LogLevel.Debug,
                    $"Looking for remote manifest: {_options.PublishingPath ?? ""}
                      {_applicationName}.application");

                await using Stream stream = await client.GetStreamAsync(
                    $"{_applicationName}.application").ConfigureAwait(false);

                Version version = await ReadServerManifestAsync(stream)
                                            .ConfigureAwait(false);

                if (version is null)
                    throw GenerateExceptionAndLogIt("Remote version info is empty!");

                return version;
            }
            catch (Exception ex)
            {
                throw GenerateExceptionAndLogIt($"{ex.Message}");
            }
        }

        if (_installFrom != InstallFrom.Unc)
            throw GenerateExceptionAndLogIt("No network install was set");

        try
        {
            await using FileStream stream = File.OpenRead(Path.Combine(
                $"{_options.PublishingPath!}", $"{_applicationName}.application"));
            return await ReadServerManifestAsync(stream).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            throw GenerateExceptionAndLogIt(ex.Message);
        }
    }

    /// <summary>
    /// Manually check if there is a newer version
    /// </summary>
    /// <returns><see langword="true" /> 
    /// if there is a newer version available</returns>
    public async Task<bool> UpdateAvailableAsync()
        => await CurrentVersionAsync().ConfigureAwait(false) <
           await ServerVersionAsync().ConfigureAwait(false);

    /// <summary>
    /// Prepare to update the application 
    /// by downloading the new setup to do the updating
    /// </summary>
    /// <returns><see langword="true" /> if successful</returns>
    public async Task<bool> PrepareForUpdatingAsync()
    {
        // Nothing to update
        if (!await UpdateAvailableAsync().ConfigureAwait(false))
            return false;

        _isProcessing = true;

        switch (_installFrom)
        {
            case InstallFrom.Web:
                {
                    await GetSetupFromServerAsync().ConfigureAwait(false);
                    break;
                }

            case InstallFrom.Unc:
                _setupPath = Path.Combine($"{_options.PublishingPath!}",
                                          $"{_applicationName}.application");
                break;

            default:
                throw GenerateExceptionAndLogIt("No network install was set");
        }

        _isProcessing = false;
        return true;
    }

    /// <summary>
    /// Start the update process
    /// </summary>
    /// <returns>A task that represents the asynchronous execute operation.</returns>
    public async Task ExecuteUpdateAsync()
    {
        if (_setupPath is null)
            throw GenerateExceptionAndLogIt("No update available.");

        Process? process = OpenUrl(_setupPath!);

        if (process is null)
            throw GenerateExceptionAndLogIt("No update available.");

        await process.WaitForExitAsync().ConfigureAwait(false);

        if (!string.IsNullOrEmpty(_setupPath))
            File.Delete(_setupPath);
    }

    #endregion

    #region Internals

    #region Manual HttpClientFactory for non-DI

    private void CreateHttpClient()
    {
        ServiceCollection builder = new();
        builder.AddHttpClient(HttpClientKey);
        ServiceProvider serviceProvider = builder.BuildServiceProvider();

        _httpClientFactory = serviceProvider.GetRequiredService<IHttpClientFactory>();
    }

    #endregion

    private void Initialize()
    {
        _applicationPath = AppDomain
            .CurrentDomain.SetupInformation.ApplicationBase ?? string.Empty;

        _applicationName = Assembly.GetEntryAssembly()?.GetName().Name ?? string.Empty;
        _isNetworkDeployment = VerifyDeployment();

        if (string.IsNullOrEmpty(_applicationName))
            throw GenerateExceptionAndLogIt("Can't find entry assembly name!");

        if (_isNetworkDeployment && !string.IsNullOrEmpty(_applicationPath))
        {
            string programData = Path.Combine(
                KnownFolder.GetLocalApplicationData(), @"Apps\2.0\Data\");

            string currentFolderName = new DirectoryInfo(_applicationPath).Name;
            _dataDirectory = 
                 ApplicationDataDirectory(programData, currentFolderName, 0);
        }
        else
        {
            _dataDirectory = string.Empty;
        }

        SetInstallFrom();
    }

    private async Task<bool> CheckHasUpdateAsync()
    {
        if (_isProcessing || !await UpdateAvailableAsync().ConfigureAwait(false))
            return false;

        OnUpdateDetected();

        _logger.Emit(EventId, LogLevel.Information,
            "New version identified. Current: {current}, Server: {server}",
            null,
            _currentVersion,
            _serverVersion);

        if (await PrepareForUpdatingAsync().ConfigureAwait(false))
        {
            _logger.Emit(EventId, LogLevel.Information, 
                         "Update is ready for processing.");

            IsUpdatingReady = true;
            OnUpdateReady();
            return true;
        }

        return false;
    }

    private bool VerifyDeployment()
        => !string.IsNullOrEmpty(_applicationPath) &&
           _applicationPath.Contains(@"AppData\Local\Apps");

    private void SetInstallFrom()
        => _installFrom = _isNetworkDeployment &&
           !string.IsNullOrEmpty(_options.PublishingPath!)
            ? _options.PublishingPath!.StartsWith("http")
                ? InstallFrom.Web : InstallFrom.Unc
            : InstallFrom.NoNetwork;

    private string ApplicationDataDirectory(
        string programData, string currentFolderName, int depth)
    {
        if (++depth > 100)
            throw GenerateExceptionAndLogIt(
                $"Can't find data dir for {currentFolderName} 
                  in path: {programData}");

        string result = string.Empty;

        foreach (string dir in Directory.GetDirectories(programData))
        {
            if (dir.Contains(currentFolderName))
            {
                result = Path.Combine(dir, "Data");
                break;
            }

            result = ApplicationDataDirectory(Path.Combine(programData, dir),
                     currentFolderName, depth);

            if (!string.IsNullOrEmpty(result))
                break;
        }

        return result;
    }

    private async Task<Version> ReadServerManifestAsync(Stream stream)
    {
        XDocument xmlDoc = await XDocument.LoadAsync(stream, LoadOptions.None,
            CancellationToken.None).ConfigureAwait(false);

        XNamespace nsVer1 = "urn:schemas-microsoft-com:asm.v1";
        XNamespace nsVer2 = "urn:schemas-microsoft-com:asm.v2";

        XElement? xmlElement = xmlDoc.Descendants(nsVer1 + "assemblyIdentity")
                                     .FirstOrDefault();

        if (xmlElement == null)
            throw GenerateExceptionAndLogIt(
                $"Invalid manifest document for {_applicationName}.application");

        string? version = xmlElement.Attribute("version")?.Value;

        if (string.IsNullOrEmpty(version))
            throw GenerateExceptionAndLogIt($"Version info is empty!");

        // get optional minim version - not always set
        string? minVersion = xmlDoc.Descendants(nsVer2 + "deployment")
            .FirstOrDefault()?
            .Attribute("minimumRequiredVersion")?
            .Value;

        if (!string.IsNullOrEmpty(minVersion))
            _minimumServerVersion = new Version(minVersion);

        _serverVersion = new Version(version);
        return _serverVersion;
    }

    private async Task GetSetupFromServerAsync()
    {
        string downLoadFolder = KnownFolder.GetDownloadsPath();
        Uri uri = new($"{_options.PublishingPath!}setup.exe");

        if (_serverVersion == null)
            await ServerVersionAsync().ConfigureAwait(false);

        _setupPath = Path.Combine(downLoadFolder, $"setup{_serverVersion}.exe");

        HttpResponseMessage? response;

        try
        {
            using HttpClient client = HttpClientFactory();

            response = await client.GetAsync(uri).ConfigureAwait(false);

            if (response is null)
                throw GenerateExceptionAndLogIt("Error retrieving from server");
        }
        catch (Exception ex)
        {
            _setupPath = string.Empty;
            throw GenerateExceptionAndLogIt(
                $"Unable to retrieve setup from server: {ex.Message}", ex);
        }

        try
        {
            if (File.Exists(_setupPath))
                File.Delete(_setupPath);

            await using FileStream fs = new(_setupPath, FileMode.CreateNew);
            await response.Content.CopyToAsync(fs).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            _setupPath = string.Empty;

            throw GenerateExceptionAndLogIt(
                $"Unable to save setup information: {ex.Message}", ex);
        }
    }

    private static Process? OpenUrl(string url)
    {
        try
        {
            return Process.Start(new ProcessStartInfo(url)
            {
                CreateNoWindow = true,
                WindowStyle = ProcessWindowStyle.Hidden,
                RedirectStandardInput = true,
                RedirectStandardOutput = false,
                UseShellExecute = false
            });
        }
        catch
        {
            // hack because of this: https://github.com/dotnet/corefx/issues/10361
            return Process.Start(new ProcessStartInfo("cmd",
                $"/c start \"\"{url.Replace("&", "^&")}\"\"")
            {
                CreateNoWindow = true,
                WindowStyle = ProcessWindowStyle.Hidden,
                RedirectStandardInput = true,
                RedirectStandardOutput = false,
                UseShellExecute = false,
            });
        }
    }

    private HttpClient HttpClientFactory(Uri? uri = null)
    {
        _logger.Emit(EventId, LogLevel.Debug,
            $"HttpClientFactory > returning HttpClient for url: {(uri is null ?
            "[to ba allocated]" : uri.ToString())}");

        HttpClient client = _httpClientFactory!.CreateClient(HttpClientKey);

        if (uri is not null)
            client.BaseAddress = uri;

        return client;
    }

    private Exception GenerateExceptionAndLogIt(string message, Exception? ex = null,
        [CallerMemberName] string? callerName = "")
    {
        ClickOnceDeploymentException exception = new(message);
        EventId eid = new(EventId.Id, $"{EventId.Name}.{callerName}");

        if (string.IsNullOrWhiteSpace(message))
            message = "no message";

        if (ex is not null)
        {
            _logger.Emit(eid, LogLevel.Error, message, ex);
            return ex;
        }

        _logger.Emit(eid, LogLevel.Warning, message);

        return exception;
    }

    private void OnUpdateDetected()
        => UpdateDetected?.Invoke(this, EventArgs.Empty);

    private void OnUpdateReady()
        => UpdateReady?.Invoke(this, EventArgs.Empty);

    private void OnUpdateCheck()
        => UpdateCheck?.Invoke(this, EventArgs.Empty);

    #endregion

    #endregion

    #endregion
}
VB.NET
Public Delegate Sub UpdateDetectedEventHandler(sender As Object, e As EventArgs)
Public Delegate Sub UpdateReadyEventHandler(sender As Object, e As EventArgs)
Public Delegate Sub UpdateCheckEventHandler(sender As Object, e As EventArgs)

Public NotInheritable Class ClickOnceUpdateService
    Inherits BackgroundService
    Implements IClickOnceUpdateService

#Region "Constructors"
     Public Sub New(
        options As IOptions(Of ClickOnceUpdateOptions),
        httpClientFactory As IHttpClientFactory,
        logger As ILogger(Of ClickOnceUpdateService))

        _options = options.Value
        _httpClientFactory = httpClientFactory
        _logger = logger

        Initialize()

    End Sub

    Public Sub New(
        options As ClickOnceUpdateOptions,
        Optional logger As ILogger(Of ClickOnceUpdateService) = Nothing)

        _options = options
        _logger = logger

        ' not using DI ... new up manually
        CreateHttpClient()

        Initialize()

    End Sub

#End Region

#Region "Fields"
 #Region "Injected"
     Private ReadOnly _options As ClickOnceUpdateOptions
    Private _httpClientFactory As IHttpClientFactory
    Private ReadOnly _logger As ILogger

#End Region

    Public Const SectionKey As String = "ClickOnce"
    Public Const HttpClientKey As String = _
           NameOf(ClickOnceUpdateService) & "_httpclient"

    Private ReadOnly EventId As EventId = New EventId(id:=&H1A4, name:="ClickOnce")

    Private _isNetworkDeployment As Boolean
    Private _applicationName As String
    Private _applicationPath As String
    Private _dataDirectory As String
    Private _installFrom As InstallFrom

    Private _isProcessing As Boolean

#Region "Cached"
     Private _minimumServerVersion As Version
    Private _currentVersion As Version
    Private _serverVersion As Version
    Private _setupPath As String

#End Region

#End Region

#Region "Properties"
     ''' The full application name
    ''' </summary>
    Public ReadOnly Property ApplicationName As String
        Implements IClickOnceUpdateService.ApplicationName
        Get
            Return _applicationName
        End Get
    End Property

    ''' <summary>
    ''' The path to where the application was installed
    ''' </summary>
    Public ReadOnly Property ApplicationPath As String
        Implements IClickOnceUpdateService.ApplicationPath
        Get
            Return _applicationPath
        End Get
    End Property

    ''' <summary>
    ''' Was the application installed   
    ''' </summary>
    Public ReadOnly Property IsNetworkDeployment As Boolean
        Implements IClickOnceUpdateService.IsNetworkDeployment
        Get
            Return _isNetworkDeployment
        End Get
    End Property

    ''' <summary>
    ''' The path to the stored application data
    ''' </summary>
    Public ReadOnly Property DataDirectory As String
        Implements IClickOnceUpdateService.DataDirectory
        Get
            Return _dataDirectory
        End Get
    End Property

    ''' <summary>
    ''' Is there an update ready 
    ''' </summary>
    Public Property IsUpdatingReady As Boolean
        Implements IClickOnceUpdateService.IsUpdatingReady

    ''' <summary>
    ''' Current installed version Is lower that the remote minimum version required 
    ''' </summary>
    Public ReadOnly Property IsMandatoryUpdate As Boolean
        Implements IClickOnceUpdateService.IsMandatoryUpdate
        Get
            Return IsUpdatingReady AndAlso
                   _minimumServerVersion IsNot Nothing AndAlso
                   _currentVersion IsNot Nothing AndAlso
                   _minimumServerVersion > _currentVersion
        End Get
    End Property

    ''' <summary>
    ''' Server path to installation files &amp; manifest
    ''' </summary>
    Public ReadOnly Property PublishingPath As String
        Implements IClickOnceUpdateService.PublishingPath
        Get
            Return _options.PublishingPath
        End Get
    End Property

    ''' <summary>
    ''' How often in milliseconds to check for updates (minimum 1000ms / 1 second) 
    ''' </summary>
    Public ReadOnly Property RetryInterval As Integer
        Implements IClickOnceUpdateService.RetryInterval
        Get
            Return _options.RetryInterval
        End Get
    End Property

    ''' <summary>
    ''' Found an update And has begun preparing
    ''' </summary>
    Public Event UpdateDetected As UpdateDetectedEventHandler
        Implements IClickOnceUpdateService.UpdateDetected

    ''' <summary>
    ''' Update Is ready And a restart Is required
    ''' </summary>
    Public Event UpdateReady As UpdateReadyEventHandler
        Implements IClickOnceUpdateService.UpdateReady

    ''' <summary>
    ''' An update check Is in progress
    ''' </summary>
    Public Event UpdateCheck As UpdateCheckEventHandler
        Implements IClickOnceUpdateService.UpdateCheck

#End Region

#Region "BackgroundService"
     ''' <inheritdoc />
    Protected Overrides Async Function ExecuteAsync(
        stoppingToken As CancellationToken) As Task

        While Not stoppingToken.IsCancellationRequested

            _logger.Emit(EventId, LogLevel.Information, "Waiting")

            ' wait for a pre-determined interval
            Await Task.Delay(_options.RetryInterval, _
                             stoppingToken).ConfigureAwait(False)

            If stoppingToken.IsCancellationRequested Then
                Exit While
            End If

            ' heartbeat logging
            _logger.Emit(EventId, LogLevel.Information, "Checking for an update")

            ' health check tick
            OnUpdateCheck()

            Try
                ' Stop checking if there is an update (already logged)
                If Await CheckHasUpdate().ConfigureAwait(False) Then
                    Exit While
                End If

            Catch __unusedClickOnceDeploymentException1__ _
                  As ClickOnceDeploymentException

                ' already handled, ignore and continue

            Catch ex As HttpRequestException

                ' website appears to be offline / can't find setup. Log and continue
                _logger.Emit(EventId, LogLevel.[Error], ex.Message, ex)

            Catch ex As Exception

                ' we hit a major issue, log & shut down
                _logger.LogError(EventId, ex.Message, ex)

                Exit While

            End Try

        End While

        _logger.Emit(EventId, LogLevel.Information, "Stopped")

    End Function

    ''' <inheritdoc />
    Public Overrides Async Function StartAsync(
        cancellationToken As CancellationToken) As Task
        Implements IHostedService.StartAsync

        _logger.Emit(EventId, LogLevel.Information, "Starting")

        ' safe guard against self-DDoS .. do not want to spam own web server
        If _options.RetryInterval < 1000 Then
            _options.RetryInterval = 1000
        End If

        Await MyBase.StartAsync(cancellationToken).ConfigureAwait(False)

    End Function

    ''' <inheritdoc />
    Public Overrides Async Function StopAsync(
        cancellationToken As CancellationToken) As Task
        Implements IHostedService.StopAsync

        _logger.Emit(EventId, LogLevel.Information, "Stopping")
        Await MyBase.StopAsync(cancellationToken).ConfigureAwait(False)

    End Function

#End Region

#Region "Methods"
 #Region "Manual HttpCleintFactory for non-DI"
     Private Sub CreateHttpClient()

        Dim builder = New ServiceCollection()
        builder.AddHttpClient(HttpClientKey)
        Dim serviceProvider As ServiceProvider = builder.BuildServiceProvider()

        _httpClientFactory = serviceProvider.GetRequiredService(Of IHttpClientFactory)()

    End Sub

#End Region

#Region "Partial 'ApplicationDeployment' implewmentation"
 #Region "Public"
     ''' <summary>
    ''' Get the current installed version 
    ''' </summary>
    ''' <returns><see cref="T:System.Version" /></returns>
    Public Async Function CurrentVersionAsync() As Task(Of Version)
        Implements IClickOnceUpdateService.CurrentVersionAsync

        If Not IsNetworkDeployment Then
            Throw GenerateExceptionAndLogIt("Not deployed by network!")
        End If

        If String.IsNullOrEmpty(_applicationName) Then
            Throw GenerateExceptionAndLogIt("Application name is empty!")
        End If

        If _currentVersion IsNot Nothing Then
            Return _currentVersion
        End If

        Dim filePath = _
            Path.Combine(_applicationPath!, $"{_applicationName}.exe.manifest")

        If Not File.Exists(filePath) Then
            Throw GenerateExceptionAndLogIt_
            ($"Can't find manifest file at path {filePath}")
        End If

        _logger.Emit(EventId, LogLevel.Debug, _
                     $"Looking for local manifest: {filePath}")

        Dim fileContent = Await File.ReadAllTextAsync(filePath).ConfigureAwait(False)

        Dim xmlDoc = XDocument.Parse(fileContent, LoadOptions.None)
        Dim nsSys As XNamespace = "urn:schemas-microsoft-com:asm.v1"
        Dim xmlElement = xmlDoc.Descendants(nsSys + "assemblyIdentity").FirstOrDefault()

        If xmlElement Is Nothing Then
            Throw GenerateExceptionAndLogIt($"Invalid manifest document for {filePath}")
        End If

        Dim version = xmlElement.Attribute("version")?.Value

        If String.IsNullOrEmpty(version) Then
            Throw GenerateExceptionAndLogIt("Local version info is empty!")
        End If

        _currentVersion = New Version(version)
        Return _currentVersion

    End Function

    ''' <summary>
    ''' Get the remote server version
    ''' </summary>
    ''' <returns><see cref="T:System.Version" /></returns>
    Public Async Function ServerVersionAsync() As Task(Of Version)
        Implements IClickOnceUpdateService.ServerVersionAsync

        If _installFrom = InstallFrom.Web Then

            Try
                Using client As HttpClient = HttpClientFactory( _
                    New Uri(_options.PublishingPath))

                    _logger.Emit(EventId, LogLevel.Debug,
                        $"Looking for remote manifest: {_options.PublishingPath}
                          {_applicationName}.application")

                    Using stream = Await client.GetStreamAsync(
                        $"{_applicationName}.application").ConfigureAwait(False)

                        Dim version = Await ReadServerManifestAsync(stream)
                                                .ConfigureAwait(False)

                        If version Is Nothing Then
                            Throw GenerateExceptionAndLogIt_
                                  ("Remote version info is empty!")
                        End If

                        Return version

                    End Using
                End Using

            Catch ex As Exception

                Throw GenerateExceptionAndLogIt($"{ex.Message}")

            End Try

        End If

        If _installFrom <> InstallFrom.Unc Then
            Throw GenerateExceptionAndLogIt("No network install was set")
        End If

        Try
            Using stream As FileStream = File.OpenRead(Path.Combine(
                $"{_options.PublishingPath}", $"{_applicationName}.application"))

                Return Await ReadServerManifestAsync(stream).ConfigureAwait(False)

            End Using

        Catch ex As Exception
            Throw GenerateExceptionAndLogIt(ex.Message)
        End Try

    End Function

    ''' <summary>
    ''' Manually check if there Is a newer version
    ''' </summary>
    ''' <returns><see langword="true" /> 
    ''' if there Is a newer version available</returns>
    Public Async Function UpdateAvailableAsync() As Task(Of Boolean)
        Implements IClickOnceUpdateService.UpdateAvailableAsync

        Return Await CurrentVersionAsync().ConfigureAwait(False) <
               Await ServerVersionAsync().ConfigureAwait(False)

    End Function

    ''' <summary>
    ''' Prepare to update the application 
    ''' by downloading the New setup to do the updating
    ''' </summary>
    ''' <returns><see langword="true" /> if successful</returns>
    Public Async Function PrepareForUpdatingAsync() As Task(Of Boolean)
        Implements IClickOnceUpdateService.PrepareForUpdatingAsync

        ' Nothing to update
        If Not Await UpdateAvailableAsync().ConfigureAwait(False) Then
            Return False
        End If

        _isProcessing = True

        Select Case _installFrom

            Case InstallFrom.Web
                Await GetSetupFromServerAsync().ConfigureAwait(False)

            Case InstallFrom.Unc
                _setupPath = Path.Combine($"{_options.PublishingPath}",
                                          $"{_applicationName}.application")

            Case Else
                Throw GenerateExceptionAndLogIt("No network install was set")

        End Select

        _isProcessing = False
        Return True

    End Function

    ''' <summary>
    ''' Start the update process
    ''' </summary>
    ''' <returns>A task that represents the asynchronous execute operation.</returns>
    Public Async Function ExecuteUpdateAsync() As Task 
        Implements IClickOnceUpdateService.ExecuteUpdateAsync

        If _setupPath Is Nothing Then
            Throw GenerateExceptionAndLogIt("No update available.")
        End If

        Dim process = OpenUrl(_setupPath)

        If (process Is Nothing) Then
            Throw GenerateExceptionAndLogIt("No update available.")
        End If

        Await process.WaitForExitAsync().ConfigureAwait(False)

        If Not String.IsNullOrEmpty(_setupPath) Then
            File.Delete(_setupPath)
        End If

    End Function

#End Region

#Region "Internals"
     Private Sub Initialize()

        _applicationPath = If(AppDomain.CurrentDomain.SetupInformation.ApplicationBase,
                              String.Empty)

        _applicationName = If(Assembly.GetEntryAssembly()?.GetName().Name, String.Empty)
        _isNetworkDeployment = CheckIsNetworkDeployment()

        If String.IsNullOrEmpty(_applicationName) Then
            Throw GenerateExceptionAndLogIt("Can't find entry assembly name!")
        End If

        If _isNetworkDeployment AndAlso Not String.IsNullOrEmpty(_applicationPath) Then

            Dim programData As String = Path.Combine(GetLocalApplicationData(),
                                                     "Apps\2.0\Data\")

            Dim currentFolderName As String = _
            New DirectoryInfo(_applicationPath).Name
            _dataDirectory = ApplicationDataDirectory_
                             (programData, currentFolderName, 0)

        Else

            _dataDirectory = String.Empty

        End If

        SetInstallFrom()

    End Sub

    Private Async Function CheckHasUpdate() As Task(Of Boolean)

        If _isProcessing OrElse Not Await _
           UpdateAvailableAsync().ConfigureAwait(False) Then
            Return False
        End If

        OnUpdateDetected()

        _logger.Emit(
            EventId,
            LogLevel.Information,
            "New version identified. Current: {current}, Server: {server}",
            _currentVersion,
            _serverVersion)

        If Await PrepareForUpdatingAsync().ConfigureAwait(False) Then

            _logger.Emit(EventId, LogLevel.Information, _
                         "Update is ready for processing.")

            IsUpdatingReady = True
            OnUpdateReady()
            Return True

        End If

        Return False

    End Function

    Private Function CheckIsNetworkDeployment() As Boolean
        Return Not String.IsNullOrEmpty(_applicationPath) AndAlso
               _applicationPath.Contains("AppData\Local\Apps")
    End Function

    Private Sub SetInstallFrom()

        _installFrom = If(_isNetworkDeployment AndAlso Not
                          String.IsNullOrEmpty(_options.PublishingPath),
                   If(_options.PublishingPath.StartsWith("http"),
                      InstallFrom.Web,
                      InstallFrom.Unc),
                   InstallFrom.NoNetwork)

    End Sub

    Private Function ApplicationDataDirectory(programData As String,
        currentFolderName As String, depth As Integer) As String

        depth += 1

        If depth > 100 Then
            Throw GenerateExceptionAndLogIt(
                $"Can't find data dir for {currentFolderName} in path: {programData}")
        End If

        Dim result = String.Empty

        For Each dir As String In Directory.GetDirectories(programData)

            If dir.Contains(currentFolderName) Then
                result = Path.Combine(dir, "Data")
                Exit For
            End If

            result = ApplicationDataDirectory(Path.Combine(programData, dir),
                                              currentFolderName, depth)

            If Not String.IsNullOrEmpty(result) Then
                Exit For
            End If
        Next

        Return result

    End Function

    Private Async Function ReadServerManifestAsync(stream As Stream) _
            As Task(Of Version)

        Dim xmlDoc As XDocument = Await XDocument.LoadAsync(stream,
            LoadOptions.None, CancellationToken.None)
            .ConfigureAwait(False)

        Dim nsVer1 As XNamespace = "urn:schemas-microsoft-com:asm.v1"
        Dim nsVer2 As XNamespace = "urn:schemas-microsoft-com:asm.v2"

        Dim xmlElement As XElement = xmlDoc.Descendants(nsVer1 + "assemblyIdentity")
                                           .FirstOrDefault()

        If xmlElement Is Nothing Then
            Throw GenerateExceptionAndLogIt(
                $"Invalid manifest document for {_applicationName}.application")
        End If

        Dim version As String = xmlElement.Attribute("version").Value

        If String.IsNullOrEmpty(version) Then
            Throw GenerateExceptionAndLogIt($"Version info is empty!")
        End If

        ' get optional minim version - not always set

        xmlElement = xmlDoc.Descendants(nsVer2 + "deployment").FirstOrDefault()

        If xmlElement IsNot Nothing AndAlso
           xmlElement.HasAttributes AndAlso
           xmlElement.Attributes.Any(Function(x)
               x.Name.ToString().Equals("minimumRequiredVersion")) Then

            Dim minVersion = xmlElement.Attribute("minimumRequiredVersion").Value

            If Not String.IsNullOrEmpty(minVersion) Then
                _minimumServerVersion = New Version(minVersion)
            End If

        End If

        _serverVersion = New Version(version)
        Return _serverVersion

    End Function

    Private Async Function GetSetupFromServerAsync() As Task

        Dim downLoadFolder = GetDownloadsPath()
        Dim uri = New Uri($"{_options.PublishingPath}setup.exe")

        If _serverVersion Is Nothing Then
            Await ServerVersionAsync().ConfigureAwait(False)
        End If

        _setupPath = Path.Combine(downLoadFolder, $"setup{_serverVersion}.exe")

        Dim response As HttpResponseMessage

        Try
            Using client As HttpClient = HttpClientFactory()

                response = Await client.GetAsync(uri).ConfigureAwait(False)

                If response Is Nothing Then
                    Throw GenerateExceptionAndLogIt("Error retrieving from server")
                End If

            End Using

        Catch ex As Exception

            _setupPath = String.Empty
            Throw GenerateExceptionAndLogIt(
                $"Unable to retrieve setup from server: {ex.Message}", ex)

        End Try

        Try

            If File.Exists(_setupPath) Then
                File.Delete(_setupPath)
            End If

            Using fs = New FileStream(_setupPath, FileMode.CreateNew)
                Await response.Content.CopyToAsync(fs).ConfigureAwait(False)
            End Using

        Catch ex As Exception

            _setupPath = String.Empty

            Throw GenerateExceptionAndLogIt(
                $"Unable to save setup information: {ex.Message}", ex)

        End Try

    End Function

    Private Shared Function OpenUrl(url As String) As Process

        Try
            Return Process.Start(New ProcessStartInfo(url) With
                   {
                        .CreateNoWindow = True,
                        .WindowStyle = ProcessWindowStyle.Hidden,
                        .RedirectStandardInput = True,
                        .RedirectStandardOutput = False,
                        .UseShellExecute = False
                   })
        Catch
            ' hack because of this: https://github.com/dotnet/corefx/issues/10361
            Return Process.Start(New ProcessStartInfo("cmd",
                $"/c start \" \ "{url.Replace(" & ", " ^ " + " & ")}\" \ "") With
                   {
                        .CreateNoWindow = True,
                        .WindowStyle = ProcessWindowStyle.Hidden,
                        .RedirectStandardInput = True,
                        .RedirectStandardOutput = False,
                        .UseShellExecute = False
                   })
        End Try

    End Function

    Private Function HttpClientFactory(Optional uri As Uri = Nothing) As HttpClient

        _logger.Emit(EventId, LogLevel.Debug,
                     $"HttpClientFactory > returning httpclient for url:
                       {If(uri Is Nothing, "[to ba allocated]", uri.ToString())}")

        Dim client As HttpClient = _httpClientFactory.CreateClient(HttpClientKey)

        If uri IsNot Nothing Then
            client.BaseAddress = uri
        End If

        Return client

    End Function

    Private Function GenerateExceptionAndLogIt(
        message As String,
        Optional ex As Exception = Nothing,
        <CallerMemberName> Optional callerName As String = "")
            As ClickOnceDeploymentException

        Dim exception = New ClickOnceDeploymentException(message)
        Dim eid = New EventId(
            EventId.Id,
            $"{EventId.Name}.{If(String.IsNullOrWhiteSpace(callerName),
            "[callerName missing]", callerName)}")

        If String.IsNullOrWhiteSpace(message) Then
            message = "no message"
        End If

        If ex IsNot Nothing Then
            _logger.Emit(eid, LogLevel.Error, message, ex)
            Return ex
        End If

        _logger.Emit(eid, LogLevel.Warning, message)

        Return exception

    End Function

    Private Sub OnUpdateDetected()
        RaiseEvent UpdateDetected(Me, EventArgs.Empty)
    End Sub

    Private Sub OnUpdateReady()
        RaiseEvent UpdateReady(Me, EventArgs.Empty)
    End Sub

    Private Sub OnUpdateCheck()
        RaiseEvent UpdateCheck(Me, EventArgs.Empty)
    End Sub

#End Region

#End Region

#End Region

End Class

NOTE

  • The ClickOnceUpdateService is designed to support both Dependency Injection and manual initialization (no dependency injection). For no dependency injection, the class internally handles the HttpClient to avoid resource depletion.
ClickOnceUpdateService - Properties
Property Description
ApplicationName The full application name
ApplicationPath The path to where the application was installed
DataDirectory The path to the stored application data
IsNetworkDeployment Was the application installed
IsUpdatingReady Is there an update ready
IsMandatoryUpdate The currently installed version is lower that the remote minimum version required
PublishingPath Server path to installation files & manifest
RetryInterval How often in milliseconds to check for updates (minimum 1000ms / 1 second)

Knowing where the application files and the data files are on the computer is now a simple task - ApplicationPath & DataDirectory properties expose this information.

ClickOnceUpdateService - Methods
Methods Description
CurrentVersionAsync Get the currently installed version
ServerVersionAsync Get the remote server version
UpdateAvailableAsync Manually check if there is a newer version
PrepareForUpdatingAsync Prepare to update the application
ExecuteUpdateAsync Start the update process

The process of preparing updates and applying the updates are a manual process. This allows the application to give the user options on how and when the updates are applied.

ClickOnceUpdateService - Background Service Methods
Methods Description
StartAsync Start checking for updates
StopAsync Stop checking for updates
ClickOnceUpdateService - Events
Events Description
UpdateCheck Letting the application know that an update check is in progress
UpdateDetected Found an update and have begun preparing
UpdateReady An update is ready and a restart is required

A CancellationToken can be passed on starting the service for remote cancellation.

If an update is found, the service will automatically stop polling for updates.

Implementation

There are two-parts to implementing support for ClickOnceUpdateService:

  1. Starting the service, unhandled application exceptions, and rebooting into the new version.
  2. User feedback and interaction

Implementation for WinForms and WPF applications is slightly different. Each will be covered individually.

WinForms Implementation - Simple/Minimal

For minimal implementation, without Dependency Injection, we need to:

  1. Reference the ClickOnceUpdateService and pass in the configuration settings.
  2. Hook the UpdateCheck and UpdateReady events.
  3. Start the background service ClickOnceUpdateService.
  4. When the update is ready, restart the application using the ExecuteUpdateAsync method and the update will download, install, and restart the application.

Below is the sample code for the above steps:

C#
public partial class Form1 : Form
{
    #region Constructors

    public Form1()
    {
        InitializeComponent();

        Configure();

        // no need to await as it is a background task
        _ = StartServiceAsync();
    }

    #endregion

    #region Fields

    private ClickOnceUpdateService? _updateService;

    #endregion

    #region Methods

    private void Configure()
    {
        ClickOnceUpdateOptions options = AppSettings<ClickOnceUpdateOptions>
            .Current("ClickOnce") ?? new()
            {
                // defaults if 'appsetting.json' file(s) is unavailable
                RetryInterval = 1000,
                PublishingPath = _
                "http://silentupdater.net:5216/Installer/WinFormsSimple/"
            };

        _updateService = new ClickOnceUpdateService(options);

        _updateService.UpdateCheck += OnUpdateCheck;
        _updateService.UpdateReady += OnUpdateReady;
    }

    private async Task StartServiceAsync()
    {
        await _updateService!.StartAsync_
             (CancellationToken.None).ConfigureAwait(false);

        try
        {
            Version currentVersion = await _updateService.CurrentVersionAsync()
                                                         .ConfigureAwait(false);
            DispatcherHelper.Execute(() =>
                labCurrentVersion.Text = currentVersion.ToString());
        }
        catch (ClickOnceDeploymentException ex)
        {
            DispatcherHelper.Execute(() => labCurrentVersion.Text = ex.Message);
        }
    }

    private async void OnUpdateReady(object? sender, EventArgs e)
    {
        Version serverVersion = await _updateService!.ServerVersionAsync()
                                                     .ConfigureAwait(false);
        DispatcherHelper.Execute(() =>
        {
            labUpdateStatus.Text = 
                @$"Ready To Update. New version is {serverVersion}. Please restart.";
            btnUpdate.Enabled = true;
        });
    }

    private void OnUpdateCheck(object? sender, EventArgs e)
        => DispatcherHelper.Execute(() => 
            labUpdateStatus.Text = $@"Last checked at {DateTime.Now}");

    private void OnUpdateClick(object sender, EventArgs e)
        => _ = RestartAsync();

    private async Task RestartAsync()
    {
        if (_updateService!.IsUpdatingReady)
            await _updateService.ExecuteUpdateAsync();

        Application.Exit();
    }

    // optional cleaning up...
    #endregion

    private void OnClosingForm(object sender, FormClosingEventArgs e)
    {
        _updateService!.UpdateCheck -= OnUpdateCheck;
        _updateService.UpdateReady -= OnUpdateReady;

        _ = _updateService.StopAsync(CancellationToken.None);
    }
}
VB.NET
Public Class Form1

#Region "Constructors"
     Public Sub New()

        InitializeComponent()

        Configure()
        Dim task = StartServiceAsync()

    End Sub

#End Region

#Region "Fields"
     Private _updateService As ClickOnceUpdateService

#End Region

#Region "Methods"
     Private Sub Configure()

        Dim options As ClickOnceUpdateOptions = _
             AppSettings(Of ClickOnceUpdateOptions) _
            .Current("ClickOnce")

        If options Is Nothing Then
            options = New ClickOnceUpdateOptions() With
            {
                .RetryInterval = 1000,
                .PublishingPath = _
                 "http://silentupdater.net:5218/Installer/WinFormsSimpleVB/"
            }
        End If

        _updateService = New ClickOnceUpdateService(options)

        AddHandler _updateService.UpdateCheck, AddressOf OnUpdateCheck
        AddHandler _updateService.UpdateReady, AddressOf OnUpdateReady

    End Sub

    Private Async Function StartServiceAsync() As Task

        Await _updateService.StartAsync(CancellationToken.None).ConfigureAwait(False)

        Try

            Dim currentVersion As Version = Await _updateService.CurrentVersionAsync() _
                                                                .ConfigureAwait(False)

            'DispatcherHelper
            Execute(Sub() labCurrentVersion.Text = currentVersion.ToString())

        Catch ex As ClickOnceDeploymentException

            ' DispatcherHelper
            Execute(Sub() labCurrentVersion.Text = ex.Message)

        End Try

    End Function

    Private Sub OnUpdateCheck(sender As Object, e As EventArgs)

        Debug.WriteLine("OnUpdateCheck")

        'DispatcherHelper
        Execute(Sub() labUpdateStatus.Text = $"Last checked at {Now}")

    End Sub

    Private Async Sub OnUpdateReady(sender As Object, e As EventArgs)

        Debug.WriteLine("OnUpdateReady")

        Dim serverVersion As Version = Await _updateService.ServerVersionAsync() _
                                                           .ConfigureAwait(False)

        'DispatcherHelper
        Execute(
            Sub()
                labUpdateStatus.Text = 
                    $"Ready To Update. New version is {serverVersion}. Please restart."
                btnUpdate.Enabled = True
            End Sub)

    End Sub

    Private Sub OnUpdateClick(sender As Object, e As EventArgs)
        Handles btnUpdate.Click

        Dim task = RestartAsync()

    End Sub

    Private Async Function RestartAsync() As Task

        If _updateService.IsUpdatingReady Then
            Await _updateService.ExecuteUpdateAsync()
        End If

        Forms.Application.Exit()

    End Function

    ' optional cleaning up...
    Private Sub OnClosingForm(sender As Object, e As FormClosingEventArgs)
        Handles MyBase.FormClosing

        RemoveHandler _updateService.UpdateCheck, AddressOf OnUpdateCheck
        RemoveHandler _updateService.UpdateReady, AddressOf OnUpdateReady

        Dim task = _updateService.StopAsync(CancellationToken.None)

    End Sub

#End Region

End Class

NOTE

  • In the above example, we use the AppSettings helper class to load the configuration from the appsettings*.json file. There is a separate article that discusses how it works: .NET App Settings Demystified (C# & VB).

Here is an animation for the simple/minimal implementation:

Image 8

WPF Implementation - Simple/Minimal

For WPF, the process is the same as WinForms:

  1. Reference the ClickOnceUpdateService and pass in the configuration settings.
  2. Hook the UpdateCheck and UpdateReady events.
  3. Start the background service ClickOnceUpdateService.
  4. When the update is ready, restart the application using the ExecuteUpdateAsync method and the update will download, install, and restart the application.

Below is the sample code for the above steps:

C#
public partial class MainWindow
{
    #region Constructors

    public MainWindow()
    {
        InitializeComponent();

        Configure();
        _ = StartServiceAsync();
    }

    #endregion

    #region Fields

    private ClickOnceUpdateService? _updateService;

    #endregion

    #region Methods

    private void Configure()
    {
        ClickOnceUpdateOptions options = AppSettings<ClickOnceUpdateOptions>
            .Current("ClickOnce") ?? new()
        {
            // defaults if 'appsetting.json' file(s) is unavailable
            RetryInterval = 1000,
            PublishingPath = "http://silentupdater.net:5216/Installer/WinFormsSimple/"
        };

        _updateService = new ClickOnceUpdateService(options);

        _updateService.UpdateCheck += OnUpdateCheck;
        _updateService.UpdateReady += OnUpdateReady;
    }

    private async Task StartServiceAsync()
    {
        await _updateService!.StartAsync(CancellationToken.None).ConfigureAwait(false);

        try
        {
            Version currentVersion = await _updateService.CurrentVersionAsync()
                                                         .ConfigureAwait(false);

            DispatcherHelper.Execute(() =>
                labCurrentVersion.Text = currentVersion.ToString());
        }
        catch (ClickOnceDeploymentException ex)
        {
            DispatcherHelper.Execute(() => labCurrentVersion.Text = ex.Message);
        }
    }

    private async void OnUpdateReady(object? sender, EventArgs e)
    {
        Version serverVersion = await _updateService!.ServerVersionAsync()
                                                     .ConfigureAwait(false);
        DispatcherHelper.Execute(() =>
        {
            labUpdateStatus.Text =
                @$"Ready To Update. New version is {serverVersion}. Please restart.";
            btnUpdate.IsEnabled = true;
        });
    }

    private void OnUpdateCheck(object? sender, EventArgs e)
        => DispatcherHelper.Execute(() => labUpdateStatus.Text =
            $@"Last checked at {DateTime.Now}");

    private void OnUpdateClick(object sender, RoutedEventArgs e)
        => _ = RestartAsync();

    private async Task RestartAsync()
    {
        if (_updateService!.IsUpdatingReady)
            await _updateService.ExecuteUpdateAsync();

        Application.Current.Shutdown();
    }

    // optional cleaning up...
    private void OnClosing(object? sender, CancelEventArgs e)
    {
        _updateService!.UpdateCheck -= OnUpdateCheck;
        _updateService.UpdateReady -= OnUpdateReady;

        _ = _updateService.StopAsync(CancellationToken.None);
    }

    #endregion
}
VB.NET
Class MainWindow

#Region "Constructors"
     Public Sub New()

        InitializeComponent()

        Configure()
        Dim task = StartServiceAsync()

    End Sub

#End Region

#Region "Fields"
     Private _updateService As ClickOnceUpdateService

#End Region

#Region "Methods"
     Private Sub Configure()

        Dim options As ClickOnceUpdateOptions = AppSettings(Of ClickOnceUpdateOptions) _
                .Current("ClickOnce")

        If options Is Nothing Then
            options = New ClickOnceUpdateOptions() With
                {
                .RetryInterval = 1000,
                .PublishingPath = "http://silentupdater.net:5218/Installer/WpfSimpleVB/"
                }
        End If

        _updateService = New ClickOnceUpdateService(options)

        AddHandler _updateService.UpdateCheck, AddressOf OnUpdateCheck
        AddHandler _updateService.UpdateReady, AddressOf OnUpdateReady

    End Sub

    Private Async Function StartServiceAsync() As Task

        Await _updateService.StartAsync(CancellationToken.None).ConfigureAwait(False)

        Try

            Dim currentVersion As Version = Await _updateService.CurrentVersionAsync() _
                                                                .ConfigureAwait(False)

            'DispatcherHelper
            Execute(Sub() labCurrentVersion.Text = currentVersion.ToString())

        Catch ex As ClickOnceDeploymentException

            'DispatcherHelper
            Execute(Sub() labCurrentVersion.Text = ex.Message)

        End Try

    End Function

    Private Sub OnUpdateCheck(sender As Object, e As EventArgs)

        Debug.WriteLine("OnUpdateCheck")

        'DispatcherHelper
        Execute(Sub() labUpdateStatus.Text = $"Last checked at {Now}")

    End Sub

    Private Async Sub OnUpdateReady(sender As Object, e As EventArgs)

        Debug.WriteLine("OnUpdateReady")

        Dim serverVersion As Version = Await _updateService.ServerVersionAsync() _
                                                           .ConfigureAwait(False)

        'DispatcherHelper
        Execute(
            Sub()
                labUpdateStatus.Text =
                    $"Ready To Update. New version is {serverVersion}. Please restart."
                btnUpdate.IsEnabled = True
            End Sub)

    End Sub

    Private Sub OnUpdateClick(sender As Object, e As RoutedEventArgs)

        Dim task = RestartAsync()

    End Sub

    Private Async Function RestartAsync() As Task

        If _updateService.IsUpdatingReady Then
            Await _updateService.ExecuteUpdateAsync()
        End If

        Application.Current.Shutdown()

    End Function

    ' optional cleaning up...
    Private Sub OnClosingWindow(sender As Object, e As CancelEventArgs)

        RemoveHandler _updateService.UpdateCheck, AddressOf OnUpdateCheck
        RemoveHandler _updateService.UpdateReady, AddressOf OnUpdateReady

        Dim task = _updateService.StopAsync(CancellationToken.None)

    End Sub

#End Region

End Class

NOTE

  • In the above example, we use the AppSettings helper class to load the configuration from the appsettings*.json file. There is a separate article that discusses how it works: .NET App Settings Demystified (C# & VB).

StatusBar Notification Example

If you want a more polished experience for your users, I have included a sample Statusbar implementation for communicating the application version and when a new version is available.

Image 9

Dependency Injection Support

As the ClickOnceUpdateService implementation uses Microsoft.Extensions.Hosting.BackgroundService class, it is fully compliant with Microsoft Hosting for managing the service state.

The wiring up of the service for dependency injection is wrapped in a ServicesExtension class:

C#
public static class ServicesExtension
{
    public static HostApplicationBuilder AddClickOnceMonitoring(
        this HostApplicationBuilder builder)
    {
        builder.Services.Configure<ClickOnceUpdateOptions>
            (builder.Configuration.GetSection(ClickOnceUpdateService.SectionKey));

        builder.Services.AddSingleton<ClickOnceUpdateService>();

        builder.Services.AddHostedService(service => 
            service.GetRequiredService<ClickOnceUpdateService>());

        builder.Services.AddHttpClient(ClickOnceUpdateService.HttpClientKey);

        return builder;
    }
}
VB.NET
Public Module ServicesExtension

    <Extension>
    Public Function AddClickOnceMonitoring(builder As HostApplicationBuilder)
        As HostApplicationBuilder

        builder.Services.Configure(Of ClickOnceUpdateOptions) _
            (builder.Configuration.GetSection(ClickOnceUpdateService.SectionKey))

        builder.Services.AddSingleton(Of IClickOnceUpdateService, _
            ClickOnceUpdateService)()

        builder.Services _
            .AddHostedService(Function(service) _
                service.GetRequiredService(Of IClickOnceUpdateService))

        builder.Services.AddHttpClient(ClickOnceUpdateService.HttpClientKey)

        Return builder

    End Function

End Module

To add the service to an application, all that we need to do is:

C#
private static IHost? _host;

HostApplicationBuilder builder = Host.CreateApplicationBuilder();

builder.AddClickOnceMonitoring();

_host = builder.Build();
VB.NET
Private Shared _host As IHost

Dim builder As HostApplicationBuilder = Host.CreateApplicationBuilder()

builder.AddClickOnceMonitoring()

_host = builder.Build()

Then to start the service:

C#
private readonly CancellationTokenSource _cancellationTokenSource;

 _cancellationTokenSource = new();

 // startup background services
  _ = _host.StartAsync(_cancellationTokenSource.Token);
VB.NET
Private Shared _cancellationTokenSource As CancellationTokenSource

_cancellationTokenSource = New CancellationTokenSource()

' startup background services
Dim task = _host.StartAsync(_cancellationTokenSource.Token)

When an update is available, the service will automatically stop polling and raise the UpdateDetected and UpdateReady events. Then it is a manual process to call ExecuteUpdateAsync to complete the update process.

Here is an animation of that process in action where the StatusBar control is monitoring the ClickOnceUpdateService events and keeping the user informed:

Image 10

Whilst the animation is for a WPF application, the sample WinForms application looks and works identically. All source code is provided in the download link at the top of this article.

If you want to learn more about the LogView Control, and how it integrates with Microsoft, Serilog, NLog, or Log4Net logging frameworks, check out this dedicated article: LogViewer Control for WinForms, WPF, and Avalonia in C# & VB.

Preparing the Desktop Application for ClickOnce

Testing any ClickOnce update support requires installing and running either on a live server or localhost. This next section will cover:

  • Configuring Launch Settings
  • Creating a ClickOnce web-based installer using Publish Profiles
  • Hosting the ClickOnce installer on a local and live server
  • How to run a test web install on a local machine and the setup required
  • Avoiding common pitfalls
  • How to test the Silent Updater

When working on applications, there is a time in the development cycle when you need multiple deployment states - Development / local testing, Staging (optional but recommended), and Production / live deployment. To set this up, we need multiple Launch Profiles and Publish Profiles.

For this article, I have separated the Desktop application and Web Application / Website into 2 separate projects. This allows us to keep the website running and test multiple application builds and deployments. Just like the application, the Web Application will have its own Launch profiles depending on where it is deployed.

Setting up Launch Profiles

Both the desktop application(s) and the hosting web server have multiple profiles. Below, we cover an example for each. For the purposes of this article, our fictitious website is silentupdater.net.

Desktop Applications

We need to configure our Launch Profiles using appsettings*.json files. To do this, we require four files:

  1. appsettings.json - common to development and production
  2. appsettings.Development.json - for development configuration
  3. appsettings.Staging.json - for staging configuration
  4. appsettings.Production.json - for live/production options
Configuring for Development & Production Environments

To set the DOTNET_ENVIRONMENT variable, open the application 'Properties' by right-clicking on the application name in the Solution Explorer, navigate to Debug > General section and click on the 'open debug launch profiles UI'. This will open the Launch Profile window.

Image 11

Or we can access the Launch Profiles window from the toolbar:

Image 12

What we are interested in is the Environment variables. You can set anything here. What we need to add is the name: DOTNET_ENVIRONMENT with value: Development. There is no close button, simply close the window and chose File > Save... from the VS menu.

Image 13

A sample of a Launch Profile setting in the launchSettings.json file found in the Properties folder:

JavaScript
{
  "profiles": {
    "Development": {
      "commandName": "Project",
      "environmentVariables": {
        "DOTNET_ENVIRONMENT": "Development"
      }
    },
    "Staging": {
      "commandName": "Project",
      "environmentVariables": {
        "DOTNET_ENVIRONMENT": "Staging"
      }
    },
    "Production": {
      "commandName": "Project"
    }
  }
}

NOTE: The launchSettings.json file is only applicable to Visual Studio. It will not be copied to your compiled folder. If you do include it with your application, the runtime will ignore it. You will need to manually support this file in your application for use outside of Visual Studio.

File: appsettings.json

This is the root settings file. If a setting is here, this will not be overridden by the optional development/production settings. As we will be overriding the settings with different Launch Profiles, appsettings.json will have the default settings.

JavaScript
{
  "Logging": {
    "LogLevel": {
      "Default": "Warning",
      "System.Net.Http.HttpClient": "Warning"
    }
  }
}

File: appsettings.Development.json

We are interested in verbose logging. Also, a short RetryInterval, I have it set to 1 second, so testing is quick. Lastly, PublishingPath points to a special URL for testing in the development environment - more on this later in the article as to why we need to do this.

JavaScript
{
  "Logging": {
    "LogLevel": {
      "Default": "Trace",
      "System.Net.Http.HttpClient": "Trace",
      "ClickOnce": "Trace"
    }
  },
  "ClickOnce": {
    "PublishingPath": "http://silentupdater.net:5216/Installer/WinformsApp/",
    "RetryInterval": 1000
  }
}

File: appsettings.Production.json

We are only interested in warnings and critical logging. RetryInterval is set to every 30 seconds, and PublishingPath is pointing to our live production website.

JavaScript
{
  "Logging": {
    "LogLevel": {
      "Default": "Warning",
      "System.Net.Http.HttpClient": "Warning",
      "ClickOnce": "Warning"
    }
  },
  "ClickOnce": {
    "PublishingPath": "http://silentupdater.net:5216/Installer/WpfSimple/",
    "RetryInterval": 30000
  }
}

Selecting Profile

To select a profile to test, we can select from the Toolbar:

Image 14

Configuring Which Profile(s) to Publish

When publishing your project, it is important to set the Build Action for each of the files. You don't want to publish the development profile to the user's computer, so set the Build Action to equal None:

Image 15

The files to be published need to be set to Content:

Image 16

NOTE: If you don't get this right, regardless of the settings in your publish profile, the files marked as Build Action: None will not be included and you will encounter an installation failure. Below is an example of how it can go wrong:

Here, you can see that the appsettings.Development.json file is set to be published:

Image 17

However, as the Build Action: None was set for appsettings.Development.json, the file is missing, however, will be listed in the manifest for the publisher to install.

Image 18

Web Application

As this is a sample application, no additional configuration files are used. Here is the default appsettings.json file used for this article:

JavaScript
{
  "Logging": {
    "LogLevel": {
      "Default": "Trace",
      "Microsoft.AspNetCore": "Trace"
    }
  },
  "AllowedHosts": "*"
}

NOTE: I have used Trace level logging so that we can see exactly what is happening. However, for a live web app, you would normally use Warning for only critical information.

Setting up Publish Profiles

Desktop Applications

Next, we need to set up the publishing profiles for ClickOnce.

Target

As we are working with ClickOnce, we need to select the ClickOnce option:

Image 19

Publish Location

Here, we set the path to the path location in our website. The files need to be in the wwwroot path. This avoids the need to manually move files manually.

Image 20

Install Location

Now we need to point to where the ClickOnce will look for updates. Due to limitations, we also need to copy this path and add it to the correct appsettings*.json file.

Important: Both must be identical otherwise the update checking, or install will fail.

Image 21

Settings

Three are multiple pages of options. For silent updating, the important field is The application will check for updates must be unchecked. This will stop the Launch Application window from appearing as the application starts and allow the Silent Service to do the process in the application background after the app starts.

Image 22

Prerequisites

Image 23

Publish Options

Image 24

Notes

  • Support URL field is used by the ClickOnce installer and uninstaller processes.
  • Error URL is used by ClickOnce to automatically post any error information.

Image 25

Notes

  • Automatically generate the following webpage after publish field is only required if you want to use the default Microsoft page. My recommendation, a personal choice, is to not use this and instead use the generated *.application manifest file. The downloadable demo uses both, so you can see how they both work.

Image 26

Sign Manifests

You should always sign the ClickOnce manifests to reduce the chance of any hacking. You can either buy and use your own (really needed for released applications) or you can let VS generate one for you (good for testing only). It is a good practice to maintain even when only testing applications. This is done as part of setting up the Publish Profile. The Sign manifests section has the option to Create test certificate.

Image 27

NOTE

  • If you have multiple apps that you publish on the same website, using the Publish wizard, the certificate will be copied from the original directory which you selected. You will need to load each publish profile in a text editor and point to the directory where the certificate is found, then delete the copied version. Below, you can see how I have done this:

Image 28

Configuration

Only change these settings if you know what you are doing. The default selected should be suitable for most installations.

Image 29

Publish Window

Once the Publish Profiles are created, we can select them before publishing. It is highly recommended to rename each profile. Below we walk through this process.

Image 30

Select Rename:

Image 31

Enter the New Profile name and click the Rename button:

Image 32

Now make sure the correct publish profile is selected:

Image 33

Web Application

Image 34

Important: The key fields are Environment Variables and App URL.

Below are the Launch Settings used for this article.

JavaScript
{
    "profiles": {
        "Development_localhost_http": {
            "commandName": "Project",
                "launchBrowser": true,
                "environmentVariables": {
                "ASPNETCORE_ENVIRONMENT": "Development"
            },
            "dotnetRunMessages": true,
                "applicationUrl": "http://localhost:5216"
        },
        "Development_http": {
            "commandName": "Project",
                "launchBrowser": true,
                "environmentVariables": {
                "ASPNETCORE_ENVIRONMENT": "Development"
            },
            "dotnetRunMessages": true,
                "applicationUrl": "http://silentupdater.net:5216"
        },
        "Staging_https": {
            "commandName": "Project",
                "launchBrowser": true,
                "environmentVariables": {
                "ASPNETCORE_ENVIRONMENT": "Staging"
            },
            "dotnetRunMessages": true,
                "applicationUrl": "https://silentupdater.net:7285;
                                   http://silentupdater.net:5216"
        },
        "Production_https": {
            "commandName": "Project",
                "launchBrowser": true,
                "environmentVariables": {
                "ASPNETCORE_ENVIRONMENT": "Production"
            },
            "dotnetRunMessages": true,
                "applicationUrl": "https://silentupdater.net:7285;
                                   http://silentupdater.net:5216"
        }
    },
    "https": {
        "commandName": "Project",
            "dotnetRunMessages": true,
            "launchBrowser": true,
            "applicationUrl": "https://localhost:7285;http://localhost:5216",
            "environmentVariables": {
            "ASPNETCORE_ENVIRONMENT": "Development"
        }
    },
    "IIS Express": {
        "commandName": "IISExpress",
            "launchBrowser": true,
            "environmentVariables": {
            "ASPNETCORE_ENVIRONMENT": "Development"
        }
    },
    "iisSettings": {
        "windowsAuthentication": false,
            "anonymousAuthentication": true,
            "iisExpress": {
            "applicationUrl": "http://localhost:51927",
                "sslPort": 44366
        }
    }
}

Publishing Your Application

All that we need to do is open the Publish Window, select the Profile, and then press the Publish button. Here, we can see that we successfully published:

Image 35

If you have set up your publishing profile correctly, the install files will automatically be added to your web application:

Image 36

NOTE: We have two options to expose the installer to the users:

  1. The legacy Publish.html file
  2. The [application_name].application manifest file

We will cover these later in the Installing and Testing Silent Updating section.

Hosting in a Web Application

For this article, I required a simple website to host the deployment pages, so I chose Minimal API & a static page. Below is the minimal API implementation use:

C#
WebApplication app = WebApplication
    .CreateBuilder(args)
    .Build();

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.MapGet("/", async (HttpContext ctx) =>
{
    //sets the content type as html
    ctx.Response.Headers.ContentType = new StringValues("text/html; charset=UTF-8");
    await ctx.Response.SendFileAsync("wwwroot/index.html");
});

app.Run();

It simply serves up a html file, in this case, the wwwroot/index.html file:

Image 37

VB.NET has its own implementation:

VB.NET
Module Program
    Sub Main(args As String())

        ' Add services to the container.
        Dim app = WebApplication _
                .CreateBuilder(args) _
                .Build()

        app.UseHttpsRedirection()
        app.UseStaticFiles()

        app.UseRouting()

        app.MapGet("/",
            Async Function(ctx As HttpContext)

                ' set content type to html
                ctx.Response.Headers.ContentType = _
                    New StringValues("text/html; charset=UTF-8")
                Await ctx.Response.SendFileAsync("wwwroot/index.html")

            End Function)

        app.Run()

    End Sub

End Module

Image 38

Installing and Testing Silent Updating

Steps to install, re-publish, re-host, run, update, and restart.

  1. Start the Host Web Application/Server.
  2. Publish the application to your Host Web Application/Server.
  3. Install the application.
  4. The application will automatically run (don't stop it).
  5. Republish the application.
  6. Wait for the application to do a check and notify there is an update ready.
  7. Click the Restart button, and the application will close, update, and then restart automatically.
  8. Now check the updated version number.

Earlier in the article, I mentioned that there are two methods for users to install from the Host Web Application/Server:

  1. via the generated publish.html page
  2. the app_name.application manifest file

In the next section, we will explore the process for each using a test certificate.

Installing with the Publish.html File

Following is the automatically generated publish.html file. This is optional. Let's look at the installation process with a test certificate generated when setting up the publish profile. There are several warnings and processes to do the download before installing process:

Image 39

Next, we need to click on the See more option:

Image 40

Select Keep:

Image 41

Now, as we are using a test certificate, we need to select Show more and select Keep anyway:

Image 42

Now the setup.exe file has been downloaded. We need to select Open file:

Image 43

The installation will now proceed.

Installing using the Application Manifest

Selecting to install the *.application manifest is far simpler, and preferred. It is one step to download the setup.exe file:

Image 44

Once downloaded, the installation will now proceed. Simply click the Open button.

The Installation

Once the installer is downloaded, it will run, download and install the application, and then start the application.

Image 45

Here, we can see the Publish Options that we set up. The More Information link will point to the Support URL that was supplied.

Image 46

Notes

  • If an approved certificate was used when publishing, there would be no warning and the Publisher would be displayed.

Now the application is downloaded:

Image 47

Once the download is completed, and the application is installed, the application will automatically run:

Image 48

Example of Incorrect Settings for the Development PC

Now we are going to look at a successful installation, however, incorrectly configured.

Image 49

Image 50

Image 51

Everything appears to be working correctly, however, the app was installed from localhost and the Hosts file was not configured with the network computer name. When the Click to Restart button is clicked, the following error is thrown:

Image 52

Image 53

If we try using the URL that the installer tried, we will see the following:

Image 54

Server Changed to the Computer Name

If you do not uninstall the incorrectly installed application, change the Hosts file, then try to do an update, you will experience something like the following error:

Image 55

Image 56<

(Click to view full-sized image)

 

The application must be uninstalled, then the installation will be successful.

Configuring your Development PC for Local Testing

Like any development cycle, doing test installation and updates on our local machine is recommended. To do this, there is a step required to configure the web hosting. Without this, you will experience difficulty.

We have already configured the application(s) for publishing, and hosting, next we need to configure the development PC by configuring your Hosts file. The Hosts file is found in C:\Windows\System32\drivers\etc directory.

The HOSTS file is a special file and usually requires Administrator Privileges to save changes.

The method that I use to make changes is as follows:

  1. Press Start button on the taskbar and find the Notepad application.
  2. Right-click on the Notepad application and select Run as administrator.
  3. Once in Notepad, go to FileOpen. Change to the folder C:\Windows\System32\Drivers\etc.
  4. Now make the changes to the hosts file.
  5. Save the changes.

Here is an example that we will use for this article and the code provided:

# Name of website hosting the ClickOne application installer/deployment
127.0.0.1    silentupdater.net
127.0.0.1    www.silentupdater.net

# The network computer name
127.0.0.1 network_computer_name_goes_here

I have two sets of IP mappings:

  1. The host server website is mapped to the local machine
  2. Mapping the LocalHost to the network computer name

You do not require both, however, to understand how they work, it is best to set both. The second is to mitigate installation errors if you chose to use localhost for your web application.

Now you can run your web server and do your testing. The Hosts setup above is for trying out the download. Without using the Hosts configuration setup above, you will experience issues.

Summary

We have covered Microsoft ClickOnce from development, publishing, hosting, installation, and finally a user friendly Silent ClickOnceUpdateService for a friendly user experience. As a developer, the ClickOnceUpdateService arms you with a background service for your application that will do all of the work for you, plus give you access to information that is normally not visible. Lastly, a StatusBar control that you can drop into your app to get started quickly, plus LogViewer and ServiceProperties controls to help with live debugging your application. Hopefully, this article leaves you with more hair than me.

If you have any questions, please post below and I will be more than happy to answer.

References

Documentation, Articles, etc.

Nuget Packages

History

  • 17th April, 2023 - v1.0 - Initial release
  • 25th April, 2023 - v1.10 - Added C# & VB sample / proof-of-concept Console application + RetroConsole (prototype) library for advance Console rendering - see Retro Console in the Preview section
  • 13th October, 2023 - v1.10a - updated downloads to correct incorrect zip file type (was a rar  by mistake masquerading as a zip file - fixed)

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)