.NET静默ClickOnce安装程序,用于C#和VB中的Winform和WPF

目录

介绍

定义

概述

好处

预览

复古控制台(NEW!)

先决条件

ClickOnceUpdateService核心

ClickOnceUpdateOptions类

ClickOnceUpdateService类

IClickOnceUpdateService接口

ClickOnceUpdateService类

ClickOnceUpdateService——属性

ClickOnceUpdateService——方法

ClickOnceUpdateService——后台服务方法

ClickOnceUpdateService——事件

实现

WinForms实现——简单/最小

WPF实现——简单/最小

StatusBar通知示例 

依赖注入支持

为ClickOnce准备桌面应用程序

设置启动配置文件

桌面应用程序

配置开发和生产环境

文件:appsettings.json

文件:appsettings. Development.json

文件:appsettings. Production.json

选择配置文件

配置要发布的配置文件

Web应用程序

设置发布配置文件

桌面应用程序

目标

发布位置

安装位置

设置

先决条件

发布选项

签署清单

配置

发布窗口

Web应用程序

发布应用程序

在Web应用程序中托管

安装和测试静默更新

使用Publish.html文件进行安装

使用应用程序清单进行安装

安装

开发电脑设置不正确的示例

服务器更改为计算机名称

配置开发电脑以进行本地测试

总结

引用

文档、文章等

Nuget包


重要提示:如果您使用的是.NET Framework 4.8+或更早版本,请阅读上一篇文章 C#和VB中Winform和WPF的静默ClickOnce安装程序。

介绍

Microsoft和第三方公司拥有许多不同的安装程序框架系统。其中许多需要部分或全部手动交互,有些(如ClickOnce)可以自动执行此过程。本文将介绍适用于.NET Core 3.3+ClickOnce

定义

什么是ClickOnceMicrosoft将其定义为:

引用:

ClickOnce是一种部署技术,可用于创建基于Windows的自我更新应用程序,这些应用程序可以通过最少的用户交互进行安装和运行。可以通过三种不同的方式发布ClickOnce应用程序:从网页、从网络文件共享或从CD-ROM等媒体发布。...Microsoft文档[^]

概述

许多应用程序利用类似的机制来管理其应用程序的通用,并在需要重新启动以应用更新时与用户进行通信。Google ChromeMicrosoft EdgeDiscord,仅举几例。

以下是Discord更新通知程序的屏幕截图:

这是Google Chrome的更新通知程序:

MicrosoftClickOnce的实现有点尴尬。更新检查发生在应用程序启动时,在应用程序启动之前有一个相当不需要的更新检查窗口。

我们将解决这个问题。

好处

ClickOnce用于Windows桌面应用程序的主要优点包括:

  • 易于发布
  • 易于安装
  • 自动更新系统

本文的目的是删除这个尴尬的窗口,并在应用程序的后台静默监视任何更新。如果找到更新,请准备更新并通知应用程序/用户更新已准备就绪,然后应用程序可以自动更新或允许用户选择何时更新。最后,作为开发人员,可以完全控制次要更新策略与主要更新策略/强制更新策略。

因此,主要目标可以总结如下:

  • WinForms 和 WPF 支持(可实现控制台应用)
  • 在应用程序运行之前删除默认的Microsoft更新检查
  • 用于管理监视和通知的后台服务
  • 允许自定义工作流的API
  • 公开所有属性的API
  • 可以放入应用程序中以快速入门的StatusBar控件
  • 日志记录框架集成
    • 实时LogView控制作为示例包含在内,用于可视化用于调试的日志

预览

让我们看看我们将在本文中实现的目标,这是一种在应用程序后台运行并在更新准备就绪时发出通知的静默更新服务。本文介绍了一个最小的非依赖关系注入示例和另一个支持依赖关系注入的示例实现,其示例实现了使用StatusBar显示更新进程。将有C#VB.NET中的代码。

首先,应用响应更新通知的最小实现指示可用版本并启用更新按钮。单击后,应用程序会自动应用更新并重新启动,以反映更新(已发布)的版本号。

