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





5.00/5 (18投票s)
适用于 Winform、WPF 和 Console 的 .NET 兼容 C# 和 VB 静默 ClickOnce 更新后台工作服务
- 下载 DotNet_UpdateClickOnceService_v1.10a (zip) - 8 MB
- 下载 DotNet_UpdateClickOnceService_v1.10 (rar) - 9.9 MB
- 下载 DotNet_UpdateClickOnceService_v1.00 (zip) - 7.3 MB [已过时]
更新:2023年4月25日 - v1.10
增加了 C# 和 VB 示例/概念验证控制台应用程序 + 用于高级控制台渲染的 RetroConsole
(原型)库 - 参见 预览 部分的 Retro Console。
目录
- 引言
- 预览
- 必备组件
- ClickOnceUpdateService 核心
- 实现
- 准备用于 ClickOnce 的桌面应用程序
- 发布您的应用程序
- 在 Web 应用程序中托管
- 安装和测试静默更新
- 摘要
- 参考文献
- 历史
引言
微软和第三方公司有许多不同的安装程序框架系统。其中许多需要部分或完全手动交互,有些像 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 桌面应用程序的主要优点是:
- 易于发布
- 易于安装
- 自动更新系统
本文的目的是消除那个笨拙的窗口,并在应用程序后台静默监控任何更新。如果发现更新,则准备更新并通知应用程序/用户更新已准备就绪,然后应用程序可以自动更新或允许用户选择何时更新。最后,作为开发人员,可以完全控制次要更新与主要/强制更新策略。
因此,主要目标可以总结如下:
- WinForms 和 WPF 支持(控制台应用程序实现可能)
- 在应用程序运行之前移除默认的微软更新检查
- 一个后台服务来管理监控和通知
- 允许自定义工作流的 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
不仅仅适用于 WinForms 和 WPF 应用程序。为了好玩,我使用我的 RetroConsole
原型库编写了 C# 和 VB 示例应用程序。控制台应用程序模仿了带有属性和日志视图的 Winforms 和 WPF 示例应用程序。
发布、安装和运行控制台应用程序与 Winforms 和 WPF 版本没有区别。相同的 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 包:
- Microsoft.Extensions.Configuration
- Microsoft.Extensions.Configuration.EnvironmentVariables
- Microsoft.Extensions.Configuration.Json
- Microsoft.Extensions.Hosting
- Microsoft.Extensions.Http
- Microsoft.Extensions.Logging.Abstractions
- Microsoft.Extensions.Options.ConfigurationExtensions
- SingleInstanceCore - 用于管理应用程序单实例(推荐)
ClickOnceUpdateService 核心
这是完成所有工作的核心服务。该服务被实现为异步后台任务,仅在托管服务器上有更新准备就绪时才与任何应用程序交互。该服务还公开了许多信息属性和操作方法。这些方法提供了对更新如何处理的完全控制。所有活动都使用 Microsoft 日志框架进行日志记录。
服务的实现由两部分组成:
ClickOnceUpdateOptions
配置选项类- 远程服务器托管更新的路径
- 检查更新的重试间隔
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 秒) |
现在,了解应用程序文件和数据文件在计算机上的位置是一个简单的任务 - ApplicationPath
和 DataDirectory
属性公开了此信息。
ClickOnceUpdateService - 方法
方法 | 描述 |
CurrentVersionAsync | 获取当前安装的版本 |
ServerVersionAsync | 获取远程服务器版本 |
UpdateAvailableAsync | 手动检查是否有新版本 |
PrepareForUpdatingAsync | 准备更新应用程序 |
ExecuteUpdateAsync | 启动更新过程 |
准备更新和应用更新是手动过程。这允许应用程序向用户提供关于如何以及何时应用更新的选项。
ClickOnceUpdateService - 后台服务方法
方法 | 描述 |
StartAsync | 开始检查更新 |
StopAsync | 停止检查更新 |
ClickOnceUpdateService - 事件
事件 | 描述 |
UpdateCheck | 通知应用程序正在进行更新检查 |
UpdateDetected | 发现更新并已开始准备 |
UpdateReady | 更新已准备就绪,需要重新启动 |
启动服务时可以传递 CancellationToken
以进行远程取消。
如果发现更新,服务将自动停止轮询更新。
实现
实现 ClickOnceUpdateService
支持有两部分:
- 启动服务、未处理的应用程序异常以及重新启动到新版本。
- 用户反馈和交互
WinForms 和 WPF 应用程序的实现略有不同。将分别介绍。
WinForms 实现 - 简单/最小
对于没有依赖注入的最小实现,我们需要:
- 引用
ClickOnceUpdateService
并传入配置设置。 - 挂钩
UpdateCheck
和UpdateReady
事件。 - 启动后台服务
ClickOnceUpdateService
。 - 当更新准备就绪时,使用
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 相同
- 引用
ClickOnceUpdateService
并传入配置设置。 - 挂钩
UpdateCheck
和UpdateReady
事件。 - 启动后台服务
ClickOnceUpdateService
。 - 当更新准备就绪时,使用
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)
当有更新可用时,服务将自动停止轮询并触发 UpdateDetected
和 UpdateReady
事件。然后,需要手动调用 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 文件配置我们的启动配置文件。为此,我们需要四个文件:
- appsettings.json - 开发和生产通用
- appsettings.Development.json - 用于开发配置
- appsettings.Staging.json - 用于暂存配置
- 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
安装程序和卸载程序进程使用。错误 URL
由ClickOnce
用于自动发布任何错误信息。
注释
发布后自动生成以下网页
字段仅在您想使用默认的 Microsoft 页面时才需要。我的建议(个人选择)是不使用此选项,而是使用生成的 *.application 清单文件。可下载的演示同时使用了两者,因此您可以看到它们的工作方式。
签名清单
您应该始终签署 ClickOnce
清单以减少被黑客攻击的可能性。您可以购买并使用自己的证书(发布应用程序时确实需要),或者您可以让 VS 为您生成一个(仅用于测试)。即使只测试应用程序,这也是一个好习惯。这是设置发布配置文件的一部分。签名清单部分有创建测试证书的选项。
注意
- 如果您在同一个网站上发布多个应用程序,使用发布向导,证书将从您选择的原始目录复制。您需要用文本编辑器加载每个发布配置文件,并指向证书所在的目录,然后删除复制的版本。下面,您可以看到我是如何做的:
配置
如果您不了解这些设置的含义,请不要更改它们。默认选择应适用于大多数安装。
发布窗口
创建发布配置文件后,我们可以在发布前选择它们。强烈建议重命名每个配置文件。下面我们将逐步介绍此过程。
选择重命名
输入新配置文件名称并单击重命名按钮
现在确保选择了正确的发布配置文件
Web 应用程序
以下是本文使用的启动设置。
{
"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 应用程序中。
注意:我们有两种方式向用户提供安装程序:
- 旧版 Publish.html 文件
- [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
安装和测试静默更新
安装、重新发布、重新托管、运行、更新和重新启动的步骤。
- 启动主机 Web 应用程序/服务器。
- 将应用程序发布到您的主机 Web 应用程序/服务器。
- 安装应用程序。
- 应用程序将自动运行(不要停止它)。
- 重新发布应用程序。
- 等待应用程序进行检查并通知有更新准备就绪。
- 点击重新启动按钮,应用程序将关闭、更新,然后自动重新启动。
- 现在检查更新后的版本号。
在文章前面,我提到用户可以通过两种方式从主机 Web 应用程序/服务器安装:
- 通过生成的 publish.html 页面
- 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 文件是一个特殊文件,通常需要管理员权限才能保存更改。
我使用以下方法进行更改:
- 按任务栏上的开始按钮,找到记事本应用程序。
- 右键单击记事本应用程序,选择以管理员身份运行。
- 进入记事本后,转到文件 → 打开。更改到文件夹 C:\Windows\System32\Drivers\etc。
- 现在对主机文件进行更改。
- 保存更改。
这是我们将用于本文和所提供代码的示例
# 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 映射:
- 主机服务器网站映射到本地机器
- 将
LocalHost
映射到网络计算机名称
您不需要两者,但是为了了解它们的工作原理,最好同时设置两者。第二个是为了缓解如果您选择将 localhost
用于您的 Web 应用程序而导致的安装错误。
现在您可以运行您的 Web 服务器并进行测试了。上述 Hosts
设置是为了尝试下载。如果不使用上述 Hosts
配置设置,您将遇到问题。
摘要
我们已经涵盖了从开发、发布、托管、安装,到最终为用户提供友好体验的静默 ClickOnceUpdateService
的 Microsoft ClickOnce。作为开发人员,ClickOnceUpdateService
为您的应用程序配备了一个后台服务,它将为您完成所有工作,并让您访问通常不可见的信息。最后,一个您可以放入应用程序中快速启动的 StatusBar
控件,以及 LogViewer
和 ServiceProperties
控件,可帮助您实时调试应用程序。希望本文能让您比我拥有更多的头发。
如果您有任何问题,请在下方发布,我将非常乐意回答。
参考文献
文档、文章等
- ClickOnce 安全和部署
- 适用于 Windows 的 .NET ClickOnce
- 发布 ClickOnce 应用程序
- .NET 中的依赖注入 | Microsoft Learn
- Model-View-ViewModel (MVVM) | Microsoft Learn
- 数据绑定概述 (Windows Forms .NET)
- 数据绑定概述 (WPF .NET)
- ASP.NET Core 中带托管服务的后台任务
- 宣布 .NET 5.0 RC 2 - ClickOnce
- 如何在 Windows 10 中编辑 Hosts 文件
- 如何在 Windows 11 上编辑 HOSTS 文件
- .NET 应用程序设置揭秘 (C# & VB)
- WinForms、WPF 和 Avalonia 中 C# 和 VB 的 LogViewer 控件
Nuget 包
- Microsoft.Extensions.Configuration
- Microsoft.Extensions.Configuration.EnvironmentVariables
- Microsoft.Extensions.Configuration.Json
- Microsoft.Extensions.Hosting
- Microsoft.Extensions.Http
- Microsoft.Extensions.Logging.Abstractions
- Microsoft.Extensions.Options.ConfigurationExtensions
- SingleInstanceCore - 用于管理应用程序单实例(推荐)
历史
- 2023年4月17日 - v1.0 - 首次发布
- 2023年4月25日 - v1.10 - 添加了 C# 和 VB 示例/概念验证控制台应用程序 + 用于高级控制台渲染的
RetroConsole
(原型)库 - 参见 预览 部分的 Retro Console - 2023年10月13日 - v1.10a - 更新了下载以更正不正确的 zip 文件类型(之前错误地将 rar 伪装成 zip 文件 - 已修复)