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.
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:
And here is the update notifier for Google Chrome:
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.
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.
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:
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:
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.
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:
ClickOnceUpdateOptions
Configuration Options class
- Path to remote server hosting of updated
- Retry interval for checking for updates
ClickOnceUpdateService
Core Background Service
class:
ClickOnceUpdateOptions Class
This is just a simple Options
class:
public sealed class ClickOnceUpdateOptions
{
public string? PublishingPath { get; set; }
public int RetryInterval { get; set; } = 1000;
}
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:
{
"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
public interface IClickOnceUpdateService : IHostedService
{
string? ApplicationName { get; }
string? ApplicationPath { get; }
bool IsNetworkDeployment { get; }
string DataDirectory { get; }
bool IsUpdatingReady { get; }
bool IsMandatoryUpdate { get; }
string PublishingPath { get; }
int RetryInterval { get; }
event UpdateDetectedEventHandler? UpdateDetected;
event UpdateReadyEventHandler? UpdateReady;
event UpdateCheckEventHandler? UpdateCheck;
Task<Version> CurrentVersionAsync();
Task<Version> ServerVersionAsync();
Task<bool> UpdateAvailableAsync();
Task<bool> PrepareForUpdatingAsync();
Task ExecuteUpdateAsync();
}
Public Interface IClickOnceUpdateService : Inherits IHostedService
ReadOnly Property ApplicationName As String
ReadOnly Property ApplicationPath As String
ReadOnly Property IsNetworkDeployment As Boolean
ReadOnly Property DataDirectory As String
ReadOnly Property IsUpdatingReady As Boolean
ReadOnly Property IsMandatoryUpdate As Boolean
ReadOnly Property PublishingPath As String
ReadOnly Property RetryInterval As Integer
Event UpdateDetected As UpdateDetectedEventHandler
Event UpdateReady As UpdateReadyEventHandler
Event UpdateCheck As UpdateCheckEventHandler
Function CurrentVersionAsync() As Task(Of Version)
Function ServerVersionAsync() As Task(Of Version)
Function UpdateAvailableAsync() As Task(Of Boolean)
Function PrepareForUpdatingAsync() As Task(Of Boolean)
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.
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!;
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
public string? ApplicationName => _applicationName;
public string? ApplicationPath => _applicationPath;
public bool IsNetworkDeployment => _isNetworkDeployment;
public string DataDirectory => _dataDirectory ?? string.Empty;
public bool IsUpdatingReady { get; private set; }
public bool IsMandatoryUpdate => IsUpdatingReady &&
_minimumServerVersion is not null &&
_currentVersion is not null &&
_minimumServerVersion > _currentVersion;
public string PublishingPath => _options.PublishingPath ?? "";
public int RetryInterval => _options.RetryInterval;
public event UpdateDetectedEventHandler? UpdateDetected;
public event UpdateReadyEventHandler? UpdateReady;
public event UpdateCheckEventHandler? UpdateCheck;
#endregion
#region BackgroundService
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
_logger.Emit(EventId, LogLevel.Information, "Waiting");
await Task.Delay(_options.RetryInterval,
stoppingToken).ConfigureAwait(false);
if (stoppingToken.IsCancellationRequested)
break;
_logger.Emit(EventId, LogLevel.Information, "Checking for an update");
OnUpdateCheck();
try
{
if (await CheckHasUpdateAsync().ConfigureAwait(false))
break;
}
catch (ClickOnceDeploymentException)
{
}
catch (HttpRequestException ex)
{
_logger.Emit(EventId, LogLevel.Error, ex.Message, ex);
}
catch (Exception ex)
{
_logger.LogError(EventId, ex.Message, ex);
break;
}
}
_logger.Emit(EventId, LogLevel.Information, "Stopped");
}
public override async Task StartAsync(CancellationToken cancellationToken)
{
await Task.Yield();
_logger.Emit(EventId, LogLevel.Information, "Starting");
if (_options.RetryInterval < 1000)
_options.RetryInterval = 1000;
await base.StartAsync(cancellationToken).ConfigureAwait(false);
}
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
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;
}
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);
}
}
public async Task<bool> UpdateAvailableAsync()
=> await CurrentVersionAsync().ConfigureAwait(false) <
await ServerVersionAsync().ConfigureAwait(false);
public async Task<bool> PrepareForUpdatingAsync()
{
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;
}
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!");
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
{
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
}
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
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"
Public ReadOnly Property ApplicationName As String
Implements IClickOnceUpdateService.ApplicationName
Get
Return _applicationName
End Get
End Property
Public ReadOnly Property ApplicationPath As String
Implements IClickOnceUpdateService.ApplicationPath
Get
Return _applicationPath
End Get
End Property
Public ReadOnly Property IsNetworkDeployment As Boolean
Implements IClickOnceUpdateService.IsNetworkDeployment
Get
Return _isNetworkDeployment
End Get
End Property
Public ReadOnly Property DataDirectory As String
Implements IClickOnceUpdateService.DataDirectory
Get
Return _dataDirectory
End Get
End Property
Public Property IsUpdatingReady As Boolean
Implements IClickOnceUpdateService.IsUpdatingReady
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
Public ReadOnly Property PublishingPath As String
Implements IClickOnceUpdateService.PublishingPath
Get
Return _options.PublishingPath
End Get
End Property
Public ReadOnly Property RetryInterval As Integer
Implements IClickOnceUpdateService.RetryInterval
Get
Return _options.RetryInterval
End Get
End Property
Public Event UpdateDetected As UpdateDetectedEventHandler
Implements IClickOnceUpdateService.UpdateDetected
Public Event UpdateReady As UpdateReadyEventHandler
Implements IClickOnceUpdateService.UpdateReady
Public Event UpdateCheck As UpdateCheckEventHandler
Implements IClickOnceUpdateService.UpdateCheck
#End Region
#Region "BackgroundService"
Protected Overrides Async Function ExecuteAsync(
stoppingToken As CancellationToken) As Task
While Not stoppingToken.IsCancellationRequested
_logger.Emit(EventId, LogLevel.Information, "Waiting")
Await Task.Delay(_options.RetryInterval, _
stoppingToken).ConfigureAwait(False)
If stoppingToken.IsCancellationRequested Then
Exit While
End If
_logger.Emit(EventId, LogLevel.Information, "Checking for an update")
OnUpdateCheck()
Try
If Await CheckHasUpdate().ConfigureAwait(False) Then
Exit While
End If
Catch __unusedClickOnceDeploymentException1__ _
As ClickOnceDeploymentException
Catch ex As HttpRequestException
_logger.Emit(EventId, LogLevel.[Error], ex.Message, ex)
Catch ex As Exception
_logger.LogError(EventId, ex.Message, ex)
Exit While
End Try
End While
_logger.Emit(EventId, LogLevel.Information, "Stopped")
End Function
Public Overrides Async Function StartAsync(
cancellationToken As CancellationToken) As Task
Implements IHostedService.StartAsync
_logger.Emit(EventId, LogLevel.Information, "Starting")
If _options.RetryInterval < 1000 Then
_options.RetryInterval = 1000
End If
Await MyBase.StartAsync(cancellationToken).ConfigureAwait(False)
End Function
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"
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
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
Public Async Function UpdateAvailableAsync() As Task(Of Boolean)
Implements IClickOnceUpdateService.UpdateAvailableAsync
Return Await CurrentVersionAsync().ConfigureAwait(False) <
Await ServerVersionAsync().ConfigureAwait(False)
End Function
Public Async Function PrepareForUpdatingAsync() As Task(Of Boolean)
Implements IClickOnceUpdateService.PrepareForUpdatingAsync
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
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
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
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
:
- Starting the service, unhandled application exceptions, and rebooting into the new version.
- User feedback and interaction
Implementation for WinForms and WPF applications is slightly different. Each will be covered individually.
For minimal implementation, without Dependency Injection, we need to:
- Reference the
ClickOnceUpdateService
and pass in the configuration settings. - Hook the
UpdateCheck
and UpdateReady
events. - Start the background service
ClickOnceUpdateService
. - 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:
public partial class Form1 : Form
{
#region Constructors
public Form1()
{
InitializeComponent();
Configure();
_ = StartServiceAsync();
}
#endregion
#region Fields
private ClickOnceUpdateService? _updateService;
#endregion
#region Methods
private void Configure()
{
ClickOnceUpdateOptions options = AppSettings<ClickOnceUpdateOptions>
.Current("ClickOnce") ?? new()
{
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();
}
#endregion
private void OnClosingForm(object sender, FormClosingEventArgs e)
{
_updateService!.UpdateCheck -= OnUpdateCheck;
_updateService.UpdateReady -= OnUpdateReady;
_ = _updateService.StopAsync(CancellationToken.None);
}
}
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)
Execute(Sub() labCurrentVersion.Text = currentVersion.ToString())
Catch ex As ClickOnceDeploymentException
Execute(Sub() labCurrentVersion.Text = ex.Message)
End Try
End Function
Private Sub OnUpdateCheck(sender As Object, e As EventArgs)
Debug.WriteLine("OnUpdateCheck")
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)
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
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:
WPF Implementation - Simple/Minimal
For WPF, the process is the same as WinForms:
- Reference the
ClickOnceUpdateService
and pass in the configuration settings. - Hook the
UpdateCheck
and UpdateReady
events. - Start the background service
ClickOnceUpdateService
. - 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:
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()
{
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();
}
private void OnClosing(object? sender, CancelEventArgs e)
{
_updateService!.UpdateCheck -= OnUpdateCheck;
_updateService.UpdateReady -= OnUpdateReady;
_ = _updateService.StopAsync(CancellationToken.None);
}
#endregion
}
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)
Execute(Sub() labCurrentVersion.Text = currentVersion.ToString())
Catch ex As ClickOnceDeploymentException
Execute(Sub() labCurrentVersion.Text = ex.Message)
End Try
End Function
Private Sub OnUpdateCheck(sender As Object, e As EventArgs)
Debug.WriteLine("OnUpdateCheck")
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)
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
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.
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:
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;
}
}
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:
private static IHost? _host;
HostApplicationBuilder builder = Host.CreateApplicationBuilder();
builder.AddClickOnceMonitoring();
_host = builder.Build();
Private Shared _host As IHost
Dim builder As HostApplicationBuilder = Host.CreateApplicationBuilder()
builder.AddClickOnceMonitoring()
_host = builder.Build()
Then to start the service:
private readonly CancellationTokenSource _cancellationTokenSource;
_cancellationTokenSource = new();
_ = _host.StartAsync(_cancellationTokenSource.Token);
Private Shared _cancellationTokenSource As CancellationTokenSource
_cancellationTokenSource = New CancellationTokenSource()
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:
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:
- appsettings.json - common to development and production
- appsettings.Development.json - for development configuration
- appsettings.Staging.json - for staging configuration
- 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.
Or we can access the Launch Profiles window from the toolbar:
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.
A sample of a Launch Profile setting in the launchSettings.json file found in the Properties folder:
{
"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.
{
"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.
{
"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.
{
"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:
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
:
The files to be published need to be set to Content
:
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:
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.
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:
{
"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:
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.
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.
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.
Prerequisites
Publish Options
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.
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.
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.
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:
Configuration
Only change these settings if you know what you are doing. The default selected should be suitable for most installations.
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.
Select Rename:
Enter the New Profile name and click the Rename button:
Now make sure the correct publish profile is selected:
Web Application
Important: The key fields are Environment Variables and App URL.
Below are the Launch Settings used for this article.
{
"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:
If you have set up your publishing profile correctly, the install files will automatically be added to your web application:
NOTE: We have two options to expose the installer to the users:
- The legacy Publish.html file
- 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:
WebApplication app = WebApplication
.CreateBuilder(args)
.Build();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.MapGet("/", async (HttpContext ctx) =>
{
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:
VB.NET has its own implementation:
Module Program
Sub Main(args As String())
Dim app = WebApplication _
.CreateBuilder(args) _
.Build()
app.UseHttpsRedirection()
app.UseStaticFiles()
app.UseRouting()
app.MapGet("/",
Async Function(ctx As HttpContext)
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
Installing and Testing Silent Updating
Steps to install, re-publish, re-host, run, update, and restart.
- Start the Host Web Application/Server.
- Publish the application to your Host Web Application/Server.
- Install the application.
- The application will automatically run (don't stop it).
- Republish the application.
- Wait for the application to do a check and notify there is an update ready.
- Click the Restart button, and the application will close, update, and then restart automatically.
- 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:
- via the generated publish.html page
- 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:
Next, we need to click on the See more option:
Select Keep:
Now, as we are using a test certificate, we need to select Show more and select Keep anyway:
Now the setup.exe file has been downloaded. We need to select Open file:
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:
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.
Here, we can see the Publish Options that we set up. The More Information link will point to the Support URL that was supplied.
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:
Once the download is completed, and the application is installed, the application will automatically run:
Example of Incorrect Settings for the Development PC
Now we are going to look at a successful installation, however, incorrectly configured.
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:
If we try using the URL that the installer tried, we will see the following:
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:
<
(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:
- Press Start button on the taskbar and find the Notepad application.
- Right-click on the Notepad application and select Run as administrator.
- Once in Notepad, go to File → Open. Change to the folder C:\Windows\System32\Drivers\etc.
- Now make the changes to the hosts file.
- 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:
- The host server website is mapped to the local machine
- 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)