接下来是使用 LogViewControl  WinFormsVB)和 WPFC#)示例应用程序以及ServiceProperties调试工具(控件以及用于与用户通信的StatusBar控件。

首先,VB.NET Winforms示例应用程序:

注意

  • 示例Statusbar控件具有针对服务器每次ping的检查检测信号指示器
  • 在“属性”选项卡中,我们可以看到ClickOnceService提供的所有信息,包括已安装和远程版本、应用程序的安装位置(带有复制”按钮)以及服务查找更新的位置,这对于调试任何问题都非常方便
  • 使用该LogViewer控件,当更新可用时,我们可以看到客户端与服务器的对话。

第二个是具有自定义LogViewer颜色的 C# WPF 示例应用程序:

注意

  • 在这里,我们可以看到所有日志记录都被捕获了,甚至Trace信息从HttpClient与头信息。

复古控制台(NEW!

ClickOnce不仅适用于WinFormsWPF应用程序。只是为了好玩,我使用我的RetroConsole原型库将C#VB示例应用程序放在一起。控制台应用程序使用属性日志视图模拟WinformsWPF示例应用程序。

发布、安装和运行控制台应用程序与WinformsWPF版本没有什么不同。同一ClickOnceUpdateService类用于管理静默更新和通知。此示例控制台应用程序只是一个概念证明,如果您希望在自己的控制台应用程序中使用ClickOnce,则可以将其用作示例。

先决条件

本文的项目在构建时考虑了以下几点:

  • .NET Core 7.03
  • C# 11.0和Visual Basic 16.0
  • 使用Visual Studio 2022 v17.4.5/JetBrains Rider 2022.3.2构建
  • 若要发布,需要创建测试证书
  • 对于开发托管和测试,您需要配置HOSTS文件

注意:对于最后2点,下面提供了说明。

使用了以下Nuget包:

ClickOnceUpdateService核心

这是完成所有工作的核心服务。该服务是作为异步后台任务实现的,该任务仅在托管服务器上准备好更新时才与任何应用交互。该服务还公开了许多信息属性和操作方法。这些方法可以完全控制更新的处理方式。所有活动都使用Microsoft日志记录框架进行记录。

服务的实现由两部分组成:

  1. ClickOnceUpdateOptions Configuration Options类
    • 更新的远程服务器托管的路径
    • 用于检查更新的重试间隔
  2. ClickOnceUpdateService核心背景Service类:

ClickOnceUpdateOptions

这只是一个简单的Options类:

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

可以手动设置此类,也可以在外部appsettings.json文件中进行设置:

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

ClickOnceUpdateService

这是完成所有工作的核心。如上一节所述,它支持依赖注入(DI)或手动使用。

由于服务正在与远程主机通信,因此HttpClient是必需的。对于手动使用,在内部初始化和使用IHttpClientFactory。这可确保在使用HttpClient时不会耗尽任何资源。

DI和手动使用也完全支持可选日志记录。

该服务使你作为开发人员和你的用户能够完全控制更新过程的完成方式。示例应用程序将下载并准备更新。

您可以选择通知用户并让他们选择下载更新。该服务还支持识别关键更新,如果需要,这些更新可以允许你作为开发人员覆盖用户对何时更新的选择。

IClickOnceUpdateService接口

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Public Interface IClickOnceUpdateService : Inherits IHostedService

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

End Interface

ClickOnceUpdateService

ClickOnceUpdateService的实现利用本地和远程清单来公开关键信息,并监视和准备更新。更新过程非常灵活,仅在调用ExecuteUpdateAsync时适用。这使您可以作为开发人员完全控制该过程,并且还允许用户检查更新发生的时间和方式。

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

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

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

        Initialize();
    }

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

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

        Initialize();
    }

    #endregion

    #region Fields

    #region Injected

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

    #endregion

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

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

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

    private bool _isProcessing;

    #region Cached

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

    #endregion

    #endregion

    #region Properties

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

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

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

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

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

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

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

    #endregion

    #region BackgroundService

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

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

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

            // health check tick
            OnUpdateCheck();

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

                break;
            }
        }

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

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

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

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

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

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

    #endregion

    #region Methods

    #region Partial 'ApplicationDeployment' implementation

    #region Public

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

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

        if (_currentVersion is not null)
            return _currentVersion;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        _isProcessing = true;

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

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

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

        _isProcessing = false;
        return true;
    }

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

        Process? process = OpenUrl(_setupPath!);

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

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

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

    #endregion

    #region Internals

    #region Manual HttpClientFactory for non-DI

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

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

    #endregion

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

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

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

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

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

        SetInstallFrom();
    }

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

        OnUpdateDetected();

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

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

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

        return false;
    }

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

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

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

        string result = string.Empty;

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

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

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

        return result;
    }

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

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

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

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

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

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

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

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

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

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

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

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

        HttpResponseMessage? response;

        try
        {
            using HttpClient client = HttpClientFactory();

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

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

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

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

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

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

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

        HttpClient client = _httpClientFactory!.CreateClient(HttpClientKey);

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

        return client;
    }

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

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

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

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

        return exception;
    }

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

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

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

    #endregion

    #endregion

    #endregion
}

