目录
ClickOnceUpdateService——后台服务方法
文件:appsettings. Development.json
文件:appsettings. Production.json
重要提示:如果您使用的是.NET Framework 4.8+或更早版本,请阅读上一篇文章 C#和VB中Winform和WPF的静默ClickOnce安装程序。
- 下载DotNet_UpdateClickOnceService_v1.10a(zip)- 8 MB
- 下载DotNet_UpdateClickOnceService_v1.10(rar)- 9.9 MB
- 下载DotNet_UpdateClickOnceService_v1.00(zip)- 7.3 MB [废型]
介绍
Microsoft和第三方公司拥有许多不同的安装程序框架系统。其中许多需要部分或全部手动交互,有些(如ClickOnce)可以自动执行此过程。本文将介绍适用于.NET Core 3.3+的ClickOnce。
定义
什么是ClickOnce?Microsoft将其定义为:
引用:
ClickOnce是一种部署技术,可用于创建基于Windows的自我更新应用程序,这些应用程序可以通过最少的用户交互进行安装和运行。可以通过三种不同的方式发布ClickOnce应用程序:从网页、从网络文件共享或从CD-ROM等媒体发布。...Microsoft文档[^]
概述
许多应用程序利用类似的机制来管理其应用程序的通用,并在需要重新启动以应用更新时与用户进行通信。Google Chrome,Microsoft Edge和Discord,仅举几例。
以下是Discord更新通知程序的屏幕截图:
这是Google Chrome的更新通知程序:
Microsoft对ClickOnce的实现有点尴尬。更新检查发生在应用程序启动时,在应用程序启动之前有一个相当不需要的更新检查窗口。
我们将解决这个问题。
好处
将ClickOnce用于Windows桌面应用程序的主要优点包括:
- 易于发布
- 易于安装
- 自动更新系统
本文的目的是删除这个尴尬的窗口,并在应用程序的后台静默监视任何更新。如果找到更新,请准备更新并通知应用程序/用户更新已准备就绪,然后应用程序可以自动更新或允许用户选择何时更新。最后,作为开发人员,可以完全控制次要更新策略与主要更新策略/强制更新策略。
因此,主要目标可以总结如下:
- WinForms 和 WPF 支持(可实现控制台应用)
- 在应用程序运行之前删除默认的Microsoft更新检查
- 用于管理监视和通知的后台服务
- 允许自定义工作流的API
- 公开所有属性的API
- 可以放入应用程序中以快速入门的StatusBar控件
- 日志记录框架集成
- 实时LogView控制作为示例包含在内,用于可视化用于调试的日志
预览
让我们看看我们将在本文中实现的目标,这是一种在应用程序后台运行并在更新准备就绪时发出通知的静默更新服务。本文介绍了一个最小的非依赖关系注入示例和另一个支持依赖关系注入的示例实现,其示例实现了使用StatusBar显示更新进程。将有C#和VB.NET中的代码。
首先,应用响应更新通知的最小实现指示可用版本并启用“更新”按钮。单击后,应用程序会自动应用更新并重新启动,以反映更新(已发布)的版本号。
接下来是使用 LogViewControl 的 WinForms(VB)和 WPF(C#)示例应用程序以及ServiceProperties调试工具(控件)以及用于与用户通信的StatusBar控件。
首先,VB.NET Winforms示例应用程序:
注意
- 示例Statusbar控件具有针对服务器每次ping的检查检测信号指示器
- 在“属性”选项卡中,我们可以看到ClickOnceService提供的所有信息,包括已安装和远程版本、应用程序的安装位置(带有“复制”按钮)以及服务查找更新的位置,这对于调试任何问题都非常方便
- 使用该LogViewer控件,当更新可用时,我们可以看到客户端与服务器的对话。
第二个是具有自定义LogViewer颜色的 C# WPF 示例应用程序:
注意
- 在这里,我们可以看到所有日志记录都被捕获了,甚至Trace信息从HttpClient与头信息。
复古控制台(NEW!)
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文件
注意:对于最后2点,下面提供了说明。
使用了以下Nuget包:
- Microsoft.Extensions.Configuration
- Microsoft.Extensions.Configuration.EnviraonmentVariables
- Microsoft.Extensions.Configuration.Json
- Microsoft.Extensions.Hosting
- Microsoft.Extensions.Http
- Microsoft.Extensions.Logging.Abstractions
- Microsoft.Extensions.Options.ConfigurationExtensions
- SingleInstanceCore——用于管理应用程序单个实例(推荐)
ClickOnceUpdateService核心
这是完成所有工作的核心服务。该服务是作为异步后台任务实现的,该任务仅在托管服务器上准备好更新时才与任何应用交互。该服务还公开了许多信息属性和操作方法。这些方法可以完全控制更新的处理方式。所有活动都使用Microsoft日志记录框架进行记录。
服务的实现由两部分组成:
- ClickOnceUpdateOptions Configuration Options类
- 更新的远程服务器托管的路径
- 用于检查更新的重试间隔
- 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——属性
属性 | 描述 |
ApplicationName | 完整的应用程序名称 |
ApplicationPath | 应用程序安装位置的路径 |
DataDirectory | 存储的应用程序数据的路径 |
IsNetworkDeployment | 是否安装了应用程序 |
IsUpdatingReady | 是否有更新就绪 |
IsMandatoryUpdate | 当前安装的版本低于所需的远程最低版本 |
PublishingPath | 安装文件和清单的服务器路径 |
RetryInterval | 检查更新的频率(以毫秒为单位)(最小1000毫秒/1 秒) |
知道应用程序文件和数据文件在计算机上的位置现在是一项简单的任务——ApplicationPath& DataDirectory属性公开此信息。
ClickOnceUpdateService——方法
方法 | 描述 |
CurrentVersionAsync | 获取当前安装的版本 |
ServerVersionAsync | 获取远程服务器版本 |
UpdateAvailableAsync | 手动检查是否有更新的版本 |
PrepareForUpdatingAsync | 准备更新应用程序 |
ExecuteUpdateAsync | 启动更新过程 |
准备更新和应用更新的过程是一个手动过程。这允许应用程序为用户提供有关如何以及何时应用更新的选项。
ClickOnceUpdateService——后台服务方法
方法 | 描述 |
StartAsync | 开始检查更新 |
StopAsync | 停止检查更新 |
ClickOnceUpdateService——事件
事件 | 描述 |
UpdateCheck | 让应用程序知道更新检查正在进行中 |
UpdateDetected | 找到更新并开始准备 |
UpdateReady | 更新已准备就绪,需要重新启动 |
可以在启动服务时传递一个CancellationToken以进行远程取消。
如果找到更新,该服务将自动停止轮询更新。
实现
ClickOnceUpdateService实现对以下各项的支持分为两部分:
- 启动服务、未处理的应用程序异常,并重新启动到新版本。
- 用户反馈和互动
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)。(YIYOU)
下面是简单/最小实现的动画:
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)。(YIYOU)
StatusBar通知示例
如果您希望为用户提供更完美的体验,我提供了一个Statusbar示例实现,用于传达应用程序版本以及新版本何时可用。
依赖注入支持
由于ClickOnceUpdateService实现使用Microsoft.Extensions.Hosting.BackgroundService类,因此它完全符合Microsoft托管来管理服务状态。
依赖关系注入服务的连接包装在一个ServicesExtension类中:
public static class ServicesExtension
{
public static HostApplicationBuilder AddClickOnceMonitoring(
this HostApplicationBuilder builder)
{
builder.Services.Configure<ClickOnceUpdateOptions>
(builder.Configuration.GetSection(ClickOnceUpdateService.SectionKey));
builder.Services.AddSingleton<ClickOnceUpdateService>();
builder.Services.AddHostedService(service =>
service.GetRequiredService<ClickOnceUpdateService>());
builder.Services.AddHttpClient(ClickOnceUpdateService.HttpClientKey);
return builder;
}
}
Public Module ServicesExtension
<Extension>
Public Function AddClickOnceMonitoring(builder As HostApplicationBuilder)
As HostApplicationBuilder
builder.Services.Configure(Of ClickOnceUpdateOptions) _
(builder.Configuration.GetSection(ClickOnceUpdateService.SectionKey))
builder.Services.AddSingleton(Of IClickOnceUpdateService, _
ClickOnceUpdateService)()
builder.Services _
.AddHostedService(Function(service) _
service.GetRequiredService(Of IClickOnceUpdateService))
builder.Services.AddHttpClient(ClickOnceUpdateService.HttpClientKey)
Return builder
End Function
End Module
要将服务添加到应用程序,我们需要做的就是:
private static IHost? _host;
HostApplicationBuilder builder = Host.CreateApplicationBuilder();
builder.AddClickOnceMonitoring();
_host = builder.Build();
Private Shared _host As IHost
Dim builder As HostApplicationBuilder = Host.CreateApplicationBuilder()
builder.AddClickOnceMonitoring()
_host = builder.Build()
然后启动服务:
private readonly CancellationTokenSource _cancellationTokenSource;
_cancellationTokenSource = new();
// startup background services
_ = _host.StartAsync(_cancellationTokenSource.Token);
Private Shared _cancellationTokenSource As CancellationTokenSource
_cancellationTokenSource = New CancellationTokenSource()
' startup background services
Dim task = _host.StartAsync(_cancellationTokenSource.Token)
当有可用更新时,该服务将自动停止轮询并引发UpdateDetected和UpdateReady事件。然后,这是一个手动过程,可以调用ExecuteUpdateAsync以完成更新过程。
下面是该过程的动画,其中StatusBar控件正在监视ClickOnceUpdateService事件并让用户了解情况:
虽然动画用于WPF应用程序,但示例WinForms应用程序的外观和工作方式相同。本文顶部的下载链接中提供了所有源代码。
如果您想了解有关LogView控件的更多信息,以及它如何与Microsoft、Serilog、NLog或Log4Net日志记录框架集成,请查看这篇专门的文章:C#和VB中WinForms、WPF和Avalonia的LogViewer控件。
为ClickOnce准备桌面应用程序
测试任何ClickOnce更新支持都需要在实时服务器或本地主机上安装和运行。下一节将介绍:
- 配置启动设置
- 使用发布配置文件创建基于Web的ClickOnce安装程序
- 在本地和实时服务器上承载ClickOnce安装程序
- 如何在本地计算机上运行测试Web安装以及所需的设置
- 避免常见的陷阱
- 如何测试静默更新程序
在处理应用程序时,在开发周期中有一段时间需要多种部署状态——开发/本地测试、Stage(可选,但建议)和生产/实时部署。为此,我们需要多个启动配置文件和发布配置文件。
在本文中,我将桌面应用程序和Web应用程序/网站分成2个独立的项目。这使我们能够保持网站运行并测试多个应用程序构建和部署。就像应用程序一样,Web应用程序将有自己的启动配置文件,具体取决于它的部署位置。
设置启动配置文件
桌面应用程序和托管Web服务器都有多个配置文件。下面,我们将介绍每个示例。就本文而言,我们的虚构网站silentupdater.net。
桌面应用程序
我们需要使用appsettings*.json文件配置启动配置文件。为此,我们需要四个文件:
- appsettings.json——-开发和生产通用
- appsettings.Development.json——用于开发配置
- appsettings.Staging.json——用于Staging配置
- appsettings.Production.json——用于实时/生产选项
配置开发和生产环境
若要设置DOTNET_ENVIRONMENT变量,请在“解决方案资源管理器”中右键单击应用程序名称,打开应用程序“属性”,导航到“调试>常规”部分,然后单击“打开调试启动配置文件 UI”。这将打开“启动配置文件”窗口。
或者,我们可以从工具栏访问“启动配置文件”窗口:
我们感兴趣的是环境变量。您可以在此处设置任何内容。我们需要添加的是名称:DOTNET_ENVIRONMENT,值是:Development。没有关闭按钮,只需关闭窗口并选择“文件”>“保存...”。从VS菜单。
在“属性”文件夹中找到的launchSettings.json文件中的“启动配置文件”设置示例:
{
"profiles": {
"Development": {
"commandName": "Project",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
},
"Staging": {
"commandName": "Project",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Staging"
}
},
"Production": {
"commandName": "Project"
}
}
}
注意:launchSettings.json文件仅适用于Visual Studio。它不会被复制到已编译的文件夹中。如果将它包含在应用程序中,运行时将忽略它。需要在应用程序中手动支持此文件,以便在Visual Studio外部使用。
文件:appsettings.json
这是根设置文件。如果此处有设置,则可选的开发/生产设置不会覆盖此设置。由于我们将使用不同的启动配置文件覆盖设置,因此appsettings.json将具有默认设置。
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
}
}
文件:appsettings. Development.json
我们对详细日志记录感兴趣。另外,短的RetryInterval,我将其设置为1秒,因此测试速度很快。最后,PublishingPath指向一个用于在开发环境中进行测试的特殊URL——在本文后面将详细介绍为什么我们需要这样做。
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"System.Net.Http.HttpClient": "Trace",
"ClickOnce": "Trace"
}
},
"ClickOnce": {
"PublishingPath": "http://silentupdater.net:5216/Installer/WinformsApp/",
"RetryInterval": 1000
}
}
文件:appsettings. Production.json
我们只对警告和关键日志记录感兴趣。RetryInterval设置为每30秒一次,PublishingPath并指向我们的实时制作网站。
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"System.Net.Http.HttpClient": "Warning",
"ClickOnce": "Warning"
}
},
"ClickOnce": {
"PublishingPath": "http://silentupdater.net:5216/Installer/WpfSimple/",
"RetryInterval": 30000
}
}
选择配置文件
要选择要测试的配置文件,我们可以从工具栏中进行选择:
配置要发布的配置文件
发布项目时,请务必为每个文件设置“生成操作”。您不希望将开发配置文件发布到用户的计算机,因此请将“生成操作”设置为“等于”None:
要发布的文件需要设置为Content:
注意:如果未正确执行此操作,则无论发布配置文件中的设置如何,都不会包含标记为Build Action: None的文件,并且将遇到安装失败。下面是一个示例,说明它是如何出错的:
在这里,您可以看到appsettings.Development.json文件设置为发布:
但是,由于为appsettings.Development.json设置了Build Action: None。但该文件丢失,将列在清单中,供发布者安装。
Web应用程序
由于这是一个示例应用程序,因此不使用其他配置文件。下面是本文使用的默认appsettings.json文件:
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Microsoft.AspNetCore": "Trace"
}
},
"AllowedHosts": "*"
}
注意:我使用了Trace级别日志记录,以便我们可以准确地看到发生了什么。但是,对于实时Web应用程序,您通常只对关键信息使用Warning。
设置发布配置文件
桌面应用程序
接下来,我们需要为ClickOnce设置发布配置文件。
目标
当我们使用ClickOnce时,我们需要选择以下ClickOnce选项:
发布位置
在这里,我们将路径设置为网站中的路径位置。文件需要位于wwwroot路径中。这样就无需手动移动文件。
安装位置
现在我们需要指出ClickOnce将在哪里寻找更新。由于限制,我们还需要复制此路径并将其添加到正确的appsettings*.json文件中。
重要提示:两者必须相同,否则更新检查或安装将失败。
设置
第三个是多页选项。对于静默更新,重要字段是“应用程序将检查更新必须取消选中”。这将阻止“启动应用程序”窗口在应用程序启动时出现,并允许静默服务在应用程序启动后在应用程序后台执行该过程。
先决条件
发布选项
注意
- Support URL字段由ClickOnce Installer和Uninstaller进程使用。
- Error URL被ClickOnce用于自动发布任何错误信息。
注意
- 仅当要使用默认的Microsoft页面时,Automatically generate the following webpage after publish字段才是必需的。我个人的建议是不要使用它,而是使用生成的*.application清单文件。可下载的演示同时使用这两种方法,因此您可以看到它们的工作原理。
签署清单
您应该始终在ClickOnce清单上签名,以减少任何黑客攻击的机会。你可以购买并使用你自己的(发布应用程序确实需要),也可以让VS为你生成一个(仅用于测试)。即使只测试应用程序,也是一种很好的做法。这是在设置发布配置文件的过程中完成的。“签名清单”部分包含“创建测试证书”选项。
注意
- 如果在同一网站上发布了多个应用,则使用“发布”向导,将从所选的原始目录中复制证书。您需要在文本编辑器中加载每个发布配置文件,并指向找到证书的目录,然后删除复制的版本。下面,你可以看到我是如何做到的:
配置
仅当您知道自己在做什么时才更改这些设置。选择的默认值应适用于大多数安装。
发布窗口
创建发布配置文件后,我们可以在发布之前选择它们。强烈建议重命名每个配置文件。下面我们将介绍此过程。
选择重命名:
输入“新建配置文件名称”,然后单击“重命名”按钮:
现在,请确保选择了正确的发布配置文件:
Web应用程序
重要提示:关键字段是“环境变量”和“应用网址”。
以下是本文使用的启动设置。
{
"profiles": {
"Development_localhost_http": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5216"
},
"Development_http": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "http://silentupdater.net:5216"
},
"Staging_https": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Staging"
},
"dotnetRunMessages": true,
"applicationUrl": "https://silentupdater.net:7285;
http://silentupdater.net:5216"
},
"Production_https": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Production"
},
"dotnetRunMessages": true,
"applicationUrl": "https://silentupdater.net:7285;
http://silentupdater.net:5216"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7285;http://localhost:5216",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:51927",
"sslPort": 44366
}
}
}
发布应用程序
我们需要做的就是打开“发布窗口”,选择“配置文件”,然后按“发布”按钮。在这里,我们可以看到我们成功发布了:
如果已正确设置发布配置文件,则安装文件将自动添加到Web应用程序中:
注意:我们有两个选项可以向用户公开安装程序:
- 旧版Publish.html文件
- [application_name].application清单文件
我们稍后将在“安装和测试静默更新”(接下来的章节部分)部分介绍这些内容。
在Web应用程序中托管
对于本文,我需要一个简单的网站来托管部署页面,因此我选择了最小API和静态页面。以下是最低限度的API实现使用:
WebApplication app = WebApplication
.CreateBuilder(args)
.Build();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.MapGet("/", async (HttpContext ctx) =>
{
//sets the content type as html
ctx.Response.Headers.ContentType = new StringValues("text/html; charset=UTF-8");
await ctx.Response.SendFileAsync("wwwroot/index.html");
});
app.Run();
它只是提供一个html文件,在本例中为wwwroot/index.html文件:
VB.NET有自己的实现:
Module Program
Sub Main(args As String())
' Add services to the container.
Dim app = WebApplication _
.CreateBuilder(args) _
.Build()
app.UseHttpsRedirection()
app.UseStaticFiles()
app.UseRouting()
app.MapGet("/",
Async Function(ctx As HttpContext)
' set content type to html
ctx.Response.Headers.ContentType = _
New StringValues("text/html; charset=UTF-8")
Await ctx.Response.SendFileAsync("wwwroot/index.html")
End Function)
app.Run()
End Sub
End Module
安装和测试静默更新
安装、重新发布、重新托管、运行、更新和重启的步骤。
- 启动主机Web应用程序/服务器。
- 将应用程序发布到主机Web应用程序/服务器。
- 安装应用程序。
- 应用程序将自动运行(不要停止它)。
- 重新发布应用程序。
- 等待应用程序执行检查并通知已准备好更新。
- 单击“重新启动”按钮,应用程序将关闭、更新,然后自动重新启动。
- 现在检查更新的版本号。
在本文前面,我提到用户可以通过两种方法从主机Web应用程序/服务器进行安装:
- 通过生成的Publish.HTML页面
- app_name.Application清单文件
在下一节中,我们将探讨每个使用测试证书的过程。
使用Publish.html文件进行安装
以下是自动生成的publish.html文件。这是可选的。让我们看一下安装过程,其中包含设置发布配置文件时生成的测试证书。在安装过程之前,有几个警告和过程需要执行下载:
接下来,我们需要单击“查看更多”选项:
选择Keep:
现在,由于我们使用的是测试证书,因此我们需要选择“显示更多”,然后选择“仍然保留”:
现在,setup.exe文件已下载。我们需要选择打开文件:
安装现在将继续。
使用应用程序清单进行安装
选择安装*.application清单要简单得多,并且是首选。下载setup.exe文件只需一个步骤:
下载后,安装现在将继续进行。只需单击“打开”按钮。
安装
下载安装程序后,它将运行、下载和安装应用程序,然后启动应用程序。
在这里,我们可以看到我们设置的发布选项。“详细信息”链接将指向提供的支持URL。
注意
- 如果在发布时使用了已批准的证书,则不会出现警告,并且会显示发布服务器。
现在应用程序已下载:
下载完成并安装应用程序后,应用程序将自动运行:
开发电脑设置不正确的示例
现在我们将查看一个成功的安装,但是,配置不正确。
一切似乎都正常工作,但是,应用程序是从localhost安装的,并且Hosts文件未配置网络计算机名称。单击“单击以重新启动”按钮时,将引发以下错误:
如果我们尝试使用安装程序尝试的URL,我们将看到以下内容:
服务器更改为计算机名称
如果不卸载错误安装的应用程序,更改主机文件,然后尝试进行更新,则会遇到类似以下错误的情况:
必须卸载应用程序,然后安装才会成功。
配置开发电脑以进行本地测试
与任何开发周期一样,建议在本地计算机上进行测试安装和更新。为此,需要一个步骤来配置虚拟主机。没有这个,你会遇到困难。
我们已经配置了用于发布和托管的应用程序,接下来我们需要通过Hosts配置文件来配置开发电脑。Hosts文件位于C:\Windows\System32\drivers\etc目录中。
HOSTS文件是一个特殊文件,通常需要管理员权限才能保存更改。
我用来进行更改的方法如下:
- 按任务栏上的“开始”按钮,然后找到记事本应用程序。
- 右键单击记事本应用程序,然后选择“以管理员身份运行”。
- 进入记事本后,转到“文件”→“打开”。切换到文件夹C:\Windows\System32\Drivers\etc。
- 现在对hosts文件进行更改。
- 保存更改。
下面是我们将用于本文的示例和提供的代码:
# 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配置设置,您将遇到问题。
总结
我们已经介绍了Microsoft ClickOnce从开发、发布、托管、安装到用户友好的静默ClickOnceUpdateService,以提供友好的用户体验。作为开发人员,ClickOnceUpdateService为您的应用程序提供后台服务,该服务将为您完成所有工作,并允许您访问通常不可见的信息。最后,你可以将StatusBar控件拖放到应用中以快速入门,以及用于帮助实时调试应用程序的LogViewer和ServiceProperties控件。
引用
文档、文章等
- ClickOnce安全性和部署
- Windows上的ClickOnce for .NET
- 发布ClickOnce应用程序
- .NET中的依赖项注入|Microsoft学习
- 模型-视图-视图模型(MVVM)|Microsoft学习
- 数据绑定概述(Windows窗体.NET)
- 数据绑定概述(WPF .NET)
- 在ASP.NET Core中使用托管服务的后台任务
- 宣布推出.NET 5.0 RC 2 - ClickOnce
- 如何在Windows 10中编辑主机文件
- 如何在Windows 11上编辑HOSTS文件
- .NET应用设置揭秘(C#和VB)
- C#和VB中WinForms、WPF和Avalonia的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_用于管理应用程序单个实例(推荐)
https://www.codeproject.com/Articles/5359156/NET-Silent-ClickOnce-Installer-for-Winform-WPF-in