65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIconstarIcon

5.00/5 (18投票s)

2023年4月17日

CPOL

22分钟阅读

viewsIcon

58976

downloadIcon

1582

适用于 Winform、WPF 和 Console 的 .NET 兼容 C# 和 VB 静默 ClickOnce 更新后台工作服务

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

更新:2023年4月25日 - v1.10

增加了 C#VB 示例/概念验证控制台应用程序 + 用于高级控制台渲染的 RetroConsole(原型)库 - 参见 预览 部分的 Retro Console

目录

引言

微软和第三方公司有许多不同的安装程序框架系统。其中许多需要部分或完全手动交互,有些像 ClickOnce 可以自动化此过程。本文将介绍 .NET Core 3.1+ 的 ClickOnce。

定义

什么是 ClickOnce?微软将其定义为:

引用

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

概述

许多应用程序利用类似的机制来管理其应用程序的最新性,并在需要重新启动以应用更新时与用户进行通信。例如 Google Chrome、Microsoft Edge 和 Discord。

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

这是 Google Chrome 的更新通知程序

微软的 ClickOnce 实现有点笨拙。更新检查发生在应用程序启动时,并在应用程序启动之前弹出一个令人不快的更新检查窗口。

我们将解决这个问题。

优点

将 ClickOnce 用于 Windows 桌面应用程序的主要优点是:

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

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

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

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

预览

让我们看看本文将实现什么,一个在应用程序后台运行的静默更新服务,并在更新准备就绪时通知。本文涵盖了最小的非依赖注入示例和另一个支持依赖注入的示例实现,该实现使用 StatusBar 显示更新过程。代码将同时包含 C#VB.NET

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

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

首先是一个 VB.NET Winforms 示例应用程序

注释

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

其次是具有自定义 LogViewer 颜色的 C# WPF 示例应用程序

注释

  • 在这里,我们可以看到所有日志都被捕获,甚至来自 HttpClient 的带有头部信息的 Trace 信息。

复古控制台(新增!)

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 文件

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

使用了以下 Nuget 包:

ClickOnceUpdateService 核心

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

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

  1. ClickOnceUpdateOptions 配置选项类
    • 远程服务器托管更新的路径
    • 检查更新的重试间隔
  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 & 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 & 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 & 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 & 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 - 属性
属性 描述
应用程序名称 完整的应用程序名称
应用程序路径 应用程序安装路径
数据目录 存储的应用程序数据路径
IsNetworkDeployment 应用程序是否已安装
IsUpdatingReady 是否有更新准备就绪
IsMandatoryUpdate 当前安装的版本低于远程所需的最低版本
发布路径 安装文件和清单的服务器路径
RetryInterval 多久检查一次更新(最小 1000 毫秒/1 秒)

现在,了解应用程序文件和数据文件在计算机上的位置是一个简单的任务 - ApplicationPathDataDirectory 属性公开了此信息。

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)

这是简单/最小实现的动画

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)

状态栏通知示例

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

依赖注入支持

由于 ClickOnceUpdateService 实现使用了 Microsoft.Extensions.Hosting.BackgroundService 类,它完全符合 Microsoft Hosting 管理服务状态的要求。

服务的依赖注入配置封装在 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 Control 的信息,以及它如何与 Microsoft、Serilog、NLog 或 Log4Net 日志框架集成,请查看这篇专门的文章:WinForms、WPF 和 Avalonia 中 C# 和 VB 的 LogViewer 控件

准备用于 ClickOnce 的桌面应用程序

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

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

在开发应用程序时,开发周期中需要多个部署状态——开发/本地测试、暂存(可选但推荐)和生产/实时部署。为了设置这些,我们需要多个启动配置文件和发布配置文件。

对于本文,我将桌面应用程序和 Web 应用程序/网站分为两个独立的项。这使我们能够保持网站运行并测试多个应用程序构建和部署。就像应用程序一样,Web 应用程序将根据部署位置拥有自己的启动配置文件。

设置启动配置文件

桌面应用程序和托管 Web 服务器都具有多个配置文件。下面,我们将为每个配置文件提供一个示例。为本文的目的,我们的虚构网站是 silentupdater.net

桌面应用程序

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

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

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

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

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

Properties 文件夹中找到的 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
  }
}

选择配置文件

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

配置要发布的配置文件

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

要发布的文件需要设置为 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 文件中。

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

设置

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

必备组件

发布选项

注释

  • 支持 URL 字段由 ClickOnce 安装程序和卸载程序进程使用。
  • 错误 URLClickOnce 用于自动发布任何错误信息。