Public Delegate Sub UpdateDetectedEventHandler(sender As Object, e As EventArgs)
Public Delegate Sub UpdateReadyEventHandler(sender As Object, e As EventArgs)
Public Delegate Sub UpdateCheckEventHandler(sender As Object, e As EventArgs)

Public NotInheritable Class ClickOnceUpdateService
    Inherits BackgroundService
    Implements IClickOnceUpdateService

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

        _options = options.Value
        _httpClientFactory = httpClientFactory
        _logger = logger

        Initialize()

    End Sub

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

        _options = options
        _logger = logger

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

        Initialize()

    End Sub

#End Region

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

#End Region

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

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

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

    Private _isProcessing As Boolean

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

#End Region

#End Region

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

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

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

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

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

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

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

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

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

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

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

#End Region

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

        While Not stoppingToken.IsCancellationRequested

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

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

            If stoppingToken.IsCancellationRequested Then
                Exit While
            End If

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

            ' health check tick
            OnUpdateCheck()

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

            Catch __unusedClickOnceDeploymentException1__ _
                  As ClickOnceDeploymentException

                ' already handled, ignore and continue

            Catch ex As HttpRequestException

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

            Catch ex As Exception

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

                Exit While

            End Try

        End While

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

    End Function

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

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

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

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

    End Function

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

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

    End Function

#End Region

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

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

        _httpClientFactory = serviceProvider.GetRequiredService(Of IHttpClientFactory)()

    End Sub

#End Region

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

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

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

        If _currentVersion IsNot Nothing Then
            Return _currentVersion
        End If

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

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

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

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

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

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

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

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

        _currentVersion = New Version(version)
        Return _currentVersion

    End Function

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

        If _installFrom = InstallFrom.Web Then

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

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

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

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

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

                        Return version

                    End Using
                End Using

            Catch ex As Exception

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

            End Try

        End If

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

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

                Return Await ReadServerManifestAsync(stream).ConfigureAwait(False)

            End Using

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

    End Function

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

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

    End Function

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

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

        _isProcessing = True

        Select Case _installFrom

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

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

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

        End Select

        _isProcessing = False
        Return True

    End Function

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

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

        Dim process = OpenUrl(_setupPath)

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

        Await process.WaitForExitAsync().ConfigureAwait(False)

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

    End Function

#End Region

