Introduction
This article comprehensively covers the Microsoft ClickOnce Installer and improves on a previous article, published by Ivan Leonenko, with a bare-bones WinForm/WPF C#/VB Silent Updater framework. This article covers how to implement, troubleshoot and test locally, plus release to a live MVC web server.
If you download the solution and follow this article, you will:
- configure a ClickOnce installation that will work with all major Web Browsers
- create a ClickOnce Manifest Signing Certificate automagically
- publish to a Web Application
- set up a local Custom Domain for your Web Application
- download ClickOnce Installer, run, and installed the application(s)
- update the published files
- watch the Silent Updater automatically download and update whilst the application is running
Contents
Overview
I have looked at a number of methods of installing applications and how to keep users up-to-date with the latest version of the application that I release. Having fragmentation with multiple versions of an application out in the wild presented a major headache for a small business like mine.
Microsoft, Apple, and Google store apps all have a mechanism to automate the update of applications installed on user devices. I needed a simple and automated system that ensures users were always up to date and pushing changes would be quick and transparent. ClickOnce looked like, and proved to be, the solution:
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[^]
I didn't like how the update worked with the check before running the application. It felt a bit amateurish. So a quick Google Search[^] found Ivan Leonenko's Silently updatable single instance WPF ClickOnce application[^] article.
Ivan's article is a good implementation of a Silent ClickOnce updater however was a bit rough, had slight problems, and appears to be no longer supported. The following article addresses this plus:
- Pre-built application frameworks for WinForm and WPF applications in C# and VB ready for use
- Cleaned up the code and changed to a Single Instance class
- Both WinForm and WPF sample frameworks include graceful unhandled application exception shutdown
- Added a sample MVC web server host
- Added instructions on how to do localized IIS/IIS Express host troubleshooting and testing
- Added MVC ClickOnce file support for IIS hosting on a live website
- Added ClickOnce user installation troubleshooting help
- Included both C# and VB versions for all samples
Prerequisites
The projects for this article were built with the following in mind:
- C#6 minimum (Set in Properties > Build > Advanced > General > Language Version > C#6)
- Built using VS2017 (VS2015 will also load, build, and run)
- When you load the code the first time, you will need to restore Nuget Packages
- Will need to follow the article to see the Silent Update in action
The Silent Updater Core
The actual code that does all the work is quite simple:
- Single Instance class (new)
- Checks for an update every 60 seconds
- Starts a background/asynchronous update with feedback
- Silent handling of download issues + retry every 60 seconds
- Notify when an update is ready
public sealed class SilentUpdater : INotifyPropertyChanged
{
private static volatile SilentUpdater instance;
public static SilentUpdater Instance
{
get { return instance ?? (instance = new SilentUpdater()); }
}
private bool updateAvailable;
public bool UpdateAvailable
{
get { return updateAvailable; }
internal set
{
updateAvailable = value;
RaisePropertyChanged(nameof(UpdateAvailable));
}
}
private Timer Timer { get; }
private ApplicationDeployment ApplicationDeployment { get; }
private bool Processing { get; set; }
public event EventHandler<UpdateProgressChangedEventArgs> ProgressChanged;
public event EventHandler<EventArgs> Completed;
public event PropertyChangedEventHandler PropertyChanged;
public void RaisePropertyChanged(string propertyName)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
private SilentUpdater()
{
if (!ApplicationDeployment.IsNetworkDeployed) return;
ApplicationDeployment = ApplicationDeployment.CurrentDeployment;
ApplicationDeployment.UpdateProgressChanged += (s, e) =>
ProgressChanged?.Invoke(this, new UpdateProgressChangedEventArgs(e));
ApplicationDeployment.UpdateCompleted += (s, e) =>
{
Processing = false;
if (e.Cancelled || e.Error != null)
return;
UpdateAvailable = true;
Completed?.Invoke(sender: this, e: null);
};
Timer = new Timer(60000);
Timer.Elapsed += (s, e) =>
{
if (Processing) return;
Processing = true;
try
{
if (ApplicationDeployment.CheckForUpdate(false))
ApplicationDeployment.UpdateAsync();
else
Processing = false;
}
catch (Exception)
{
Processing = false;
}
};
Timer.Start();
}
}
Public NotInheritable Class SilentUpdater : Implements INotifyPropertyChanged
Private Shared mInstance As SilentUpdater
Public Shared ReadOnly Property Instance() As SilentUpdater
Get
Return If(mInstance, (Factory(mInstance, New SilentUpdater())))
End Get
End Property
Private mUpdateAvailable As Boolean
Public Property UpdateAvailable() As Boolean
Get
Return mUpdateAvailable
End Get
Friend Set
mUpdateAvailable = Value
RaisePropertyChanged(NameOf(UpdateAvailable))
End Set
End Property
Private ReadOnly Property Timer() As Timer
Private ReadOnly Property ApplicationDeployment() As ApplicationDeployment
Private Property Processing() As Boolean
Public Event ProgressChanged As EventHandler(Of UpdateProgressChangedEventArgs)
Public Event Completed As EventHandler(Of EventArgs)
Public Event PropertyChanged As PropertyChangedEventHandler _
Implements INotifyPropertyChanged.PropertyChanged
Public Sub RaisePropertyChanged(propertyName As String)
RaiseEvent PropertyChanged(Me, New PropertyChangedEventArgs(propertyName))
End Sub
Private Sub New()
If Not ApplicationDeployment.IsNetworkDeployed Then Return
ApplicationDeployment = ApplicationDeployment.CurrentDeployment
AddHandler ApplicationDeployment.UpdateProgressChanged,
Sub(s, e)
RaiseEvent ProgressChanged(Me, New UpdateProgressChangedEventArgs(e))
End Sub
AddHandler ApplicationDeployment.UpdateCompleted,
Sub(s, e)
Processing = False
If e.Cancelled OrElse e.[Error] IsNot Nothing Then
Return
End If
UpdateAvailable = True
RaiseEvent Completed(Me, Nothing)
End Sub
Timer = New Timer(60000)
AddHandler Timer.Elapsed,
Sub(s, e)
If Processing Then Return
Processing = True
Try
If ApplicationDeployment.CheckForUpdate(False) Then
ApplicationDeployment.UpdateAsync()
Else
Processing = False
End If
Catch generatedExceptionName As Exception
Processing = False
End Try
End Sub
Timer.Start()
End Sub
Private Shared Function Factory(Of T)(ByRef target As T, value As T) As T
target = value
Return value
End Function
End Class
Implementation
There are two parts to implementing support for ClickOnce Silent Updating:
- Starting the service, unhandled application exceptions, and rebooting into the new version
- User feedback and interaction
Implementation for WinForm and WPF applications is slightly different. Each will be covered individually.
WinForm
First, we need to hook up the SilentUpdater
class. The following code will:
- obtain a reference to the
SilentUpdater
class instance - listen to the events of the
SilentUpdater
class - update the UI when an update is being downloaded
- show the restart button when the download is completed
- restart the application when the restart button is clicked
Lastly, C# and VB WinForm applications start a little differently. So in the VB version, to keep the bootstrap code separate to the form code, we need to manually call the start-up/bootstrap code as the main form initializes.
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
UpdateService = SilentUpdater.Instance;
UpdateService.ProgressChanged += SilentUpdaterOnProgressChanged;
UpdateService.Completed += UpdateService_Completed;
Version = AppProcessHelper.Version();
}
#region Update Service
private SilentUpdater UpdateService { get; }
public string UpdaterText { set { sbMessage.Text = value; } }
private void RestartClicked(object sender, EventArgs e)
{
AppProcessHelper.BeginReStart();
}
private bool updateNotified;
private void SilentUpdaterOnProgressChanged
(object sender, UpdateProgressChangedEventArgs e)
=> UpdaterText = e.StatusString;
private void UpdateService_Completed(object sender, EventArgs e)
{
if (updateNotified) return;
updateNotified = true;
NotifyUser();
}
private void NotifyUser()
{
if (InvokeRequired)
Invoke((MethodInvoker)(NotifyUser));
else
{
sbButRestart.Visible = true;
UpdaterText = "A new version was installed!";
}
#endregion
}
}
Public Class Form1
Sub New()
Bootstrap()
InitializeComponent()
UpdateService = SilentUpdater.Instance
AddHandler UpdateService.ProgressChanged, AddressOf SilentUpdaterOnProgressChanged
AddHandler UpdateService.Completed, AddressOf UpdateService_Completed
Version = AppProcessHelper.Version()
End Sub
#Region "Update Service"
Private ReadOnly Property UpdateService As SilentUpdater
Public WriteOnly Property UpdaterText() As String
Set
sbMessage.Text = Value
End Set
End Property
Public WriteOnly Property Version() As String
Set
sbVersion.Text = Value
End Set
End Property
Private Sub RestartClicked(sender As Object, e As EventArgs) Handles sbButRestart.Click
AppProcessHelper.BeginReStart()
End Sub
Private updateNotified As Boolean
Private Sub SilentUpdaterOnProgressChanged(sender As Object, _
e As UpdateProgressChangedEventArgs)
UpdaterText = e.StatusString
End Sub
Private Sub UpdateService_Completed(sender As Object, e As EventArgs)
If updateNotified Then
Return
End If
updateNotified = True
NotifyUser()
End Sub
Private Sub NotifyUser()
If InvokeRequired Then
Invoke(DirectCast(AddressOf NotifyUser, MethodInvoker))
Else
sbButRestart.Visible = True
UpdaterText = "A new version was installed!"
End If
End Sub
#End Region
End Class
The above code will also support notifying the currently installed application version.
The Application
class that is part of the WinForm framework simplifies the code required. However, as the Application
class is sealed, we can't write extensions to extend it with our own method calls. So, we need an AppProcessHelper
class to enable:
- Single application instance management
- The conditional restarting of the application
- Installed version number retrieval
It is not a good idea to have multiple copies of the application running at the same time all trying to update themselves. The requirement of this article is to only have one instance of the application running. So I won't be covering in this article how to handle multiple running instances with single instance responsibility for silent updating.
public static class AppProcessHelper
{
private static Mutex instanceMutex;
public static bool SetSingleInstance()
{
bool createdNew;
instanceMutex = new Mutex(
true,
@"Local\" + Process.GetCurrentProcess().MainModule.ModuleName,
out createdNew);
return createdNew;
}
public static bool ReleaseSingleInstance()
{
if (instanceMutex == null) return false;
instanceMutex.Close();
instanceMutex = null;
return true;
}
private static bool isRestartDisabled;
private static bool canRestart;
public static void BeginReStart()
{
canRestart = true;
Application.Exit();
}
public static void PreventRestart(bool state = true)
{
isRestartDisabled = state;
if (state) canRestart = false;
}
public static void RestartIfRequired(int exitCode = 0)
{
ReleaseSingleInstance();
if (canRestart)
Application.Restart();
else
Environment.Exit(exitCode);
}
public static string Version()
{
return Assembly.GetEntryAssembly().GetName().Version.ToString();
}
}
Public Module AppProcessHelper
Private instanceMutex As Mutex
Public Function SetSingleInstance() As Boolean
Dim createdNew As Boolean
instanceMutex = New Mutex(True, _
String.Format("Local\{0}", Process.GetCurrentProcess() _
.MainModule.ModuleName), _
createdNew)
Return createdNew
End Function
Public Function ReleaseSingleInstance() As Boolean
If instanceMutex Is Nothing Then
Return False
End If
instanceMutex.Close()
instanceMutex = Nothing
Return True
End Function
Private isRestartDisabled As Boolean
Private canRestart As Boolean
Public Sub BeginReStart()
canRestart = True
Application.[Exit]()
End Sub
Public Sub PreventRestart(Optional state As Boolean = True)
isRestartDisabled = state
If state Then
canRestart = False
End If
End Sub
Public Sub RestartIfRequired(Optional exitCode As Integer = 0)
ReleaseSingleInstance()
If canRestart Then
Application.Restart()
Else
Environment.[Exit](exitCode)
End If
End Sub
Public Function Version() As String
Return Assembly.GetEntryAssembly().GetName().Version.ToString()
End Function
End Module
I have separated the Restart into two steps with the option to prevent restarting. I have done this for two reasons:
- To give the application the opportunity to let the user choose to save any unsaved work, abort if pressed by accident, and allow the application to clean up before finalizing the shutdown process.
- If any unhandled exceptions occur, to (optionally) prevent restarting and end in a possible endless exception cycle.
internal static class Program
{
[STAThread]
private static void Main()
{
if (!AppProcessHelper.SetSingleInstance())
{
MessageBox.Show("Application is already running!",
"ALREADY ACTIVE",
MessageBoxButtons.OK,
MessageBoxIcon.Exclamation);
Environment.Exit(-1);
}
Application.ApplicationExit += ApplicationExit;
Application.ThreadException += Application_ThreadException;
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new Form1());
}
private static void CurrentDomain_UnhandledException(object sender,
UnhandledExceptionEventArgs e)
=> ShowExceptionDetails(e.ExceptionObject as Exception);
private static void Application_ThreadException(object sender,
ThreadExceptionEventArgs e)
=> ShowExceptionDetails(e.Exception);
private static void ShowExceptionDetails(Exception Ex)
{
MessageBox.Show(Ex.Message,
Ex.TargetSite.ToString(),
MessageBoxButtons.OK,
MessageBoxIcon.Error);
AppProcessHelper.PreventRestart();
Application.Exit();
}
private static void ApplicationExit(object sender, EventArgs e)
{
AppProcessHelper.RestartIfRequired();
}
}
Partial Class Form1
Sub Bootstrap()
If Not AppProcessHelper.SetSingleInstance() Then
MessageBox.Show("Application is already running!", _
"ALREADY ACTIVE", _
MessageBoxButtons.OK, _
MessageBoxIcon.Exclamation)
Environment.[Exit](-1)
End If
AddHandler Application.ApplicationExit, AddressOf ApplicationExit
AddHandler Application.ThreadException, AddressOf Application_ThreadException
AddHandler AppDomain.CurrentDomain.UnhandledException, _
AddressOf CurrentDomain_UnhandledException
End Sub
Private Sub CurrentDomain_UnhandledException_
(sender As Object, e As UnhandledExceptionEventArgs)
ShowExceptionDetails(TryCast(e.ExceptionObject, Exception))
End Sub
Private Sub Application_ThreadException(sender As Object, e As ThreadExceptionEventArgs)
ShowExceptionDetails(e.Exception)
End Sub
Private Sub ShowExceptionDetails(Ex As Exception)
MessageBox.Show(Ex.Message, _
Ex.TargetSite.ToString(), _
MessageBoxButtons.OK, _
MessageBoxIcon.[Error])
AppProcessHelper.PreventRestart()
Application.[Exit]()
End Sub
Private Sub ApplicationExit(sender As Object, e As EventArgs)
AppProcessHelper.RestartIfRequired()
End Sub
End Class
WPF (Windows Presentation Foundation)
First, we need to hook up the SilentUpdater
class. This is the code-behind example. An MVVM version is also included in the download. I have put the code in a separate UserControl
called StatusBarView
. This will keep the code separate from the rest of the code in the main window.
The following code will:
- obtain a reference to the
SilentUpdater
class instance - listen to the events of the
SilentUpdater
class - update the UI when an update is being downloaded
- show the restart button when the download is completed
- restart the application when the restart button is clicked
public partial class StatusBarView : UserControl, INotifyPropertyChanged
{
public StatusBarView()
{
InitializeComponent();
DataContext = this;
if (!this.IsInDesignMode())
{
UpdateService = SilentUpdater.Instance;
UpdateService.ProgressChanged += SilentUpdaterOnProgressChanged;
}
}
#region Update Service
public SilentUpdater UpdateService { get; }
private string updaterText;
public string UpdaterText
{
get { return updaterText; }
set { Set(ref updaterText, value); }
}
public string Version { get { return Application.Current.Version(); } }
private void RestartClicked(object sender, RoutedEventArgs e)
=> Application.Current.BeginReStart();
private bool updateNotified;
private void SilentUpdaterOnProgressChanged(object sender,
UpdateProgressChangedEventArgs e)
=> UpdaterText = e.StatusString;
#endregion
#region INotifyPropertyChanged
public void Set<TValue>(ref TValue field,
TValue newValue,
[CallerMemberName] string propertyName = "")
{
if (EqualityComparer<TValue>.Default.Equals(field, default(TValue))
|| !field.Equals(newValue))
{
field = newValue;
PropertyChanged?.Invoke(this,
new PropertyChangedEventArgs(propertyName));
}
}
public event PropertyChangedEventHandler PropertyChanged;
#endregion
}
Public Class StatusBarView : Implements INotifyPropertyChanged
Public Sub New()
InitializeComponent()
DataContext = Me
If Not IsInDesignMode() Then
UpdateService = SilentUpdater.Instance
AddHandler UpdateService.ProgressChanged,
AddressOf SilentUpdaterOnProgressChanged
End If
End Sub
#Region "Update Service"
Public ReadOnly Property UpdateService() As SilentUpdater
Private mUpdaterText As String
Public Property UpdaterText As String
Get
Return mUpdaterText
End Get
Set
[Set](mUpdaterText, Value)
End Set
End Property
Public ReadOnly Property Version As String
Get
Return Application.Current.Version()
End Get
End Property
Private Sub RestartClicked(sender As Object, e As RoutedEventArgs)
Application.Current.BeginReStart()
End Sub
Private updateNotified As Boolean
Private Sub SilentUpdaterOnProgressChanged(sender As Object,
e As UpdateProgressChangedEventArgs)
UpdaterText = e.StatusString
End Sub
Private Sub UpdateServiceCompleted(sender As Object, e As EventArgs)
If updateNotified Then
Return
End If
updateNotified = True
NotifyUser()
End Sub
Private Sub NotifyUser()
Dispatcher.Invoke(Sub()
MessageBox.Show("A new version is now available.",
"NEW VERSION",
MessageBoxButton.OK,
MessageBoxImage.Information)
End Sub)
End Sub
#End Region
#Region "INotifyPropertyChanged"
Public Sub [Set](Of TValue)(ByRef field As TValue, _
newValue As TValue, _
<CallerMemberName> Optional propertyName As String = "")
If EqualityComparer(Of TValue).Default.Equals(field, Nothing) _
OrElse Not field.Equals(newValue) Then
field = newValue
RaiseEvent PropertyChanged(Me, New PropertyChangedEventArgs(propertyName))
End If
End Sub
Public Event PropertyChanged As PropertyChangedEventHandler _
Implements INotifyPropertyChanged.PropertyChanged
#End Region
End Class
One thing that should stand out is that the WinForm and WPF versions are almost identical.
And here is the XAML for the UI:
<UserControl
x:Class="WpfCBApp.Views.StatusBarView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:c="clr-namespace:Wpf.Core.Converters;assembly=Wpf.Core"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d" d:DesignHeight="30" d:DesignWidth="400">
<Grid Background="DarkGray">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto"/>
<ColumnDefinition/>
<ColumnDefinition Width="auto"/>
<ColumnDefinition Width="auto"/>
</Grid.ColumnDefinitions>
<Grid.Resources>
<c:VisibilityConverter x:Key="VisibilityConverter"/>
<c:NotVisibilityConverter x:Key="NotVisibilityConverter"/>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="White"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<Style TargetType="Button">
<Setter Property="Foreground" Value="White"/>
<Setter Property="Background" Value="Green"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Margin" Value="4 1 1 1"/>
<Setter Property="Padding" Value="10 0"/>
<Setter Property="VerticalAlignment" Value="Stretch"/>
</Style>
</Grid.Resources>
<TextBlock Margin="4 0">
<Run FontWeight="SemiBold">Version: </Run>
<Run Text="{Binding Version, Mode=OneTime}"/>
</TextBlock>
<TextBlock Text="{Binding UpdaterText}" Grid.Column="2"
Margin="4 0" HorizontalAlignment="Right"
Visibility="{Binding UpdateService.UpdateAvailable,
Converter={StaticResource NotVisibilityConverter}}"/>
<TextBlock Text="A new version was installed!" Grid.Column="2"
Margin="4 0" HorizontalAlignment="Right"
Visibility="{Binding UpdateService.UpdateAvailable,
Converter={StaticResource VisibilityConverter}}"/>
<Button Content="Click to Restart" Grid.Column="3"
Visibility="{Binding UpdateService.UpdateAvailable,
Converter={StaticResource VisibilityConverter}}"
Click="RestartClicked"/>
</Grid>
</UserControl>
The above code will also support notifying the currently installed application version.
The Application
class that is part of the WPF framework does not have support for Restarting however the class is not sealed
, so we can write extensions to extend it with our own method calls. So we need a slightly different version of the WinForm AppProcessHelper
class to enable:
- single application instance management
- support for restarting the application (Ivan's article has a good implementation that we will use)
- the conditional restarting of the application
- installed version number retrieval
Again, it is not a good idea to have multiple copies of the application running at the same time all trying to update themselves. The requirement of this article is to only have one instance of the application running. So I won't be covering in this article how to handle multiple running instances with single instance responsibility for silent updating.
internal static class AppProcessHelper
{
private static Process process;
public static Process GetProcess
{
get
{
return process ?? (process = new Process
{
StartInfo =
{
FileName = GetShortcutPath(), UseShellExecute = true
}
});
}
}
public static string GetShortcutPath()
=> $@"{Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.Programs),
GetPublisher(),
GetDeploymentInfo().Name.Replace(".application", ""))}.appref-ms";
private static ActivationContext ActivationContext
=> AppDomain.CurrentDomain.ActivationContext;
public static string GetPublisher()
{
XDocument xDocument;
using (var memoryStream = new MemoryStream(ActivationContext.DeploymentManifestBytes))
using (var xmlTextReader = new XmlTextReader(memoryStream))
xDocument = XDocument.Load(xmlTextReader);
if (xDocument.Root == null)
return null;
return xDocument.Root
.Elements().First(e => e.Name.LocalName == "description")
.Attributes().First(a => a.Name.LocalName == "publisher")
.Value;
}
public static ApplicationId GetDeploymentInfo()
=> (new ApplicationSecurityInfo(ActivationContext)).DeploymentId;
private static Mutex instanceMutex;
public static bool SetSingleInstance()
{
bool createdNew;
instanceMutex = new Mutex(true,
@"Local\" + Assembly.GetExecutingAssembly().GetType().GUID,
out createdNew);
return createdNew;
}
public static bool ReleaseSingleInstance()
{
if (instanceMutex == null) return false;
instanceMutex.Close();
instanceMutex = null;
return true;
}
private static bool isRestartDisabled;
private static bool canRestart;
public static void BeginReStart()
{
var proc = GetProcess;
canRestart = !isRestartDisabled;
Application.Current.Shutdown();
}
public static void PreventRestart(bool state = true)
{
isRestartDisabled = state;
if (state) canRestart = false;
}
public static void RestartIfRequired(int exitCode = 0)
{
ReleaseSingleInstance();
if (canRestart && process != null)
process.Start();
else
Application.Current.Shutdown(exitCode);
}
}
Public Module AppProcessHelper
Private process As Process
Public ReadOnly Property GetProcess() As Process
Get
If process Is Nothing Then
process = New Process() With
{
.StartInfo = New ProcessStartInfo With
{
.FileName = GetShortcutPath(),
.UseShellExecute = True
}
}
End If
Return process
End Get
End Property
Public Function GetShortcutPath() As String
Return String.Format("{0}.appref-ms", _
Path.Combine( _
Environment.GetFolderPath( _
Environment.SpecialFolder.Programs), _
GetPublisher(), _
GetDeploymentInfo().Name.Replace(".application", "")))
End Function
Private ReadOnly Property ActivationContext() As ActivationContext
Get
Return AppDomain.CurrentDomain.ActivationContext
End Get
End Property
Public Function GetPublisher() As String
Dim xDocument As XDocument
Using memoryStream = New MemoryStream(ActivationContext.DeploymentManifestBytes)
Using xmlTextReader = New XmlTextReader(memoryStream)
xDocument = XDocument.Load(xmlTextReader)
End Using
End Using
If xDocument.Root Is Nothing Then
Return Nothing
End If
Return xDocument.Root _
.Elements().First(Function(e) e.Name.LocalName = "description") _
.Attributes().First(Function(a) a.Name.LocalName = "publisher") _
.Value
End Function
Public Function GetDeploymentInfo() As ApplicationId
Return (New ApplicationSecurityInfo(ActivationContext)).DeploymentId
End Function
Private instanceMutex As Mutex
Public Function SetSingleInstance() As Boolean
Dim createdNew As Boolean
instanceMutex = New Mutex(True, _
String.Format("Local\{0}", _
Assembly.GetExecutingAssembly() _
.GetType().GUID), _
createdNew)
Return createdNew
End Function
Public Function ReleaseSingleInstance() As Boolean
If instanceMutex Is Nothing Then
Return False
End If
instanceMutex.Close()
instanceMutex = Nothing
Return True
End Function
Private isRestartDisabled As Boolean
Private canRestart As Boolean
Public Sub BeginReStart()
Dim proc = GetProcess
canRestart = Not isRestartDisabled
Application.Current.Shutdown()
End Sub
Public Sub PreventRestart(Optional state As Boolean = True)
isRestartDisabled = state
If state Then
canRestart = False
End If
End Sub
Public Sub RestartIfRequired(Optional exitCode As Integer = 0)
ReleaseSingleInstance()
If canRestart AndAlso process IsNot Nothing Then
process.Start()
Else
Application.Current.Shutdown(exitCode)
End If
End Sub
End Module
And here are the extensions for the WPF framework Application
class:
public static class ApplicationExtension
{
public static bool SetSingleInstance(this Application app)
=> AppProcessHelper.SetSingleInstance();
public static bool ReleaseSingleInstance(this Application app)
=> AppProcessHelper.ReleaseSingleInstance();
public static void BeginReStart(this Application app)
=> AppProcessHelper.BeginReStart();
public static void PreventRestart(this Application app, bool state = true)
=> AppProcessHelper.PreventRestart(state);
public static void RestartIfRequired(this Application app)
=> AppProcessHelper.RestartIfRequired();
public static string Version(this Application app)
=> Assembly.GetEntryAssembly().GetName().Version.ToString();
}
Public Module ApplicationExtension
<Extension>
Public Function SetSingleInstance(app As Application) As Boolean
Return AppProcessHelper.SetSingleInstance()
End Function
<Extension>
Public Function ReleaseSingleInstance(app As Application) As Boolean
Return AppProcessHelper.ReleaseSingleInstance()
End Function
<Extension>
Public Sub BeginReStart(app As Application)
AppProcessHelper.BeginReStart()
End Sub
<Extension>
Public Sub PreventRestart(app As Application, Optional state As Boolean = True)
AppProcessHelper.PreventRestart(state)
End Sub
<Extension>
Public Sub RestartIfRequired(app As Application)
AppProcessHelper.RestartIfRequired()
End Sub
<Extension>
Public Function Version(app As Application) As String
Return Assembly.GetEntryAssembly().GetName().Version.ToString()
End Function
End Module
Again, for the same reasons, I have separated the restart into two steps with the option to prevent restarting:
- To give the application the opportunity to let the user choose to save any unsaved work, abort if pressed by accident, and allow the application to clean up before finalizing the shutdown process.
- If any unhandled exceptions occur, to (optionally) prevent restarting and end in a possible endless exception cycle.
public partial class App : Application
{
protected override void OnStartup(StartupEventArgs e)
{
if (!Current.SetSingleInstance())
{
MessageBox.Show("Application is already running!",
"ALREADY ACTIVE",
MessageBoxButton.OK,
MessageBoxImage.Exclamation);
Current.Shutdown(-1);
}
Current.DispatcherUnhandledException +=
new DispatcherUnhandledExceptionEventHandler(AppDispatcherUnhandledException);
Dispatcher.UnhandledException +=
new DispatcherUnhandledExceptionEventHandler(DispatcherOnUnhandledException);
AppDomain.CurrentDomain.UnhandledException += CurrentDomainOnUnhandledException;
base.OnStartup(e);
}
private void AppDispatcherUnhandledException(object sender,
DispatcherUnhandledExceptionEventArgs e)
=> ForwardUnhandledException(e);
private void DispatcherOnUnhandledException(object sender,
DispatcherUnhandledExceptionEventArgs e)
=> ForwardUnhandledException(e);
private void ForwardUnhandledException(DispatcherUnhandledExceptionEventArgs e)
{
Current.Dispatcher.Invoke(DispatcherPriority.Normal,
new Action<Exception>((exc) =>
{
throw new Exception("Exception from another Thread", exc);
}),
e.Exception);
}
private void CurrentDomainOnUnhandledException
(object sender, UnhandledExceptionEventArgs e)
{
var ex = e.ExceptionObject as Exception;
MessageBox.Show(ex.Message,
ex.TargetSite.ToString(),
MessageBoxButton.OK,
MessageBoxImage.Error);
Current.PreventRestart();
Current.Shutdown();
}
protected override void OnExit(ExitEventArgs e)
{
base.OnExit(e);
Current.RestartIfRequired();
}
}
Class Application
Protected Overrides Sub OnStartup(e As StartupEventArgs)
If Not Current.SetSingleInstance() Then
MessageBox.Show("Application is already running!",
"ALREADY ACTIVE",
MessageBoxButton.OK,
MessageBoxImage.Exclamation)
Current.Shutdown(-1)
End If
AddHandler Current.DispatcherUnhandledException,
New DispatcherUnhandledExceptionEventHandler_
(AddressOf AppDispatcherUnhandledException)
AddHandler Dispatcher.UnhandledException,
New DispatcherUnhandledExceptionEventHandler_
(AddressOf DispatcherOnUnhandledException)
AddHandler AppDomain.CurrentDomain.UnhandledException, _
AddressOf CurrentDomainOnUnhandledException
MyBase.OnStartup(e)
End Sub
Private Sub AppDispatcherUnhandledException(sender As Object, _
e As DispatcherUnhandledExceptionEventArgs)
ForwardUnhandledException(e)
End Sub
Private Sub DispatcherOnUnhandledException(sender As Object, _
e As DispatcherUnhandledExceptionEventArgs)
ForwardUnhandledException(e)
End Sub
Private Sub ForwardUnhandledException(e As DispatcherUnhandledExceptionEventArgs)
Current.Dispatcher.Invoke(DispatcherPriority.Normal,
New Action(Of Exception)(Sub(exc)
Throw New Exception_
("Exception from another Thread", exc)
End Sub), e.Exception)
End Sub
Private Sub CurrentDomainOnUnhandledException(sender As Object, _
e As UnhandledExceptionEventArgs)
Dim ex = TryCast(e.ExceptionObject, Exception)
MessageBox.Show(ex.Message,
ex.TargetSite.ToString(),
MessageBoxButton.OK,
MessageBoxImage.[Error])
Current.PreventRestart()
Current.Shutdown()
End Sub
Protected Overrides Sub OnExit(e As ExitEventArgs)
MyBase.OnExit(e)
Current.RestartIfRequired()
End Sub
End Class
You can run the application and test the Single Instance support. However, to test the ClickOnce installation, you need to first publish the application, host the installer, install, run, then publish and host an updated version. This will be covered in the following sections.
Preparing the Desktop Application for ClickOnce Testing
Testing any ClickOnce update support requires installing and running either on a live server (IIS) or localhost (IIS / IIS Express). This next section will cover:
- Creating a ClickOnce web-based installer
- Hosting the ClickOnce installer on a local and live MVC server
- How to run a test web install on a local machine and the setup required
- How to avoid "Deployment and application do not have matching security zones" for Chrome and Firefox web browsers
- How to test the Silent Updater
Configuring the Installer
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. To do that, go to Properties > Signing > check "Sign the ClickOnce manifest".
Note: Above, we have only checked the "Sign the ClickOnce Manifests" box. When testing, the Certificate will be automatically generated for you. Here is an example of a Certificate after the first time you publish:
Next, we need to set our Publish profile and settings. First is to set up the Publish Properties defaults:
The "Publish Folder Location" points to the physical location where the published files will go. The "Installation Folder" is the location on the web server where the ClickOnce installer will look to download the files from. I have highlighted the "Installation Folder" to show where there can be a problem that we will see later when we run the "Publish Wizard".
The "Install Mode and Settings" is set to "available offline" so that the application can run when not connected to the internet.
Next, we need to set up the installer prerequisites. Here, we will set the .NET Framework version. The installer will check that the correct framework version is installed on the user's computer. If not, it will run the process automatically for you.
Next, we need to set the update settings. Here, we don't want the ClickOnce installer to run and check for updates before the application runs. This will feel amateurish and slow the loading of our application on start-up. Instead, we want the Silent Updater to do the work after the application starts. So we uncheck "The application should check for updates".
Note: I have highlighted the "Update location (if different than publish location)" section. The documentation does not mention how this optional setting will affect the installer in some circumstances. I have a section below that will discuss the ramifications of not completing this field in more detail below.
Last, we need to set the "Options". First, Deployment settings. We want to automatically publish an install page script and set the deployment file extension:
Next, for security, we don't want the manifest to be activated via URL, we do however want to use the manifest information for user trust. Lastly, I prefer to create a desktop shortcut for easy access, easier than having them find our application in the Start Menu. ;)
Setting the Desktop Application Assembly Version
Publish version is different from the Assembly and File versions. The Publish version is used by the ClickOnce installer on the user's local machine to identify versions and updates. The Assembly version will be displayed to the user. The Assembly version is set on the Properties > Application tab:
Publishing the Desktop Application to the Web Application
Once the publishing defaults are set, we can use the Publish Wizard to:
- Check the default settings
- Automatically generate the Testing Signing Certificate
- Build the application
- Create the installation and copy all relevant files to the web application
- Auto increment the Publish Version used by ClickOnce to identify updates
Step 1 - Publish Location
You can publish directly to your live web server, however, I prefer to stage and test before I "go live". So I point the publishing process to the path in my web application project.
Step 2 - ClickOnce Installer Download Location
This will be the path/url that the ClickOnce installer will look for files for installation and later for updates.
Note: I have highlighted the http://localhost/...
path. This will be changed by the wizard and we can see what happens in the final step of the Wizard.
Step 3 - ClickOnce Operation Mode
We want the application to be installed locally and be able to run offline when not connected to the internet.
Step 4 - Finish - Review Settings
In Step 2 of the Publish Wizard, we specified that the install path for testing will be http://localhost
however the Publish Wizard changed it to http://[local_network_name]
. Why the Publish Wizard does this is unclear.
Step 5 - Publishing to Web Server
Once you click the Finish button in the Publish Wizard, the publishing process will create the Testing ClickOnce Manifests Signing Certificate, build the application (if required), then create the installation files, and copy them to the web application ready for inclusion.
After you complete full testing by running the web application and installing the application, further publishing is a simple click of the "Publish Now" button. All the settings used in the Publish Wizard will be used by "Publish Now".
Including Published Files into the Web Application
To include the published installation files, in Solution Explorer:
- Go to the web application, make sure that hidden files are visible, and click the Refresh button.
- Expand the folders so that you can see the new installation files:
- Select the files and folders for inclusion, right-click, and select "Include In Project":
LocalHost Installation Fails (IIS / IIS Express)
One problem I encountered when trying to do local web server ClickOnce update testing is that the Visual Studio ClickOnce Publisher does a strange thing. http://localhost
gets changed to http://[network computer name]
. So if you download and run the ClickOnce Setup.exe application via http://localhost
, you will see something like this:
Here is the install.log file:
The following properties have been set:
Property: [AdminUser] = true {boolean}
Property: [InstallMode] = HomeSite {string}
Property: [NTProductType] = 1 {int}
Property: [ProcessorArchitecture] = AMD64 {string}
Property: [VersionNT] = 10.0.0 {version}
Running checks for package 'Microsoft .NET Framework 4.5.2 (x86 and x64)', phase BuildList
Reading value 'Release' of registry key
'HKLM\Software\Microsoft\NET Framework Setup\NDP\v4\Full'
Read integer value 460798
Setting value '460798 {int}' for property 'DotNet45Full_Release'
Reading value 'v4' of registry key
'HKLM\SOFTWARE\Microsoft\NET Framework Setup\OS Integration'
Read integer value 1
Setting value '1 {int}' for property 'DotNet45Full_OSIntegrated'
The following properties have been set for package
'Microsoft .NET Framework 4.5.2 (x86 and x64)':
Property: [DotNet45Full_OSIntegrated] = 1 {int}
Property: [DotNet45Full_Release] = 460798 {int}
Running checks for command 'DotNetFX452\NDP452-KB2901907-x86-x64-AllOS-ENU.exe'
Result of running operator 'ValueEqualTo' on property 'InstallMode' and value 'HomeSite': true
Result of checks for command 'DotNetFX452\NDP452-KB2901907-x86-x64-AllOS-ENU.exe' is 'Bypass'
Running checks for command 'DotNetFX452\NDP452-KB2901907-x86-x64-AllOS-ENU.exe'
Result of running operator 'ValueEqualTo' on property 'InstallMode' and value 'HomeSite': true
Result of checks for command 'DotNetFX452\NDP452-KB2901907-x86-x64-AllOS-ENU.exe' is 'Bypass'
Running checks for command 'DotNetFX452\NDP452-KB2901954-Web.exe'
Result of running operator 'ValueNotEqualTo' on
property 'InstallMode' and value 'HomeSite': false
Result of running operator 'ValueGreaterThanEqualTo' on
property 'DotNet45Full_Release' and value '379893': true
Result of checks for command 'DotNetFX452\NDP452-KB2901954-Web.exe' is 'Bypass'
Running checks for command 'DotNetFX452\NDP452-KB2901954-Web.exe'
Result of running operator 'ValueNotEqualTo' on property 'InstallMode' and
value 'HomeSite': false
Result of running operator 'ValueGreaterThanEqualTo' on
property 'DotNet45Full_Release' and value '379893': true
Result of checks for command 'DotNetFX452\NDP452-KB2901954-Web.exe' is 'Bypass'
'Microsoft .NET Framework 4.5.2 (x86 and x64)' RunCheck result: No Install Needed
Launching Application.
URLDownloadToCacheFile failed with HRESULT '-2146697208'
Error: An error occurred trying to download
'http://macbookpro:60492/Installer/WpfCBApp/WpfCBAppVB.application'.
If we put http://macbookpro:60492/Installer/WpfCBApp/WpfCBAppVB.application
in a web browser, we can see why it failed:
The solution is to configure your dev computer as a web server.
How to Configure Dev Computer for Offline Host Testing
To configure your dev machine for web-hosting ClickOnce update testing.
- Modifying the applicationhost.config file for Custom Domains
- Updating
Hosts
file - Running VS in Administrator mode (to access hosts file)
Configuring IIS Express with Custom Domains
For VS2015 & VS2017, the applicationhost.config file is located in the "solution" folder in the .vs\config folder. Within that folder, you will find the applicationhost.config file.
In the Website's Properties > Web tab, use the following configuration:
With the following in the hosts file (located in C:\Windows\System32\drivers\etc):
127.0.0.1 silentupdater.net
127.0.0.1 www.silentupdater.net
And the following in the applicationhost.config file:
<site name="SampleMvcServer" id="2">
<application path="/" applicationPool="Clr4IntegratedAppPool">
<virtualDirectory path="/" physicalPath="[path_to_server_project_folder]" />
</application>
<bindings>
<binding protocol="http" bindingInformation="*:63690:" />
<binding protocol="http" bindingInformation="*:63690:localhost" />
</bindings>
</site>
<site name="SampleMvcServerVB" id="4">
<application path="/" applicationPool="Clr4IntegratedAppPool">
<virtualDirectory path="/" physicalPath="[path_to_server_project_folder]" />
</application>
<bindings>
<binding protocol="http" bindingInformation="*:60492:" />
<binding protocol="http" bindingInformation="*:60492:localhost" />
</bindings>
</site>
For VS2010 & VS2013, the process is a little different.
- Right-click your Web Application Project > Properties > Web, then configure the "Servers" section as follows:
- Select "IIS Express" from the drop-down
- Project URL:
http://localhost
- Override application root URL:
http://www.silentupdater.net
- Click the "Create Virtual Directory" button (if you get an error here, you may need to disable IIS 5/6/7/8, change IIS's "Default Site" to anything but port
:80
, make sure that applications like Skype, etc. are not using port 80.
- Optionally: Set the "Start URL" to
http://www.silentupdater.net
- Open %USERPROFILE%\My Documents\IISExpress\config\applicationhost.config (Windows XP, Vista, and 7) and edit the site definition in the
<sites>
config block to be along the lines of the following:
<site name="SilentUpdater" id="997005936">
<application path="/" applicationPool="Clr2IntegratedAppPool">
<virtualDirectory
path="/"
physicalPath="C:\path\to\application\root" />
</application>
<bindings>
<binding
protocol="http"
bindingInformation=":80:www.silentupdater.net" />
</bindings>
<applicationDefaults applicationPool="Clr2IntegratedAppPool" />
</site>
- If running MVC: make sure the "
applicationPool
" is set to one of the "Integrated
" options (like "Clr2IntegratedAppPool
"). - Open your hosts file and add the line
127.0.0.1 www.silentupdater.net
. - Start your application!
NOTE: Remember to run your instance of Visual Studio 2015 as an administrator! Otherwise, the UAC will block VS & IIS Express from seeing the changes made to the hosts
file.
Running Visual Studio in Administrator Mode
There are several methods of running in administrator mode. Everyone has their favourite way. One method is to:
- Go to the Visual Studio IDE folder where the devenv.exe file is located. For VS2017, it is located by default in C:\Program Files (x86)\Microsoft Visual Studio\2017\[version]\Common7\IDE
- Hold the Shift key and RightClick on the devenv.exe file
- Click on Run as administrator
- Open the solution, set the web server as "Set as Startup Project"
- Run the web server
Visual Studio and Local Custom Domain Hosting
Before we can do any testing, we need to update the publish profile to reflect the new custom domain www.silentupdater.net
.
Configuring the Publish Download
We need to set the location that the ClickOnce Installer will look for updates. The path needs to be changed from http://localhost
to our custom domain www.silentupdater.net
.
Now we can revisit the Publish Wizard steps above and the finish screen should now be:
Once the Publish Wizard process is completed, the install files and folders included in the Web server's project, we can now run and do the ClickOnce install.
Installing and Testing Silent Updating
Steps to install, re-publish, re-host, run, update, and restart.
- Publish the application to your MVC Server
- Make sure that you included published files before updating and restarting the server
- Install the application
- Run the application (don't stop it)
- Update the version number and make a noticeable change (e.g.: application background color)
- Compile, publish, and start the server
- Wait up to 60 seconds whilst watching the application's
StatusBar
. - Once the silent update is complete, click the Restart button, and look for the changes and the updated version number.
ClickOnce Installation with Microsoft Edge or Internet Explorer
Below are the steps from the install page, to running.
Download Setup.exe Installer
The Installation
Now the application is ready to run. The first time we run, as we are using a test certificate, we will see the following screen:
ClickOnce Installation with Google Chrome or Mozilla Firefox
Downloading and installing using Chrome and Firefox should work the same as Edge and Internet Explorer. However, after downloading the installer file from the Install page using Chrome or Firefox, and running the installer, you might encounter this ClickOnce Install Failure:
And the details may look like this:
PLATFORM VERSION INFO
Windows : 10.0.15063.0 (Win32NT)
Common Language Runtime : 4.0.30319.42000
System.Deployment.dll : 4.7.2046.0 built by: NET47REL1
clr.dll : 4.7.2110.0 built by: NET47REL1LAST
dfdll.dll : 4.7.2046.0 built by: NET47REL1
dfshim.dll : 10.0.15063.0 (WinBuild.160101.0800)
SOURCES
Deployment url : file:///C:/Users/[username]/Downloads/WinFormAppVB.application
IDENTITIES
Deployment Identity : WinFormAppVB.application, Version=1.0.0.0,
Culture=neutral, PublicKeyToken=e6b9c5f6a79417a1, processorArchitecture=msil
APPLICATION SUMMARY
* Installable application.
ERROR SUMMARY
Below is a summary of the errors, details of these errors are listed later in the log.
* Activation of C:\Users\[username]\Downloads\WinFormAppVB.application resulted in exception.
Following failure messages were detected:
+ Deployment and application do not have matching security zones.
Install Failure: Deployment and application do not have matching security zones
Documentation on this failure is very limited. Microsoft's Troubleshooting ClickOnce Deployments[^] pages do not have any suitable solutions.
It turns out that both Chrome and Firefox check for the optional "Properties > Publish > Updates > Update location (if different than publish location)" section and compare it with the "Publish > Installation Folder URL". If the two locations don't match, the installation fails with "Deployment and application do not have matching security zones".
This setting is found in the <deploymentProvider codebase=... />
subsection to the <deployment>
the section in the .application file.
Here is the correction to our "Properties > Publish > Updates > Update location (if different than publish location)" section:
Running and Testing Silent Updating
When testing the silent updating, make sure to update the Assembly Version before pressing the "Publish Now" button. This makes it easier to see which version you are testing.
It is also a good practice, when doing testing, to include the install files into your web application before running. This way, when it is time to publish the release application version, you won't forget this step before pushing it to your website.
Normal State
When the application is running and there are no updates, the StatusBar
will only report the current Assembly version.
WinForm Application
WPF Application
Updating State
When the application update starts, the StatusBar
will report the downloading status.
WinForm Application
WPF Application
Updated State
When the application update has been completed, the StatusBar
will show the completed message and a restart button.
WinForm Application
WPF Application
New Version After Restarting
Lastly, after the restart button is clicked, or the application is closed and restarted, the StatusBar
will reflect the updated Assembly version.
WinForm Application
WPF Application
Hosting on a Web Service (IIS)
When hosting on a live website, we need to enable support for the install files on our MVC server. I have used the following for an Azure Web application:
RouteConfig.CS/VB
Makes sure that we accept requests for the install files and route the request to the FileController
.
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
routes.MapRoute(
"ClickOnceWpfcbInstaller",
"installer/wpfcbapp/{*fileName}",
new { controller = "File", action = "GetFile", fileName = UrlParameter.Optional },
new[] { "SampleMvcServer.Web.Controllers" }
);
routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}",
defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
);
}
Public Sub RegisterRoutes(ByVal routes As RouteCollection)
routes.IgnoreRoute("{resource}.axd/{*pathInfo}")
routes.MapRoute(
"ClickOnceWpfInstaller",
"installer/wpfapp/{*fileName}",
New With {.controller = "File", .action = "GetFile",
.fileName = UrlParameter.[Optional]},
New String() {"SampleMvcServer.Web.Controllers"})
routes.MapRoute(
name:="Default",
url:="{controller}/{action}/{id}",
defaults:=New With
{.controller = "Home", .action = "Index", .id = UrlParameter.Optional}
)
End Sub
FileController.CS/VB
The FileController
ensures that the returned files requested are returned with the correct mime-type headers.
public class FileController : Controller
{
public FilePathResult GetFile(string fileName)
{
var dir = Server.MapPath("/installer/wpfcbapp");
var path = Path.Combine(dir, fileName);
return File(path, GetMimeType(Path.GetExtension(fileName)));
}
private string GetMimeType(string extension)
{
if (extension == ".application" || extension == ".manifest")
return "application/x-ms-application";
else if (extension == ".deploy")
return "application/octet-stream";
else
return "application/x-msdownload";
}
}
Public Class FileController : Inherits Controller
Public Function GetFile(fileName As String) As FilePathResult
Dim dir = Server.MapPath("/installer/wpfcbapp")
Dim path = IO.Path.Combine(dir, fileName)
Return File(path, GetMimeType(IO.Path.GetExtension(fileName)))
End Function
Private Function GetMimeType(extension As String) As String
If extension = ".application" OrElse extension = ".manifest" Then
Return "application/x-ms-application"
ElseIf extension = ".deploy" Then
Return "application/octet-stream"
Else
Return "application/x-msdownload"
End If
End Function
End Class
Summary
Hopefully, this article leaves you with more hair (than me) and less frustration by guiding you through the holes in the Microsoft documentation; filling in the blanks left by Ivan's original article; and avoiding common mistakes that I and others have encountered over time.
Credits and Other Related Links
This article contains a lot of fragmented information that was researched and collected over a period of time. Below are the links to the various people and resources that made this possible:
History
- 1st October, 2017 - v1.0 - Initial release
- 10th January, 2023 - v1.1 - Added download for .Net Framework 4.8
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.