注释

  • 发布后自动生成以下网页 字段仅在您想使用默认的 Microsoft 页面时才需要。我的建议(个人选择)是不使用此选项,而是使用生成的 *.application 清单文件。可下载的演示同时使用了两者,因此您可以看到它们的工作方式。

签名清单

您应该始终签署 ClickOnce 清单以减少被黑客攻击的可能性。您可以购买并使用自己的证书(发布应用程序时确实需要),或者您可以让 VS 为您生成一个(仅用于测试)。即使只测试应用程序,这也是一个好习惯。这是设置发布配置文件的一部分。签名清单部分有创建测试证书的选项。

注意

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

配置

如果您不了解这些设置的含义,请不要更改它们。默认选择应适用于大多数安装。

发布窗口

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

选择重命名

输入新配置文件名称并单击重命名按钮

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

Web 应用程序

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

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

{
    "profiles": {
        "Development_localhost_http": {
            "commandName": "Project",
                "launchBrowser": true,
                "environmentVariables": {
                "ASPNETCORE_ENVIRONMENT": "Development"
            },
            "dotnetRunMessages": true,
                "applicationUrl": "https://: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://:7285;https://:5216",
            "environmentVariables": {
            "ASPNETCORE_ENVIRONMENT": "Development"
        }
    },
    "IIS Express": {
        "commandName": "IISExpress",
            "launchBrowser": true,
            "environmentVariables": {
            "ASPNETCORE_ENVIRONMENT": "Development"
        }
    },
    "iisSettings": {
        "windowsAuthentication": false,
            "anonymousAuthentication": true,
            "iisExpress": {
            "applicationUrl": "https://:51927",
                "sslPort": 44366
        }
    }
}

发布您的应用程序

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

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

注意:我们有两种方式向用户提供安装程序:

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

我们稍后将在安装和测试静默更新部分介绍这些内容。

在 Web 应用程序中托管

对于本文,我需要一个简单的网站来托管部署页面,所以我选择了 Minimal API 和一个静态页面。以下是 Minimal 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 文件。这是可选的。让我们看一下使用发布配置文件设置时生成的测试证书的安装过程。在安装过程之前,有几个警告和下载过程。

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

选择保留

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

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

安装现在将继续。

使用应用程序清单安装

选择安装 *.application 清单要简单得多,也更受推荐。只需一步即可下载 setup.exe 文件。

下载完成后,安装将继续进行。只需点击打开按钮。

安装

一旦安装程序下载完成,它将运行、下载并安装应用程序,然后启动应用程序。

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

注释

  • 如果发布时使用了经过批准的证书,则不会有警告,并且会显示发布者。

现在应用程序已下载

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

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

现在我们来看看一个成功的安装,但配置不正确的情况。

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

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

服务器更改为计算机名称

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

<

(点击查看全尺寸图片)

 

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

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

与任何开发周期一样,建议在本地机器上进行测试安装和更新。为此,需要配置 Web 托管的一个步骤。没有它,您将遇到困难。

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

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

我使用以下方法进行更改:

  1. 按任务栏上的开始按钮,找到记事本应用程序。
  2. 右键单击记事本应用程序,选择以管理员身份运行
  3. 进入记事本后,转到文件打开。更改到文件夹 C:\Windows\System32\Drivers\etc
  4. 现在对主机文件进行更改。
  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 配置设置,您将遇到问题。

摘要

我们已经涵盖了从开发、发布、托管、安装,到最终为用户提供友好体验的静默 ClickOnceUpdateService 的 Microsoft ClickOnce。作为开发人员,ClickOnceUpdateService 为您的应用程序配备了一个后台服务,它将为您完成所有工作,并让您访问通常不可见的信息。最后,一个您可以放入应用程序中快速启动的 StatusBar 控件,以及 LogViewerServiceProperties 控件,可帮助您实时调试应用程序。希望本文能让您比我拥有更多的头发。

如果您有任何问题,请在下方发布,我将非常乐意回答。

参考文献

文档、文章等

Nuget 包

历史

  • 2023年4月17日 - v1.0 - 首次发布
  • 2023年4月25日 - v1.10 - 添加了 C#VB 示例/概念验证控制台应用程序 + 用于高级控制台渲染的 RetroConsole(原型)库 - 参见 预览 部分的 Retro Console
  • 2023年10月13日 - v1.10a - 更新了下载以更正不正确的 zip 文件类型(之前错误地将 rar 伪装成 zip 文件 - 已修复)
© . All rights reserved.