#Region "Internals"
     Private Sub Initialize()

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

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

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

        If _isNetworkDeployment AndAlso Not String.IsNullOrEmpty(_applicationPath) Then

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

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

        Else

            _dataDirectory = String.Empty

        End If

        SetInstallFrom()

    End Sub

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

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

        OnUpdateDetected()

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

        If Await PrepareForUpdatingAsync().ConfigureAwait(False) Then

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

            IsUpdatingReady = True
            OnUpdateReady()
            Return True

        End If

        Return False

    End Function

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

    Private Sub SetInstallFrom()

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

    End Sub

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

        depth += 1

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

        Dim result = String.Empty

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

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

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

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

        Return result

    End Function

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

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

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

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

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

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

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

        ' get optional minim version - not always set

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

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

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

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

        End If

        _serverVersion = New Version(version)
        Return _serverVersion

    End Function

    Private Async Function GetSetupFromServerAsync() As Task

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

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

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

        Dim response As HttpResponseMessage

        Try
            Using client As HttpClient = HttpClientFactory()

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

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

            End Using

        Catch ex As Exception

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

        End Try

        Try

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

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

        Catch ex As Exception

            _setupPath = String.Empty

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

        End Try

    End Function

    Private Shared Function OpenUrl(url As String) As Process

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

    End Function

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

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

        Dim client As HttpClient = _httpClientFactory.CreateClient(HttpClientKey)

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

        Return client

    End Function

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

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

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

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

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

        Return exception

    End Function

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

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

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

#End Region

#End Region

#End Region

End Class

注意

  • ClickOnceUpdateService旨在支持依赖注入和手动初始化(无依赖注入)。对于无依赖项注入,该类在内部处理HttpClient以避免资源耗尽。
ClickOnceUpdateService——属性

属性

描述

ApplicationName

完整的应用程序名称

ApplicationPath

应用程序安装位置的路径

DataDirectory

存储的应用程序数据的路径

IsNetworkDeployment

是否安装了应用程序

IsUpdatingReady

是否有更新就绪

IsMandatoryUpdate

当前安装的版本低于所需的远程最低版本

PublishingPath

安装文件和清单的服务器路径

RetryInterval

检查更新的频率(以毫秒为单位)(最小1000毫秒/1 秒)

知道应用程序文件和数据文件在计算机上的位置现在是一项简单的任务——ApplicationPath& DataDirectory属性公开此信息。

ClickOnceUpdateService——方法

方法

描述

CurrentVersionAsync

获取当前安装的版本

ServerVersionAsync

获取远程服务器版本

UpdateAvailableAsync

手动检查是否有更新的版本

PrepareForUpdatingAsync

准备更新应用程序

ExecuteUpdateAsync

启动更新过程

准备更新和应用更新的过程是一个手动过程。这允许应用程序为用户提供有关如何以及何时应用更新的选项。

ClickOnceUpdateService——后台服务方法

方法

描述

StartAsync

开始检查更新

StopAsync

停止检查更新

ClickOnceUpdateService——事件

事件

描述

UpdateCheck

让应用程序知道更新检查正在进行中

UpdateDetected

找到更新并开始准备

UpdateReady

更新已准备就绪,需要重新启动

可以在启动服务时传递一个CancellationToken以进行远程取消。

如果找到更新,该服务将自动停止轮询更新。

实现

ClickOnceUpdateService实现对以下各项的支持分为两部分:

  1. 启动服务、未处理的应用程序异常,并重新启动到新版本。
  2. 用户反馈和互动

WinFormsWPF应用程序的实现略有不同。每个都将单独涵盖。

WinForms实现——简单/最小

对于最小的实现,在没有依赖注入的情况下,我们需要:

  1. 引用ClickOnceUpdateService并传入配置设置。
  2. 挂钩UpdateCheckUpdateReady事件。
  3. 启动后台服务ClickOnceUpdateService
  4. 更新准备就绪后,使用该ExecuteUpdateAsync方法重新启动应用程序,更新将下载、安装并重新启动应用程序。

下面是上述步骤的示例代码:

public partial class Form1 : Form
{
    #region Constructors

    public Form1()
    {
        InitializeComponent();

        Configure();

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

    #endregion

    #region Fields

    private ClickOnceUpdateService? _updateService;

    #endregion

    #region Methods

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

        _updateService = new ClickOnceUpdateService(options);

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

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

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

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

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

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

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

        Application.Exit();
    }

    // optional cleaning up...
    #endregion

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

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

Public Class Form1

#Region "Constructors"
     Public Sub New()

        InitializeComponent()

        Configure()
        Dim task = StartServiceAsync()

    End Sub

#End Region

#Region "Fields"
     Private _updateService As ClickOnceUpdateService

#End Region

#Region "Methods"
     Private Sub Configure()

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

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

        _updateService = New ClickOnceUpdateService(options)

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

    End Sub

    Private Async Function StartServiceAsync() As Task

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

        Try

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

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

        Catch ex As ClickOnceDeploymentException

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

        End Try

    End Function

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

        Debug.WriteLine("OnUpdateCheck")

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

    End Sub

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

        Debug.WriteLine("OnUpdateReady")

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

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

    End Sub

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

        Dim task = RestartAsync()

    End Sub

    Private Async Function RestartAsync() As Task

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

        Forms.Application.Exit()

    End Function

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

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

        Dim task = _updateService.StopAsync(CancellationToken.None)

    End Sub

#End Region

End Class

注意

  • 在上面的示例中,我们使用AppSettings帮助程序类从appsettings*.json文件加载配置。有一篇单独的文章讨论了它的工作原理:.NET应用设置揭秘(C#VB)。(YIYOU)

下面是简单/最小实现的动画:

WPF实现——简单/最小

对于 WPF,该过程与 WinForms 相同:

  1. 引用ClickOnceUpdateService并传入配置设置。
  2. 挂钩UpdateCheckUpdateReady事件。
  3. 启动后台服务ClickOnceUpdateService
  4. 更新准备就绪后,使用该ExecuteUpdateAsync方法重新启动应用程序,更新将下载、安装并重新启动应用程序。

下面是上述步骤的示例代码:

public partial class MainWindow
{
    #region Constructors

    public MainWindow()
    {
        InitializeComponent();

        Configure();
        _ = StartServiceAsync();
    }

    #endregion

    #region Fields

    private ClickOnceUpdateService? _updateService;

    #endregion

    #region Methods

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

        _updateService = new ClickOnceUpdateService(options);

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

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

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

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

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

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

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

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

        Application.Current.Shutdown();
    }

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

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

    #endregion
}

Class MainWindow

#Region "Constructors"
     Public Sub New()

        InitializeComponent()

        Configure()
        Dim task = StartServiceAsync()

    End Sub

#End Region

#Region "Fields"
     Private _updateService As ClickOnceUpdateService

#End Region

#Region "Methods"
     Private Sub Configure()

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

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

        _updateService = New ClickOnceUpdateService(options)

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

    End Sub

    Private Async Function StartServiceAsync() As Task

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

        Try

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

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

        Catch ex As ClickOnceDeploymentException

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

        End Try

    End Function

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

        Debug.WriteLine("OnUpdateCheck")

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

    End Sub

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

        Debug.WriteLine("OnUpdateReady")

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

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

    End Sub

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

        Dim task = RestartAsync()

    End Sub

    Private Async Function RestartAsync() As Task

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

        Application.Current.Shutdown()

    End Function

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

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

        Dim task = _updateService.StopAsync(CancellationToken.None)

    End Sub

#End Region

End Class

注意

  • 在上面的示例中,我们使用AppSettings帮助程序类从appsettings*.json文件加载配置。有一篇单独的文章讨论了它的工作原理:.NET应用设置揭秘(C#和VB)。(YIYOU)

StatusBar通知示例 

如果您希望为用户提供更完美的体验,我提供了一个Statusbar示例实现,用于传达应用程序版本以及新版本何时可用。

 

依赖注入支持

由于ClickOnceUpdateService实现使用Microsoft.Extensions.Hosting.BackgroundService类,因此它完全符合Microsoft托管来管理服务状态。

依赖关系注入服务的连接包装在一个ServicesExtension类中:

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

要将服务添加到应用程序,我们需要做的就是:

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()

然后启动服务:

private readonly CancellationTokenSource _cancellationTokenSource;

 _cancellationTokenSource = new();

 // startup background services
  _ = _host.StartAsync(_cancellationTokenSource.Token);

Private Shared _cancellationTokenSource As CancellationTokenSource

_cancellationTokenSource = New CancellationTokenSource()

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

当有可用更新时,该服务将自动停止轮询并引发UpdateDetectedUpdateReady事件。然后,这是一个手动过程,可以调用ExecuteUpdateAsync以完成更新过程。

下面是该过程的动画,其中StatusBar控件正在监视ClickOnceUpdateService事件并让用户了解情况:

虽然动画用于WPF应用程序,但示例WinForms应用程序的外观和工作方式相同。本文顶部的下载链接中提供了所有源代码。

如果您想了解有关LogView控件的更多信息,以及它如何与MicrosoftSerilogNLogLog4Net日志记录框架集成,请查看这篇专门的文章:C#和VB中WinForms、WPF和Avalonia的LogViewer控件

ClickOnce准备桌面应用程序

测试任何ClickOnce更新支持都需要在实时服务器或本地主机上安装和运行。下一节将介绍:

  • 配置启动设置
  • 使用发布配置文件创建基于Web的ClickOnce安装程序
  • 在本地和实时服务器上承载ClickOnce安装程序
  • 如何在本地计算机上运行测试Web安装以及所需的设置
  • 避免常见的陷阱
  • 如何测试静默更新程序

在处理应用程序时,在开发周期中有一段时间需要多种部署状态——开发/本地测试、Stage(可选,但建议)和生产/实时部署。为此,我们需要多个启动配置文件和发布配置文件。

在本文中,我将桌面应用程序和Web应用程序/网站分成2个独立的项目。这使我们能够保持网站运行并测试多个应用程序构建和部署。就像应用程序一样,Web应用程序将有自己的启动配置文件,具体取决于它的部署位置。

设置启动配置文件

桌面应用程序和托管Web服务器都有多个配置文件。下面,我们将介绍每个示例。就本文而言,我们的虚构网站silentupdater.net

桌面应用程序

我们需要使用appsettings*.json文件配置启动配置文件。为此,我们需要四个文件:

  1. appsettings.json——-开发和生产通用
  2. appsettings.Development.json——用于开发配置
  3. appsettings.Staging.json——用于Staging配置
  4. appsettings.Production.json——用于实时/生产选项
配置开发和生产环境

若要设置DOTNET_ENVIRONMENT变量,请在解决方案资源管理器中右键单击应用程序名称,打开应用程序属性,导航到调试>常规部分,然后单击打开调试启动配置文件 UI。这将打开启动配置文件窗口。

或者,我们可以从工具栏访问启动配置文件窗口:

我们感兴趣的是环境变量。您可以在此处设置任何内容。我们需要添加的是名称:DOTNET_ENVIRONMENT,值是:Development。没有关闭按钮,只需关闭窗口并选择文件”>保存...”。从VS菜单。

属性文件夹中找到的launchSettings.json文件中的启动配置文件设置示例:

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

注意launchSettings.json文件仅适用于Visual Studio。它不会被复制到已编译的文件夹中。如果将它包含在应用程序中,运行时将忽略它。需要在应用程序中手动支持此文件,以便在Visual Studio外部使用。

文件:appsettings.json

这是根设置文件。如果此处有设置,则可选的开发/生产设置不会覆盖此设置。由于我们将使用不同的启动配置文件覆盖设置,因此appsettings.json将具有默认设置。

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

文件:appsettings. Development.json

我们对详细日志记录感兴趣。另外,短的RetryInterval,我将其设置为1秒,因此测试速度很快。最后,PublishingPath指向一个用于在开发环境中进行测试的特殊URL——在本文后面将详细介绍为什么我们需要这样做。

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

文件:appsettings. Production.json

我们只对警告和关键日志记录感兴趣。RetryInterval设置为每30秒一次,PublishingPath并指向我们的实时制作网站。

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

选择配置文件

要选择要测试的配置文件,我们可以从工具栏中进行选择:

配置要发布的配置文件

发布项目时,请务必为每个文件设置生成操作。您不希望将开发配置文件发布到用户的计算机,因此请将生成操作设置为等于None

要发布的文件需要设置为Content

注意:如果未正确执行此操作,则无论发布配置文件中的设置如何,都不会包含标记为Build Action: None的文件,并且将遇到安装失败。下面是一个示例,说明它是如何出错的:

在这里,您可以看到appsettings.Development.json文件设置为发布:

但是,由于为appsettings.Development.json设置了Build Action: None但该文件丢失,将列在清单中,供发布者安装。

Web应用程序

由于这是一个示例应用程序,因此不使用其他配置文件。下面是本文使用的默认appsettings.json文件:

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

注意:我使用了Trace级别日志记录,以便我们可以准确地看到发生了什么。但是,对于实时Web应用程序,您通常只对关键信息使用Warning

设置发布配置文件

桌面应用程序

接下来,我们需要为ClickOnce设置发布配置文件。

目标

当我们使用ClickOnce时,我们需要选择以下ClickOnce选项:

发布位置

在这里,我们将路径设置为网站中的路径位置。文件需要位于wwwroot路径中。这样就无需手动移动文件。

安装位置

现在我们需要指出ClickOnce将在哪里寻找更新。由于限制,我们还需要复制此路径并将其添加到正确的appsettings*.json文件中。

重要提示:两者必须相同,否则更新检查或安装将失败。

设置

第三个是多页选项。对于静默更新,重要字段是应用程序将检查更新必须取消选中。这将阻止启动应用程序窗口在应用程序启动时出现,并允许静默服务在应用程序启动后在应用程序后台执行该过程。

先决条件

发布选项

注意

  • Support URL字段由ClickOnce Installer和Uninstaller进程使用。
  • Error URLClickOnce用于自动发布任何错误信息。

注意

  • 仅当要使用默认的Microsoft页面时,Automatically generate the following webpage after publish字段才是必需的。我个人的建议是不要使用它,而是使用生成的*.application清单文件。可下载的演示同时使用这两种方法,因此您可以看到它们的工作原理。

签署清单

您应该始终在ClickOnce清单上签名,以减少任何黑客攻击的机会。你可以购买并使用你自己的(发布应用程序确实需要),也可以让VS为你生成一个(仅用于测试)。即使只测试应用程序,也是一种很好的做法。这是在设置发布配置文件的过程中完成的。签名清单部分包含创建测试证书选项。

注意

  • 如果在同一网站上发布了多个应用,则使用发布”向导,将从所选的原始目录中复制证书。您需要在文本编辑器中加载每个发布配置文件,并指向找到证书的目录,然后删除复制的版本。下面,你可以看到我是如何做到的:

 

配置

仅当您知道自己在做什么时才更改这些设置。选择的默认值应适用于大多数安装。

发布窗口

创建发布配置文件后,我们可以在发布之前选择它们。强烈建议重命名每个配置文件。下面我们将介绍此过程。

选择重命名

输入新建配置文件名称,然后单击重命名按钮:

现在,请确保选择了正确的发布配置文件:

Web应用程序

重要提示:关键字段是环境变量应用网址

以下是本文使用的启动设置。

{
    "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
        }
    }
}

发布应用程序

我们需要做的就是打开发布窗口,选择配置文件,然后按发布按钮。在这里,我们可以看到我们成功发布了:

如果已正确设置发布配置文件,则安装文件将自动添加到Web应用程序中:

注意:我们有两个选项可以向用户公开安装程序:

  1. Publish.html文件
  2. [application_name].application清单文件

我们稍后将在安装和测试静默更新”(接下来的章节部分)部分介绍这些内容。

Web应用程序中托管

对于本文,我需要一个简单的网站来托管部署页面,因此我选择了最小API和静态页面。以下是最低限度的API实现使用:

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

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

app.UseRouting();

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

app.Run();

它只是提供一个html文件,在本例中为wwwroot/index.html文件:

VB.NET有自己的实现:

Module Program
    Sub Main(args As String())

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

        app.UseHttpsRedirection()
        app.UseStaticFiles()

        app.UseRouting()

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

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

            End Function)

        app.Run()

    End Sub

End Module

安装和测试静默更新

安装、重新发布、重新托管、运行、更新和重启的步骤。

  1. 启动主机Web应用程序/服务器。
  2. 将应用程序发布到主机Web应用程序/服务器。
  3. 安装应用程序。
  4. 应用程序将自动运行(不要停止它)。
  5. 重新发布应用程序。
  6. 等待应用程序执行检查并通知已准备好更新。
  7. 单击“重新启动”按钮,应用程序将关闭、更新,然后自动重新启动
  8. 现在检查更新的版本号。

在本文前面,我提到用户可以通过两种方法从主机Web应用程序/服务器进行安装:

  1. 通过生成的Publish.HTML页面
  2. app_name.Application清单文件

在下一节中,我们将探讨每个使用测试证书的过程。

使用Publish.html文件进行安装

以下是自动生成的publish.html文件。这是可选的。让我们看一下安装过程,其中包含设置发布配置文件时生成的测试证书。在安装过程之前,有几个警告和过程需要执行下载:

接下来,我们需要单击查看更多选项:

选择Keep

现在,由于我们使用的是测试证书,因此我们需要选择显示更多,然后选择仍然保留

现在,setup.exe文件已下载。我们需要选择打开文件

安装现在将继续。

使用应用程序清单进行安装

选择安装*.application清单要简单得多,并且是首选。下载setup.exe文件只需一个步骤:

下载后,安装现在将继续进行。只需单击打开按钮。

安装

下载安装程序后,它将运行、下载和安装应用程序,然后启动应用程序。

在这里,我们可以看到我们设置的发布选项。详细信息链接将指向提供的支持URL

注意

  • 如果在发布时使用了已批准的证书,则不会出现警告,并且会显示发布服务器。

现在应用程序已下载:

下载完成并安装应用程序后,应用程序将自动运行:

开发电脑设置不正确的示例

现在我们将查看一个成功的安装,但是,配置不正确。

一切似乎都正常工作,但是,应用程序是从localhost安装的,并且Hosts文件未配置网络计算机名称。单击单击以重新启动按钮时,将引发以下错误:

如果我们尝试使用安装程序尝试的URL,我们将看到以下内容:

服务器更改为计算机名称

如果不卸载错误安装的应用程序,更改主机文件,然后尝试进行更新,则会遇到类似以下错误的情况:

必须卸载应用程序,然后安装才会成功。

配置开发电脑以进行本地测试

与任何开发周期一样,建议在本地计算机上进行测试安装和更新。为此,需要一个步骤来配置虚拟主机。没有这个,你会遇到困难。

我们已经配置了用于发布和托管的应用程序,接下来我们需要通过Hosts配置文件来配置开发电脑。Hosts文件位于C:\Windows\System32\drivers\etc目录中。

HOSTS文件是一个特殊文件,通常需要管理员权限才能保存更改。

我用来进行更改的方法如下:

  1. 按任务栏上的开始”按钮,然后找到记事本应用程序。
  2. 右键单击记事本应用程序,然后选择“以管理员身份运行”。
  3. 进入记事本后,转到文件”→打开”。切换到文件夹C:\Windows\System32\Drivers\etc
  4. 现在对hosts文件进行更改。
  5. 保存更改。

下面是我们将用于本文的示例和提供的代码:

# 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

我有两组IP映射:

  1. 主机服务器网站映射到本地计算机
  2. 映射LocalHost到网络计算机名称

您不需要两者,但是,要了解它们的工作原理,最好同时设置两者。第二种是,如果您选择localhost用于Web应用程序,则可以减少安装错误。

现在,您可以运行Web服务器并进行测试。上面的Hosts设置用于尝试下载。如果不使用上面的Hosts配置设置,您将遇到问题。

总结

我们已经介绍了Microsoft ClickOnce从开发、发布、托管、安装到用户友好的静默ClickOnceUpdateService,以提供友好的用户体验。作为开发人员,ClickOnceUpdateService为您的应用程序提供后台服务,该服务将为您完成所有工作,并允许您访问通常不可见的信息。最后,你可以将StatusBar控件拖放到应用中以快速入门,以及用于帮助实时调试应用程序的LogViewerServiceProperties控件。

引用

文档、文章等

Nuget

https://www.codeproject.com/Articles/5359156/NET-Silent-ClickOnce-Installer-for-Winform-WPF-in

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值