mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
458494d131 | ||
|
|
01670147f6 | ||
|
|
0348324fa3 |
@@ -1,3 +1,5 @@
|
|||||||
{
|
{
|
||||||
"diffEditor.renderSideBySide": false
|
"diffEditor.renderSideBySide": false,
|
||||||
|
"clawMode.mode": "editor",
|
||||||
|
"workbench.activityBar.location": "default"
|
||||||
}
|
}
|
||||||
@@ -3,40 +3,40 @@
|
|||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="Avalonia" Version="12.0.1" />
|
<PackageVersion Include="Avalonia" Version="12.0.2" />
|
||||||
<PackageVersion Include="Avalonia.Controls.WebView" Version="12.0.0" />
|
<PackageVersion Include="Avalonia.Controls.WebView" Version="12.0.0" />
|
||||||
<PackageVersion Include="Avalonia.Desktop" Version="12.0.1" />
|
<PackageVersion Include="Avalonia.Desktop" Version="12.0.2" />
|
||||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.1" />
|
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.2" />
|
||||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.1" />
|
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.2" />
|
||||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1" />
|
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1" />
|
||||||
<PackageVersion Include="ClassIsland.Markdown.Avalonia" Version="12.0.0" />
|
<PackageVersion Include="ClassIsland.Markdown.Avalonia" Version="12.0.0" />
|
||||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.2.1" />
|
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
<PackageVersion Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
|
<PackageVersion Include="dotnetCampus.Ipc" Version="2.0.0-alpha436" />
|
||||||
<PackageVersion Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
|
<PackageVersion Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
|
||||||
<PackageVersion Include="Downloader" Version="4.1.1" />
|
<PackageVersion Include="Downloader" Version="5.4.0" />
|
||||||
<PackageVersion Include="FluentAvaloniaUI" Version="3.0.0-preview1" />
|
<PackageVersion Include="FluentAvaloniaUI" Version="3.0.0-preview2" />
|
||||||
<PackageVersion Include="FluentIcons.Avalonia" Version="2.1.325" />
|
<PackageVersion Include="FluentIcons.Avalonia" Version="2.1.325" />
|
||||||
<PackageVersion Include="Material.Avalonia" Version="3.16.0" />
|
<PackageVersion Include="Material.Avalonia" Version="3.16.1" />
|
||||||
<PackageVersion Include="MaterialColorUtilities" Version="0.3.0" />
|
<PackageVersion Include="MaterialColorUtilities" Version="0.3.0" />
|
||||||
<PackageVersion Include="Material.Icons.Avalonia" Version="3.0.2" />
|
<PackageVersion Include="Material.Icons.Avalonia" Version="3.0.3-nightly.0.2" />
|
||||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.0" />
|
<PackageVersion Include="Microsoft.Data.Sqlite" Version="11.0.0-preview.3.26207.106" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="11.0.0-preview.3.26207.106" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="11.0.0-preview.3.26207.106" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="11.0.0-preview.3.26207.106" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
|
||||||
<PackageVersion Include="MudTools.OfficeInterop" Version="2.0.8" />
|
<PackageVersion Include="MudTools.OfficeInterop" Version="2.0.9" />
|
||||||
<PackageVersion Include="MudTools.OfficeInterop.Excel" Version="2.0.8" />
|
<PackageVersion Include="MudTools.OfficeInterop.Excel" Version="2.0.9" />
|
||||||
<PackageVersion Include="MudTools.OfficeInterop.PowerPoint" Version="2.0.8" />
|
<PackageVersion Include="MudTools.OfficeInterop.PowerPoint" Version="2.0.9" />
|
||||||
<PackageVersion Include="MudTools.OfficeInterop.Word" Version="2.0.8" />
|
<PackageVersion Include="MudTools.OfficeInterop.Word" Version="2.0.9" />
|
||||||
<PackageVersion Include="PortAudioSharp2" Version="1.0.6" />
|
<PackageVersion Include="PortAudioSharp2" Version="1.0.6" />
|
||||||
<PackageVersion Include="PostHog" Version="2.4.0" />
|
<PackageVersion Include="PostHog" Version="2.5.0" />
|
||||||
<PackageVersion Include="Sentry" Version="4.0.0" />
|
<PackageVersion Include="Sentry" Version="6.4.1" />
|
||||||
<PackageVersion Include="System.Drawing.Common" Version="10.0.0" />
|
<PackageVersion Include="System.Drawing.Common" Version="11.0.0-preview.3.26207.106" />
|
||||||
<PackageVersion Include="System.Runtime.WindowsRuntime" Version="4.7.0" />
|
<PackageVersion Include="System.Runtime.WindowsRuntime" Version="5.0.0-preview.5.20278.1" />
|
||||||
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.92.0" />
|
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.92.0" />
|
||||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
|
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.0-pre.4" />
|
||||||
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
|
<PackageVersion Include="YamlDotNet" Version="17.1.0" />
|
||||||
<PackageVersion Include="log4net" Version="3.3.0" />
|
<PackageVersion Include="log4net" Version="3.3.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -41,4 +41,6 @@ namespace LanMountainDesktop.Launcher;
|
|||||||
[JsonSerializable(typeof(StartupAttemptRecord))]
|
[JsonSerializable(typeof(StartupAttemptRecord))]
|
||||||
[JsonSerializable(typeof(PrivacyConfig))]
|
[JsonSerializable(typeof(PrivacyConfig))]
|
||||||
[JsonSerializable(typeof(PrivacyAgreementState))]
|
[JsonSerializable(typeof(PrivacyAgreementState))]
|
||||||
|
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallProgressReport))]
|
||||||
|
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallCompleteReport))]
|
||||||
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
internal sealed partial class AppJsonContext : JsonSerializerContext;
|
||||||
|
|||||||
@@ -3,11 +3,30 @@ using LanMountainDesktop.Launcher.Models;
|
|||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析应用数据目录位置。
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 安装后的目录结构:
|
||||||
|
/// <code>
|
||||||
|
/// {AppRoot}/ ← 应用安装根目录
|
||||||
|
/// LanMountainDesktop.Launcher.exe ← Launcher 可执行文件
|
||||||
|
/// .Launcher/ ← Launcher 数据目录(日志、状态、配置等)
|
||||||
|
/// app-{version}/ ← Host 部署目录
|
||||||
|
/// LanMountainDesktop.exe
|
||||||
|
/// ...
|
||||||
|
/// </code>
|
||||||
|
///
|
||||||
|
/// Launcher 数据目录固定位于应用安装根目录下的 <c>.Launcher</c> 文件夹中,
|
||||||
|
/// 与 app-* 部署目录同级。此目录不随数据位置模式改变。
|
||||||
|
///
|
||||||
|
/// Desktop(Host)数据目录则根据用户选择可位于系统目录或便携目录。
|
||||||
|
/// </remarks>
|
||||||
internal sealed class DataLocationResolver
|
internal sealed class DataLocationResolver
|
||||||
{
|
{
|
||||||
private const string ConfigFileName = "data-location.config.json";
|
private const string ConfigFileName = "data-location.config.json";
|
||||||
private const string LauncherFolderName = "Launcher";
|
|
||||||
private const string DesktopFolderName = "Desktop";
|
private const string DesktopFolderName = "Desktop";
|
||||||
|
private const string LauncherDataFolderName = ".Launcher";
|
||||||
|
|
||||||
private readonly string _appRoot;
|
private readonly string _appRoot;
|
||||||
private readonly string _defaultSystemDataPath;
|
private readonly string _defaultSystemDataPath;
|
||||||
@@ -28,13 +47,49 @@ internal sealed class DataLocationResolver
|
|||||||
public string DefaultSystemDataPath => _defaultSystemDataPath;
|
public string DefaultSystemDataPath => _defaultSystemDataPath;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 默认便携模式数据路径(应用目录下的 AppData)
|
/// 默认便携模式数据路径(应用目录下的 Desktop 文件夹)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string DefaultPortableDataPath => Path.Combine(_appRoot, "AppData");
|
public string DefaultPortableDataPath => Path.Combine(_appRoot, DesktopFolderName);
|
||||||
|
|
||||||
private string ResolveBootstrapLauncherDataPath()
|
/// <summary>
|
||||||
|
/// Launcher 数据目录,固定位于应用安装根目录下的 .Launcher 文件夹。
|
||||||
|
/// 该目录与 app-* 部署目录同级,不随数据位置模式改变。
|
||||||
|
/// </summary>
|
||||||
|
public string ResolveLauncherDataPath()
|
||||||
{
|
{
|
||||||
return Path.Combine(_defaultSystemDataPath, LauncherFolderName);
|
return Path.Combine(_appRoot, LauncherDataFolderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 桌面应用数据目录(组件、设置、插件等)
|
||||||
|
/// </summary>
|
||||||
|
public string ResolveDesktopDataPath()
|
||||||
|
{
|
||||||
|
return Path.Combine(ResolveDataRoot(), DesktopFolderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 数据位置配置文件路径(保存在 Launcher 数据目录下)
|
||||||
|
/// </summary>
|
||||||
|
public string ResolveConfigPath()
|
||||||
|
{
|
||||||
|
return Path.Combine(ResolveLauncherDataPath(), ConfigFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 启动器日志目录
|
||||||
|
/// </summary>
|
||||||
|
public string ResolveLauncherLogsPath()
|
||||||
|
{
|
||||||
|
return Path.Combine(ResolveLauncherDataPath(), "logs");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 启动器状态目录
|
||||||
|
/// </summary>
|
||||||
|
public string ResolveLauncherStatePath()
|
||||||
|
{
|
||||||
|
return Path.Combine(ResolveLauncherDataPath(), "state");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -55,6 +110,19 @@ internal sealed class DataLocationResolver
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public DataLocationMode ResolveMode()
|
||||||
|
{
|
||||||
|
var config = LoadConfig();
|
||||||
|
if (config is null)
|
||||||
|
{
|
||||||
|
return DataLocationMode.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Equals(config.DataLocationMode, "Portable", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? DataLocationMode.Portable
|
||||||
|
: DataLocationMode.System;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 解析数据根目录(用户选择的位置)
|
/// 解析数据根目录(用户选择的位置)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -84,66 +152,11 @@ internal sealed class DataLocationResolver
|
|||||||
: _defaultSystemDataPath;
|
: _defaultSystemDataPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 启动器数据目录(日志、配置、状态等)
|
|
||||||
/// </summary>
|
|
||||||
public string ResolveLauncherDataPath()
|
|
||||||
{
|
|
||||||
return Path.Combine(ResolveDataRoot(), LauncherFolderName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 桌面应用数据目录(组件、设置、插件等)
|
|
||||||
/// </summary>
|
|
||||||
public string ResolveDesktopDataPath()
|
|
||||||
{
|
|
||||||
return Path.Combine(ResolveDataRoot(), DesktopFolderName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 数据位置配置文件路径(保存在 Launcher 目录下)
|
|
||||||
/// </summary>
|
|
||||||
public string ResolveConfigPath()
|
|
||||||
{
|
|
||||||
return Path.Combine(ResolveBootstrapLauncherDataPath(), ConfigFileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 启动器日志目录
|
|
||||||
/// </summary>
|
|
||||||
public string ResolveLauncherLogsPath()
|
|
||||||
{
|
|
||||||
return Path.Combine(ResolveLauncherDataPath(), "logs");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 启动器状态目录
|
|
||||||
/// </summary>
|
|
||||||
public string ResolveLauncherStatePath()
|
|
||||||
{
|
|
||||||
return Path.Combine(ResolveLauncherDataPath(), "state");
|
|
||||||
}
|
|
||||||
|
|
||||||
public DataLocationMode ResolveMode()
|
|
||||||
{
|
|
||||||
var config = LoadConfig();
|
|
||||||
if (config is null)
|
|
||||||
{
|
|
||||||
return DataLocationMode.System;
|
|
||||||
}
|
|
||||||
|
|
||||||
return string.Equals(config.DataLocationMode, "Portable", StringComparison.OrdinalIgnoreCase)
|
|
||||||
? DataLocationMode.Portable
|
|
||||||
: DataLocationMode.System;
|
|
||||||
}
|
|
||||||
|
|
||||||
public DataLocationConfig? LoadConfig()
|
public DataLocationConfig? LoadConfig()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 配置文件必须位于默认系统数据路径下的 Launcher 目录中
|
var configPath = ResolveConfigPath();
|
||||||
// 避免循环依赖:不能调用 ResolveConfigPath() -> ResolveLauncherDataPath() -> ResolveDataRoot() -> LoadConfig()
|
|
||||||
var configPath = Path.Combine(_defaultSystemDataPath, LauncherFolderName, ConfigFileName);
|
|
||||||
if (!File.Exists(configPath))
|
if (!File.Exists(configPath))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -163,8 +176,8 @@ internal sealed class DataLocationResolver
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var launcherPath = ResolveBootstrapLauncherDataPath();
|
var launcherDataPath = ResolveLauncherDataPath();
|
||||||
Directory.CreateDirectory(launcherPath);
|
Directory.CreateDirectory(launcherDataPath);
|
||||||
|
|
||||||
var configPath = ResolveConfigPath();
|
var configPath = ResolveConfigPath();
|
||||||
var json = JsonSerializer.Serialize(config, AppJsonContext.Default.DataLocationConfig);
|
var json = JsonSerializer.Serialize(config, AppJsonContext.Default.DataLocationConfig);
|
||||||
@@ -194,9 +207,8 @@ internal sealed class DataLocationResolver
|
|||||||
// 先创建目录结构
|
// 先创建目录结构
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var resolvedDataRoot = ResolveDataRoot(config);
|
Directory.CreateDirectory(ResolveLauncherDataPath());
|
||||||
Directory.CreateDirectory(Path.Combine(resolvedDataRoot, LauncherFolderName));
|
Directory.CreateDirectory(Path.Combine(ResolveDataRoot(config), DesktopFolderName));
|
||||||
Directory.CreateDirectory(Path.Combine(resolvedDataRoot, DesktopFolderName));
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
public interface IUpdateProgressReporter
|
||||||
|
{
|
||||||
|
void ReportProgress(InstallProgressReport report);
|
||||||
|
void ReportComplete(InstallCompleteReport report);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
using System.Buffers;
|
||||||
|
using System.IO.Pipes;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Launcher.Services;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Services.Ipc;
|
||||||
|
|
||||||
|
internal sealed class LauncherUpdateProgressIpcServer : IUpdateProgressReporter, IDisposable
|
||||||
|
{
|
||||||
|
private const int LengthPrefixSize = 4;
|
||||||
|
|
||||||
|
private readonly string _pipeName;
|
||||||
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
private NamedPipeServerStream? _pipe;
|
||||||
|
private Task? _listenTask;
|
||||||
|
private volatile bool _clientConnected;
|
||||||
|
|
||||||
|
public LauncherUpdateProgressIpcServer(int launcherPid)
|
||||||
|
{
|
||||||
|
_pipeName = $"LanMountainDesktop_Update_{launcherPid}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string PipeName => _pipeName;
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
_listenTask = Task.Run(AcceptConnectionAsync, _cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AcceptConnectionAsync()
|
||||||
|
{
|
||||||
|
while (!_cts.Token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_pipe = new NamedPipeServerStream(
|
||||||
|
_pipeName,
|
||||||
|
PipeDirection.Out,
|
||||||
|
1,
|
||||||
|
PipeTransmissionMode.Byte,
|
||||||
|
PipeOptions.Asynchronous);
|
||||||
|
|
||||||
|
await _pipe.WaitForConnectionAsync(_cts.Token).ConfigureAwait(false);
|
||||||
|
_clientConnected = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Warn($"Update progress IPC listen error: {ex.Message}");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(200, _cts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ReportProgress(InstallProgressReport report)
|
||||||
|
{
|
||||||
|
if (!_clientConnected || _pipe is null || !_pipe.IsConnected)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
WriteMessage(_pipe, JsonSerializer.Serialize(report, AppJsonContext.Default.InstallProgressReport));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Warn($"Failed to report progress via IPC: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ReportComplete(InstallCompleteReport report)
|
||||||
|
{
|
||||||
|
if (!_clientConnected || _pipe is null || !_pipe.IsConnected)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
WriteMessage(_pipe, JsonSerializer.Serialize(report, AppJsonContext.Default.InstallCompleteReport));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Warn($"Failed to report completion via IPC: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteMessage(Stream stream, string json)
|
||||||
|
{
|
||||||
|
var payload = Encoding.UTF8.GetBytes(json);
|
||||||
|
var lengthPrefix = BitConverter.GetBytes(payload.Length);
|
||||||
|
stream.Write(lengthPrefix, 0, LengthPrefixSize);
|
||||||
|
stream.Write(payload, 0, payload.Length);
|
||||||
|
stream.Flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_cts.Cancel();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_pipe?.Dispose();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_listenTask?.Wait(TimeSpan.FromSeconds(2));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
_cts.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
internal sealed class NullUpdateProgressReporter : IUpdateProgressReporter
|
||||||
|
{
|
||||||
|
public void ReportProgress(InstallProgressReport report) { }
|
||||||
|
public void ReportComplete(InstallCompleteReport report) { }
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using System.IO.Compression;
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
using ContractsUpdate = LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
@@ -20,14 +21,16 @@ internal sealed class UpdateEngineService
|
|||||||
private const string PublicKeyFileName = "public-key.pem";
|
private const string PublicKeyFileName = "public-key.pem";
|
||||||
|
|
||||||
private readonly DeploymentLocator _deploymentLocator;
|
private readonly DeploymentLocator _deploymentLocator;
|
||||||
|
private readonly IUpdateProgressReporter _progressReporter;
|
||||||
private readonly string _appRoot;
|
private readonly string _appRoot;
|
||||||
private readonly string _launcherRoot;
|
private readonly string _launcherRoot;
|
||||||
private readonly string _incomingRoot;
|
private readonly string _incomingRoot;
|
||||||
private readonly string _snapshotsRoot;
|
private readonly string _snapshotsRoot;
|
||||||
|
|
||||||
public UpdateEngineService(DeploymentLocator deploymentLocator)
|
public UpdateEngineService(DeploymentLocator deploymentLocator, IUpdateProgressReporter? progressReporter = null)
|
||||||
{
|
{
|
||||||
_deploymentLocator = deploymentLocator;
|
_deploymentLocator = deploymentLocator;
|
||||||
|
_progressReporter = progressReporter ?? new NullUpdateProgressReporter();
|
||||||
_appRoot = deploymentLocator.GetAppRoot();
|
_appRoot = deploymentLocator.GetAppRoot();
|
||||||
var resolver = new DataLocationResolver(_appRoot);
|
var resolver = new DataLocationResolver(_appRoot);
|
||||||
_launcherRoot = resolver.ResolveLauncherDataPath();
|
_launcherRoot = resolver.ResolveLauncherDataPath();
|
||||||
@@ -149,9 +152,11 @@ internal sealed class UpdateEngineService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifySignature, "Verifying signature...", 0, null, 0, 0));
|
||||||
var verifyResult = VerifySignature(fileMapPath, signaturePath, SignatureFileName);
|
var verifyResult = VerifySignature(fileMapPath, signaturePath, SignatureFileName);
|
||||||
if (!verifyResult.Success)
|
if (!verifyResult.Success)
|
||||||
{
|
{
|
||||||
|
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false));
|
||||||
return Failed("update.apply", "signature_failed", verifyResult.Message);
|
return Failed("update.apply", "signature_failed", verifyResult.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +164,7 @@ internal sealed class UpdateEngineService
|
|||||||
var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap);
|
var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap);
|
||||||
if (fileMap is null || fileMap.Files.Count == 0)
|
if (fileMap is null || fileMap.Files.Count == 0)
|
||||||
{
|
{
|
||||||
|
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, "No update file entries were found.", false));
|
||||||
return Failed("update.apply", "invalid_manifest", "No update file entries were found.");
|
return Failed("update.apply", "invalid_manifest", "No update file entries were found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,14 +212,21 @@ internal sealed class UpdateEngineService
|
|||||||
Directory.CreateDirectory(extractRoot);
|
Directory.CreateDirectory(extractRoot);
|
||||||
ZipFile.ExtractToDirectory(archivePath, extractRoot, overwriteFiles: true);
|
ZipFile.ExtractToDirectory(archivePath, extractRoot, overwriteFiles: true);
|
||||||
|
|
||||||
|
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileMap.Files.Count));
|
||||||
Directory.CreateDirectory(targetDeployment);
|
Directory.CreateDirectory(targetDeployment);
|
||||||
File.WriteAllText(partialMarker, string.Empty);
|
File.WriteAllText(partialMarker, string.Empty);
|
||||||
|
|
||||||
|
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30, null, 0, fileMap.Files.Count));
|
||||||
|
var fileIndex = 0;
|
||||||
foreach (var file in fileMap.Files)
|
foreach (var file in fileMap.Files)
|
||||||
{
|
{
|
||||||
ApplyFileEntry(file, currentDeployment, targetDeployment, extractRoot);
|
ApplyFileEntry(file, currentDeployment, targetDeployment, extractRoot);
|
||||||
|
fileIndex++;
|
||||||
|
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30 + (fileIndex * 30 / fileMap.Files.Count), file.Path, fileIndex, fileMap.Files.Count));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65, null, 0, fileMap.Files.Count));
|
||||||
|
var verifyIndex = 0;
|
||||||
foreach (var file in fileMap.Files)
|
foreach (var file in fileMap.Files)
|
||||||
{
|
{
|
||||||
if (!NeedsVerification(file))
|
if (!NeedsVerification(file))
|
||||||
@@ -227,16 +240,22 @@ internal sealed class UpdateEngineService
|
|||||||
{
|
{
|
||||||
throw new InvalidOperationException($"File hash mismatch for '{file.Path}'.");
|
throw new InvalidOperationException($"File hash mismatch for '{file.Path}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
verifyIndex++;
|
||||||
|
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65 + (verifyIndex * 15 / fileMap.Files.Count), file.Path, verifyIndex, fileMap.Files.Count));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileMap.Files.Count, fileMap.Files.Count));
|
||||||
ActivateDeployment(currentDeployment, targetDeployment);
|
ActivateDeployment(currentDeployment, targetDeployment);
|
||||||
|
|
||||||
snapshot.Status = "applied";
|
snapshot.Status = "applied";
|
||||||
SaveSnapshot(snapshotPath, snapshot);
|
SaveSnapshot(snapshotPath, snapshot);
|
||||||
CleanupIncomingArtifacts();
|
CleanupIncomingArtifacts();
|
||||||
// 婵炴挸鎳愰幃濠囧籍瑜忔晶妤呭嫉椤掑﹦绀夊ù锝呮缁绘岸鎮惧▎鎰粯閺?濞戞搩浜炴晶妤呭嫉椤戝じ绨伴柡鈧娑樼槷闁搞儳鍋炵划?
|
|
||||||
CleanupDestroyedDeployments();
|
CleanupDestroyedDeployments();
|
||||||
|
|
||||||
|
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileMap.Files.Count, fileMap.Files.Count));
|
||||||
|
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(true, currentVersion, targetVersion, null, false));
|
||||||
|
|
||||||
return new LauncherResult
|
return new LauncherResult
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
@@ -249,9 +268,11 @@ internal sealed class UpdateEngineService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0));
|
||||||
TryRollbackOnFailure(snapshot);
|
TryRollbackOnFailure(snapshot);
|
||||||
snapshot.Status = "rolled_back";
|
snapshot.Status = "rolled_back";
|
||||||
SaveSnapshot(snapshotPath, snapshot);
|
SaveSnapshot(snapshotPath, snapshot);
|
||||||
|
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, currentVersion, targetVersion, ex.Message, true));
|
||||||
return new LauncherResult
|
return new LauncherResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
@@ -283,9 +304,11 @@ internal sealed class UpdateEngineService
|
|||||||
string pdcSignaturePath,
|
string pdcSignaturePath,
|
||||||
string pdcUpdatePath)
|
string pdcUpdatePath)
|
||||||
{
|
{
|
||||||
|
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifySignature, "Verifying PLONDS signature...", 0, null, 0, 0));
|
||||||
var verifyResult = VerifySignature(pdcFileMapPath, pdcSignaturePath, PlondsSignatureFileName);
|
var verifyResult = VerifySignature(pdcFileMapPath, pdcSignaturePath, PlondsSignatureFileName);
|
||||||
if (!verifyResult.Success)
|
if (!verifyResult.Success)
|
||||||
{
|
{
|
||||||
|
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false));
|
||||||
return Failed("update.apply", "signature_failed", verifyResult.Message);
|
return Failed("update.apply", "signature_failed", verifyResult.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +322,7 @@ internal sealed class UpdateEngineService
|
|||||||
|
|
||||||
if (fileEntries.Count == 0)
|
if (fileEntries.Count == 0)
|
||||||
{
|
{
|
||||||
|
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, "No PLONDS file entries were found.", false));
|
||||||
return Failed("update.apply", "invalid_manifest", "No PLONDS file entries were found.");
|
return Failed("update.apply", "invalid_manifest", "No PLONDS file entries were found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,17 +371,26 @@ internal sealed class UpdateEngineService
|
|||||||
Directory.Delete(targetDeployment, true);
|
Directory.Delete(targetDeployment, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileEntries.Count));
|
||||||
Directory.CreateDirectory(targetDeployment);
|
Directory.CreateDirectory(targetDeployment);
|
||||||
File.WriteAllText(partialMarker, string.Empty);
|
File.WriteAllText(partialMarker, string.Empty);
|
||||||
|
|
||||||
|
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30, null, 0, fileEntries.Count));
|
||||||
|
var fileIndex = 0;
|
||||||
foreach (var entry in fileEntries)
|
foreach (var entry in fileEntries)
|
||||||
{
|
{
|
||||||
ApplyPlondsFileEntry(entry, currentDeployment, targetDeployment);
|
ApplyPlondsFileEntry(entry, currentDeployment, targetDeployment);
|
||||||
|
fileIndex++;
|
||||||
|
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30 + (fileIndex * 30 / fileEntries.Count), entry.Path, fileIndex, fileEntries.Count));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65, null, 0, fileEntries.Count));
|
||||||
|
var verifyIndex = 0;
|
||||||
foreach (var entry in fileEntries)
|
foreach (var entry in fileEntries)
|
||||||
{
|
{
|
||||||
VerifyPlondsFileEntry(entry, targetDeployment);
|
VerifyPlondsFileEntry(entry, targetDeployment);
|
||||||
|
verifyIndex++;
|
||||||
|
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65 + (verifyIndex * 15 / fileEntries.Count), entry.Path, verifyIndex, fileEntries.Count));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isInitialDeployment)
|
if (isInitialDeployment)
|
||||||
@@ -370,6 +403,7 @@ internal sealed class UpdateEngineService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileEntries.Count, fileEntries.Count));
|
||||||
ActivateDeployment(currentDeployment!, targetDeployment);
|
ActivateDeployment(currentDeployment!, targetDeployment);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,6 +412,9 @@ internal sealed class UpdateEngineService
|
|||||||
CleanupIncomingArtifacts();
|
CleanupIncomingArtifacts();
|
||||||
CleanupDestroyedDeployments();
|
CleanupDestroyedDeployments();
|
||||||
|
|
||||||
|
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileEntries.Count, fileEntries.Count));
|
||||||
|
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(true, sourceVersion, targetVersion, null, false));
|
||||||
|
|
||||||
return new LauncherResult
|
return new LauncherResult
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
@@ -405,6 +442,7 @@ internal sealed class UpdateEngineService
|
|||||||
|
|
||||||
snapshot.Status = "failed";
|
snapshot.Status = "failed";
|
||||||
SaveSnapshot(snapshotPath, snapshot);
|
SaveSnapshot(snapshotPath, snapshot);
|
||||||
|
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, "0.0.0", targetVersion, ex.Message, false));
|
||||||
return new LauncherResult
|
return new LauncherResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
@@ -417,9 +455,11 @@ internal sealed class UpdateEngineService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0));
|
||||||
TryRollbackOnFailure(snapshot);
|
TryRollbackOnFailure(snapshot);
|
||||||
snapshot.Status = "rolled_back";
|
snapshot.Status = "rolled_back";
|
||||||
SaveSnapshot(snapshotPath, snapshot);
|
SaveSnapshot(snapshotPath, snapshot);
|
||||||
|
_progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, sourceVersion, targetVersion, ex.Message, true));
|
||||||
return new LauncherResult
|
return new LauncherResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
|
|||||||
86
LanMountainDesktop.Shared.Contracts/Update/UpdateManifest.cs
Normal file
86
LanMountainDesktop.Shared.Contracts/Update/UpdateManifest.cs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
namespace LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
|
||||||
|
public sealed record UpdateManifest(
|
||||||
|
string DistributionId,
|
||||||
|
string FromVersion,
|
||||||
|
string ToVersion,
|
||||||
|
string Platform,
|
||||||
|
string Channel,
|
||||||
|
DateTimeOffset PublishedAt,
|
||||||
|
UpdatePayloadKind Kind,
|
||||||
|
string? FileMapUrl,
|
||||||
|
string? FileMapSignatureUrl,
|
||||||
|
string? FileMapSha256,
|
||||||
|
IReadOnlyList<UpdateFileEntry> Files,
|
||||||
|
IReadOnlyList<UpdateMirrorAsset>? InstallerMirrors,
|
||||||
|
IReadOnlyDictionary<string, string> Metadata)
|
||||||
|
{
|
||||||
|
public bool IsDelta => Kind is UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy;
|
||||||
|
|
||||||
|
public long EstimatedDeltaBytes
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
long total = 0;
|
||||||
|
foreach (var f in Files)
|
||||||
|
{
|
||||||
|
if (f.Action is not ("reuse" or "delete"))
|
||||||
|
{
|
||||||
|
total += f.Size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record UpdateFileEntry(
|
||||||
|
string Path,
|
||||||
|
string Action,
|
||||||
|
string Sha256,
|
||||||
|
long Size,
|
||||||
|
string Mode,
|
||||||
|
string? ObjectKey,
|
||||||
|
string? ObjectUrl,
|
||||||
|
string? ArchiveSha256,
|
||||||
|
IReadOnlyDictionary<string, string>? Metadata);
|
||||||
|
|
||||||
|
public sealed record UpdateMirrorAsset(
|
||||||
|
string Platform,
|
||||||
|
string? Url,
|
||||||
|
string? Name,
|
||||||
|
string? Sha256,
|
||||||
|
long Size);
|
||||||
|
|
||||||
|
public sealed record UpdateSettingsState(
|
||||||
|
string UpdateChannel,
|
||||||
|
string UpdateMode,
|
||||||
|
string UpdateDownloadSource,
|
||||||
|
int UpdateDownloadThreads,
|
||||||
|
string? PreferredDistributionId,
|
||||||
|
string? LastAppliedVersion,
|
||||||
|
DateTimeOffset? LastAppliedAt,
|
||||||
|
int ConsecutiveFailCount,
|
||||||
|
DateTimeOffset? LastFailureAt,
|
||||||
|
string? PendingUpdateInstallerPath,
|
||||||
|
string? PendingUpdateVersion,
|
||||||
|
long? PendingUpdatePublishedAtUtcMs,
|
||||||
|
long? LastUpdateCheckUtcMs,
|
||||||
|
string? PendingUpdateSha256)
|
||||||
|
{
|
||||||
|
public static UpdateSettingsState Default => new(
|
||||||
|
UpdateChannel: "stable",
|
||||||
|
UpdateMode: "download_then_confirm",
|
||||||
|
UpdateDownloadSource: "plonds-api",
|
||||||
|
UpdateDownloadThreads: 4,
|
||||||
|
PreferredDistributionId: null,
|
||||||
|
LastAppliedVersion: null,
|
||||||
|
LastAppliedAt: null,
|
||||||
|
ConsecutiveFailCount: 0,
|
||||||
|
LastFailureAt: null,
|
||||||
|
PendingUpdateInstallerPath: null,
|
||||||
|
PendingUpdateVersion: null,
|
||||||
|
PendingUpdatePublishedAtUtcMs: null,
|
||||||
|
LastUpdateCheckUtcMs: null,
|
||||||
|
PendingUpdateSha256: null);
|
||||||
|
}
|
||||||
66
LanMountainDesktop.Shared.Contracts/Update/UpdateMessages.cs
Normal file
66
LanMountainDesktop.Shared.Contracts/Update/UpdateMessages.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
namespace LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
|
||||||
|
public sealed record InstallProgressReport(
|
||||||
|
InstallStage Stage,
|
||||||
|
string Message,
|
||||||
|
int ProgressPercent,
|
||||||
|
string? CurrentFile,
|
||||||
|
int FilesCompleted,
|
||||||
|
int FilesTotal)
|
||||||
|
{
|
||||||
|
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record InstallCompleteReport(
|
||||||
|
bool Success,
|
||||||
|
string? FromVersion,
|
||||||
|
string? ToVersion,
|
||||||
|
string? ErrorMessage,
|
||||||
|
bool WasRolledBack)
|
||||||
|
{
|
||||||
|
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record DownloadProgressReport(
|
||||||
|
string CurrentFile,
|
||||||
|
long BytesDownloaded,
|
||||||
|
long BytesTotal,
|
||||||
|
double BytesPerSecond,
|
||||||
|
int FilesCompleted,
|
||||||
|
int FilesTotal,
|
||||||
|
double OverallFraction)
|
||||||
|
{
|
||||||
|
public int OverallPercent => (int)Math.Clamp(OverallFraction * 100, 0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record UpdateProgressReport(
|
||||||
|
UpdatePhase Phase,
|
||||||
|
string Message,
|
||||||
|
double ProgressFraction,
|
||||||
|
DownloadProgressReport? DownloadDetail,
|
||||||
|
InstallProgressReport? InstallDetail)
|
||||||
|
{
|
||||||
|
public int ProgressPercent => (int)Math.Clamp(ProgressFraction * 100, 0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record UpdateCheckReport(
|
||||||
|
bool IsUpdateAvailable,
|
||||||
|
string? LatestVersion,
|
||||||
|
string? CurrentVersion,
|
||||||
|
UpdatePayloadKind? PayloadKind,
|
||||||
|
string? DistributionId,
|
||||||
|
string? Channel,
|
||||||
|
DateTimeOffset? PublishedAt,
|
||||||
|
long? TotalDownloadBytes,
|
||||||
|
long? FullInstallerBytes,
|
||||||
|
string? ErrorMessage);
|
||||||
|
|
||||||
|
public sealed record InstallRequest(
|
||||||
|
UpdatePayloadKind PayloadKind,
|
||||||
|
string LauncherRoot,
|
||||||
|
string? LaunchSource = null);
|
||||||
|
|
||||||
|
public sealed record LaunchResult(
|
||||||
|
bool Success,
|
||||||
|
string? ErrorMessage,
|
||||||
|
int? ProcessId);
|
||||||
71
LanMountainDesktop.Shared.Contracts/Update/UpdatePaths.cs
Normal file
71
LanMountainDesktop.Shared.Contracts/Update/UpdatePaths.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
namespace LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
|
||||||
|
public static class UpdatePaths
|
||||||
|
{
|
||||||
|
private const string LauncherDirectoryName = ".launcher";
|
||||||
|
private const string UpdateDirectoryName = "update";
|
||||||
|
private const string IncomingDirectoryName = "incoming";
|
||||||
|
private const string ObjectsDirectoryName = "objects";
|
||||||
|
private const string SnapshotsDirectoryName = "snapshots";
|
||||||
|
|
||||||
|
public static string ResolveLauncherRoot(string appBaseDirectory)
|
||||||
|
{
|
||||||
|
var trimmed = appBaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
var parent = Path.GetDirectoryName(trimmed);
|
||||||
|
return string.IsNullOrWhiteSpace(parent) ? appBaseDirectory : parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetLauncherDataRoot(string launcherRoot)
|
||||||
|
{
|
||||||
|
return Path.Combine(launcherRoot, LauncherDirectoryName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetIncomingDirectory(string launcherRoot)
|
||||||
|
{
|
||||||
|
return Path.Combine(launcherRoot, LauncherDirectoryName, UpdateDirectoryName, IncomingDirectoryName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetObjectsDirectory(string launcherRoot)
|
||||||
|
{
|
||||||
|
return Path.Combine(GetIncomingDirectory(launcherRoot), ObjectsDirectoryName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetSnapshotsDirectory(string launcherRoot)
|
||||||
|
{
|
||||||
|
return Path.Combine(launcherRoot, LauncherDirectoryName, SnapshotsDirectoryName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetDownloadMarkerPath(string launcherRoot)
|
||||||
|
{
|
||||||
|
return Path.Combine(GetIncomingDirectory(launcherRoot), ".download-complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetPlondsFileMapName() => "plonds-filemap.json";
|
||||||
|
public static string GetPlondsSignatureName() => "plonds-filemap.sig";
|
||||||
|
public static string GetPlondsUpdateMetadataName() => "plonds-update.json";
|
||||||
|
public static string GetLegacyFileMapName() => "files.json";
|
||||||
|
public static string GetLegacySignatureName() => "files.json.sig";
|
||||||
|
public static string GetLegacyArchiveName() => "update.zip";
|
||||||
|
public static string GetPublicKeyFileName() => "public-key.pem";
|
||||||
|
|
||||||
|
public static string GetPlondsFileMapPath(string launcherRoot)
|
||||||
|
=> Path.Combine(GetIncomingDirectory(launcherRoot), GetPlondsFileMapName());
|
||||||
|
|
||||||
|
public static string GetPlondsSignaturePath(string launcherRoot)
|
||||||
|
=> Path.Combine(GetIncomingDirectory(launcherRoot), GetPlondsSignatureName());
|
||||||
|
|
||||||
|
public static string GetPlondsUpdateMetadataPath(string launcherRoot)
|
||||||
|
=> Path.Combine(GetIncomingDirectory(launcherRoot), GetPlondsUpdateMetadataName());
|
||||||
|
|
||||||
|
public static string GetDownloadMarkerContent(string manifestSha256, string targetVersion, int objectCount)
|
||||||
|
{
|
||||||
|
return $$"""
|
||||||
|
{
|
||||||
|
"manifestSha256": "{{manifestSha256}}",
|
||||||
|
"targetVersion": "{{targetVersion}}",
|
||||||
|
"objectCount": {{objectCount}},
|
||||||
|
"completedAt": "{{DateTimeOffset.UtcNow:O}}"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
}
|
||||||
83
LanMountainDesktop.Shared.Contracts/Update/UpdateState.cs
Normal file
83
LanMountainDesktop.Shared.Contracts/Update/UpdateState.cs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
namespace LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
|
||||||
|
public enum UpdatePhase
|
||||||
|
{
|
||||||
|
Idle,
|
||||||
|
Checking,
|
||||||
|
Checked,
|
||||||
|
Downloading,
|
||||||
|
Downloaded,
|
||||||
|
Installing,
|
||||||
|
Installed,
|
||||||
|
Verifying,
|
||||||
|
Completed,
|
||||||
|
Failed,
|
||||||
|
Recovering,
|
||||||
|
RollingBack,
|
||||||
|
RolledBack
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum UpdatePayloadKind
|
||||||
|
{
|
||||||
|
DeltaPlonds,
|
||||||
|
DeltaLegacy,
|
||||||
|
FullInstaller
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum InstallStage
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
VerifySignature,
|
||||||
|
CreateTarget,
|
||||||
|
ApplyFiles,
|
||||||
|
VerifyHashes,
|
||||||
|
ActivateDeployment,
|
||||||
|
Cleanup,
|
||||||
|
Completed,
|
||||||
|
Failed,
|
||||||
|
RollingBack
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum UpdateChannel
|
||||||
|
{
|
||||||
|
Stable,
|
||||||
|
Preview
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum UpdateMode
|
||||||
|
{
|
||||||
|
Manual,
|
||||||
|
DownloadThenConfirm,
|
||||||
|
SilentOnExit
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum UpdateDownloadSource
|
||||||
|
{
|
||||||
|
PlondsApi,
|
||||||
|
GitHub,
|
||||||
|
GhProxy
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class UpdatePhaseExtensions
|
||||||
|
{
|
||||||
|
public static bool IsTerminal(this UpdatePhase phase) =>
|
||||||
|
phase is UpdatePhase.Completed or UpdatePhase.Failed or UpdatePhase.RolledBack;
|
||||||
|
|
||||||
|
public static bool IsBusy(this UpdatePhase phase) =>
|
||||||
|
phase is not (UpdatePhase.Idle or UpdatePhase.Checked or UpdatePhase.Downloaded
|
||||||
|
or UpdatePhase.Installed or UpdatePhase.Completed or UpdatePhase.Failed
|
||||||
|
or UpdatePhase.RolledBack);
|
||||||
|
|
||||||
|
public static bool CanCheck(this UpdatePhase phase) =>
|
||||||
|
phase is UpdatePhase.Idle or UpdatePhase.Checked or UpdatePhase.Downloaded
|
||||||
|
or UpdatePhase.Completed or UpdatePhase.Failed or UpdatePhase.RolledBack;
|
||||||
|
|
||||||
|
public static bool CanDownload(this UpdatePhase phase) =>
|
||||||
|
phase is UpdatePhase.Checked;
|
||||||
|
|
||||||
|
public static bool CanInstall(this UpdatePhase phase) =>
|
||||||
|
phase is UpdatePhase.Downloaded;
|
||||||
|
|
||||||
|
public static bool CanRollback(this UpdatePhase phase) =>
|
||||||
|
phase is UpdatePhase.Failed;
|
||||||
|
}
|
||||||
@@ -646,6 +646,27 @@
|
|||||||
"settings.update.status_check_failed": "Failed to check for updates.",
|
"settings.update.status_check_failed": "Failed to check for updates.",
|
||||||
"settings.update.status_available_summary_format": "Update available: {0} (current: {1})",
|
"settings.update.status_available_summary_format": "Update available: {0} (current: {1})",
|
||||||
"settings.update.status_up_to_date_format": "You are up to date ({0}).",
|
"settings.update.status_up_to_date_format": "You are up to date ({0}).",
|
||||||
|
"settings.update.force_full_label": "Force Full Update",
|
||||||
|
"settings.update.force_full_desc": "Skip incremental update and force download the full installer. Use this if incremental update fails repeatedly.",
|
||||||
|
"settings.update.network_accel_label": "Network Acceleration",
|
||||||
|
"settings.update.network_accel_desc": "Use gh-proxy mirror to accelerate GitHub downloads. Only applies when falling back to GitHub for full updates.",
|
||||||
|
"settings.update.redownload_button": "Redownload",
|
||||||
|
"settings.update.phase_scanning": "Scanning update source...",
|
||||||
|
"settings.update.phase_force_scanning": "Force scanning update source...",
|
||||||
|
"settings.update.phase_locating_resources": "Locating update resources...",
|
||||||
|
"settings.update.phase_force_full": "Forcing full update...",
|
||||||
|
"settings.update.phase_downloading_full": "Downloading full installer...",
|
||||||
|
"settings.update.phase_downloading_delta": "Downloading incremental update...",
|
||||||
|
"settings.update.status_downloading_full": "Downloading full installer...",
|
||||||
|
"settings.update.status_force_full_checking": "Checking for full installer...",
|
||||||
|
"settings.update.status_force_full_failed": "No full installer available.",
|
||||||
|
"settings.update.status_downloaded_no_hash_format": "Update downloaded. Hash: {0}",
|
||||||
|
"settings.update.status_redownload_no_check": "Please check for updates first before redownloading.",
|
||||||
|
"settings.update.status_redownloading": "Redownloading installer...",
|
||||||
|
"settings.update.status_redownload_failed_format": "Redownload failed: {0}",
|
||||||
|
"settings.update.source_plonds": "PLONDS",
|
||||||
|
"settings.update.source_plonds_desc": "Prefer PLONDS distribution endpoints, then automatically fallback to GitHub.",
|
||||||
|
"settings.update.status_check_failed_plonds": "PLONDS update check failed, falling back to GitHub...",
|
||||||
"settings.window.drawer_default": "Details",
|
"settings.window.drawer_default": "Details",
|
||||||
"market.toolbar.search_placeholder": "Search plugins",
|
"market.toolbar.search_placeholder": "Search plugins",
|
||||||
"market.toolbar.refresh": "Refresh",
|
"market.toolbar.refresh": "Refresh",
|
||||||
|
|||||||
@@ -579,6 +579,27 @@
|
|||||||
"settings.update.status_check_failed": "アップデートの確認に失敗しました。",
|
"settings.update.status_check_failed": "アップデートの確認に失敗しました。",
|
||||||
"settings.update.status_available_summary_format": "アップデートあり: {0}(現在: {1})",
|
"settings.update.status_available_summary_format": "アップデートあり: {0}(現在: {1})",
|
||||||
"settings.update.status_up_to_date_format": "最新版です({0})。",
|
"settings.update.status_up_to_date_format": "最新版です({0})。",
|
||||||
|
"settings.update.force_full_label": "完全更新を強制",
|
||||||
|
"settings.update.force_full_desc": "差分更新をスキップし、完全インストーラを強制ダウンロードします。差分更新が繰り返し失敗する場合に使用してください。",
|
||||||
|
"settings.update.network_accel_label": "ネットワーク高速化",
|
||||||
|
"settings.update.network_accel_desc": "gh-proxyミラーを使用してGitHubダウンロードを加速します。GitHubフルアップデートにフォールバック時のみ適用されます。",
|
||||||
|
"settings.update.redownload_button": "再ダウンロード",
|
||||||
|
"settings.update.phase_scanning": "更新ソースをスキャン中...",
|
||||||
|
"settings.update.phase_force_scanning": "更新ソースを強制スキャン中...",
|
||||||
|
"settings.update.phase_locating_resources": "更新リソースを特定中...",
|
||||||
|
"settings.update.phase_force_full": "完全更新を強制中...",
|
||||||
|
"settings.update.phase_downloading_full": "完全インストーラをダウンロード中...",
|
||||||
|
"settings.update.phase_downloading_delta": "差分更新をダウンロード中...",
|
||||||
|
"settings.update.status_downloading_full": "完全インストーラをダウンロード中...",
|
||||||
|
"settings.update.status_force_full_checking": "完全インストーラを確認中...",
|
||||||
|
"settings.update.status_force_full_failed": "利用可能な完全インストーラがありません。",
|
||||||
|
"settings.update.status_downloaded_no_hash_format": "更新がダウンロードされました。ハッシュ:{0}",
|
||||||
|
"settings.update.status_redownload_no_check": "再ダウンロードする前に更新を確認してください。",
|
||||||
|
"settings.update.status_redownloading": "インストーラを再ダウンロード中...",
|
||||||
|
"settings.update.status_redownload_failed_format": "再ダウンロードに失敗しました:{0}",
|
||||||
|
"settings.update.source_plonds": "PLONDS",
|
||||||
|
"settings.update.source_plonds_desc": "PLONDS配信エンドポイントを優先し、利用不可時にGitHubに自動フォールバックします。",
|
||||||
|
"settings.update.status_check_failed_plonds": "PLONDS更新確認に失敗しました。GitHubにフォールバック中...",
|
||||||
"settings.window.drawer_default": "詳細",
|
"settings.window.drawer_default": "詳細",
|
||||||
"market.toolbar.search_placeholder": "プラグインを検索",
|
"market.toolbar.search_placeholder": "プラグインを検索",
|
||||||
"market.toolbar.refresh": "更新",
|
"market.toolbar.refresh": "更新",
|
||||||
|
|||||||
@@ -624,6 +624,27 @@
|
|||||||
"settings.update.status_check_failed": "업데이트 확인 실패.",
|
"settings.update.status_check_failed": "업데이트 확인 실패.",
|
||||||
"settings.update.status_available_summary_format": "업데이트 발견: {0} (현재: {1}).",
|
"settings.update.status_available_summary_format": "업데이트 발견: {0} (현재: {1}).",
|
||||||
"settings.update.status_up_to_date_format": "현재 최신 버전입니다 ({0}).",
|
"settings.update.status_up_to_date_format": "현재 최신 버전입니다 ({0}).",
|
||||||
|
"settings.update.force_full_label": "전체 업데이트 강제",
|
||||||
|
"settings.update.force_full_desc": "증분 업데이트를 건너뛰고 전체 설치 프로그램을 강제로 다운로드합니다. 증분 업데이트가 반복적으로 실패할 때 사용하세요.",
|
||||||
|
"settings.update.network_accel_label": "네트워크 가속",
|
||||||
|
"settings.update.network_accel_desc": "gh-proxy 미러를 사용하여 GitHub 다운로드를 가속합니다. GitHub 전체 업데이트로 대체될 때만 적용됩니다.",
|
||||||
|
"settings.update.redownload_button": "다시 다운로드",
|
||||||
|
"settings.update.phase_scanning": "업데이트 소스 스캔 중...",
|
||||||
|
"settings.update.phase_force_scanning": "업데이트 소스 강제 스캔 중...",
|
||||||
|
"settings.update.phase_locating_resources": "업데이트 리소스 찾는 중...",
|
||||||
|
"settings.update.phase_force_full": "전체 업데이트 강제 중...",
|
||||||
|
"settings.update.phase_downloading_full": "전체 설치 프로그램 다운로드 중...",
|
||||||
|
"settings.update.phase_downloading_delta": "증분 업데이트 다운로드 중...",
|
||||||
|
"settings.update.status_downloading_full": "전체 설치 프로그램 다운로드 중...",
|
||||||
|
"settings.update.status_force_full_checking": "전체 설치 프로그램 확인 중...",
|
||||||
|
"settings.update.status_force_full_failed": "사용 가능한 전체 설치 프로그램이 없습니다.",
|
||||||
|
"settings.update.status_downloaded_no_hash_format": "업데이트가 다운로드되었습니다. 해시: {0}",
|
||||||
|
"settings.update.status_redownload_no_check": "다시 다운로드하기 전에 업데이트를 확인하세요.",
|
||||||
|
"settings.update.status_redownloading": "설치 프로그램 다시 다운로드 중...",
|
||||||
|
"settings.update.status_redownload_failed_format": "다시 다운로드 실패: {0}",
|
||||||
|
"settings.update.source_plonds": "PLONDS",
|
||||||
|
"settings.update.source_plonds_desc": "PLONDS 배포 엔드포인트를 우선 사용하며, 사용 불가 시 GitHub로 자동 대체합니다.",
|
||||||
|
"settings.update.status_check_failed_plonds": "PLONDS 업데이트 확인 실패, GitHub로 대체 중...",
|
||||||
"settings.window.drawer_default": "상세 정보",
|
"settings.window.drawer_default": "상세 정보",
|
||||||
"market.toolbar.search_placeholder": "플러그인 검색",
|
"market.toolbar.search_placeholder": "플러그인 검색",
|
||||||
"market.toolbar.refresh": "새로고침",
|
"market.toolbar.refresh": "새로고침",
|
||||||
|
|||||||
@@ -494,11 +494,11 @@
|
|||||||
"settings.about.render_mode.impl_format": "运行时实现:{0}",
|
"settings.about.render_mode.impl_format": "运行时实现:{0}",
|
||||||
"settings.about.render_mode.impl_unavailable": "当前无法获取运行时实现信息。",
|
"settings.about.render_mode.impl_unavailable": "当前无法获取运行时实现信息。",
|
||||||
"settings.about.description": "应用信息。",
|
"settings.about.description": "应用信息。",
|
||||||
"settings.update.description": "检查更新、选择发布通道与下载源,并控制更新安装方式。",
|
"settings.update.description": "检查更新、选择发布通道与安装方式,并控制更新行为。",
|
||||||
"settings.update.status_card_title": "更新状态",
|
"settings.update.status_card_title": "更新状态",
|
||||||
"settings.update.status_card_description": "检查新版本、查看发布信息,并在有更新时继续下载或安装。",
|
"settings.update.status_card_description": "检查新版本、查看发布信息,并在有更新时继续下载或安装。",
|
||||||
"settings.update.preferences_header": "更新偏好",
|
"settings.update.preferences_header": "更新偏好",
|
||||||
"settings.update.preferences_description": "选择发布通道、安装包下载源、安装方式以及下载并行线程数。",
|
"settings.update.preferences_description": "选择发布通道、安装方式、网络加速以及下载并行线程数。",
|
||||||
"settings.update.last_checked_label": "上次检查",
|
"settings.update.last_checked_label": "上次检查",
|
||||||
"settings.update.source_label": "下载源",
|
"settings.update.source_label": "下载源",
|
||||||
"settings.update.source_github": "GitHub",
|
"settings.update.source_github": "GitHub",
|
||||||
@@ -640,6 +640,27 @@
|
|||||||
"settings.update.status_check_failed": "检查更新失败。",
|
"settings.update.status_check_failed": "检查更新失败。",
|
||||||
"settings.update.status_available_summary_format": "发现更新:{0}(当前:{1})。",
|
"settings.update.status_available_summary_format": "发现更新:{0}(当前:{1})。",
|
||||||
"settings.update.status_up_to_date_format": "当前已是最新版本({0})。",
|
"settings.update.status_up_to_date_format": "当前已是最新版本({0})。",
|
||||||
|
"settings.update.force_full_label": "强制完整更新",
|
||||||
|
"settings.update.force_full_desc": "跳过增量更新,直接下载完整安装包。如果增量更新反复失败,可使用此项。",
|
||||||
|
"settings.update.network_accel_label": "网络加速",
|
||||||
|
"settings.update.network_accel_desc": "使用 gh-proxy 镜像加速 GitHub 下载。仅在回退到 GitHub 全量更新时生效。",
|
||||||
|
"settings.update.redownload_button": "重新下载",
|
||||||
|
"settings.update.phase_scanning": "正在扫描更新源...",
|
||||||
|
"settings.update.phase_force_scanning": "正在强制扫描更新源...",
|
||||||
|
"settings.update.phase_locating_resources": "正在定位更新资源...",
|
||||||
|
"settings.update.phase_force_full": "正在强制完整更新...",
|
||||||
|
"settings.update.phase_downloading_full": "正在下载完整安装包...",
|
||||||
|
"settings.update.phase_downloading_delta": "正在下载增量更新...",
|
||||||
|
"settings.update.status_downloading_full": "正在下载完整安装包...",
|
||||||
|
"settings.update.status_force_full_checking": "正在检查完整安装包...",
|
||||||
|
"settings.update.status_force_full_failed": "没有可用的完整安装包。",
|
||||||
|
"settings.update.status_downloaded_no_hash_format": "更新已下载。哈希值:{0}",
|
||||||
|
"settings.update.status_redownload_no_check": "请先检查更新后再重新下载。",
|
||||||
|
"settings.update.status_redownloading": "正在重新下载安装包...",
|
||||||
|
"settings.update.status_redownload_failed_format": "重新下载失败:{0}",
|
||||||
|
"settings.update.source_plonds": "PLONDS",
|
||||||
|
"settings.update.source_plonds_desc": "优先使用 PLONDS 分发端点,不可用时自动回退到 GitHub。",
|
||||||
|
"settings.update.status_check_failed_plonds": "PLONDS 更新检查失败,正在回退到 GitHub...",
|
||||||
"settings.window.drawer_default": "详情",
|
"settings.window.drawer_default": "详情",
|
||||||
"market.toolbar.search_placeholder": "搜索插件",
|
"market.toolbar.search_placeholder": "搜索插件",
|
||||||
"market.toolbar.refresh": "刷新",
|
"market.toolbar.refresh": "刷新",
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ public sealed class AppSettingsSnapshot
|
|||||||
|
|
||||||
public string UpdateMode { get; set; } = "download_then_confirm";
|
public string UpdateMode { get; set; } = "download_then_confirm";
|
||||||
|
|
||||||
public string UpdateDownloadSource { get; set; } = "stcn";
|
public string UpdateDownloadSource { get; set; } = "plonds-api";
|
||||||
|
|
||||||
|
public bool UseGhProxyMirror { get; set; }
|
||||||
|
|
||||||
public int UpdateDownloadThreads { get; set; } = 4;
|
public int UpdateDownloadThreads { get; set; } = 4;
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ namespace LanMountainDesktop.Services;
|
|||||||
internal sealed class LauncherClient
|
internal sealed class LauncherClient
|
||||||
{
|
{
|
||||||
private const int UserCanceledUacErrorCode = 1223;
|
private const int UserCanceledUacErrorCode = 1223;
|
||||||
private const string LauncherExecutableName = "LanMountainDesktop.Launcher.exe";
|
|
||||||
|
|
||||||
public async Task<LauncherInstallResult> InstallPackageAsync(
|
public async Task<LauncherInstallResult> InstallPackageAsync(
|
||||||
string packagePath,
|
string packagePath,
|
||||||
@@ -34,13 +33,13 @@ internal sealed class LauncherClient
|
|||||||
"failed");
|
"failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
var launcherPath = ResolveLauncherPath();
|
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
||||||
if (!File.Exists(launcherPath))
|
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
|
||||||
{
|
{
|
||||||
return new LauncherInstallResult(
|
return new LauncherInstallResult(
|
||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
$"Launcher executable was not found at '{launcherPath}'.",
|
"Launcher executable was not found. Expected it to be located in the application root directory (sibling to the app-* deployment folder).",
|
||||||
"failed");
|
"failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,21 +128,6 @@ internal sealed class LauncherClient
|
|||||||
return await JsonSerializer.DeserializeAsync<HelperResultFile>(stream, cancellationToken: cancellationToken);
|
return await JsonSerializer.DeserializeAsync<HelperResultFile>(stream, cancellationToken: cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ResolveLauncherPath()
|
|
||||||
{
|
|
||||||
var baseDirectory = AppContext.BaseDirectory;
|
|
||||||
var candidates = new[]
|
|
||||||
{
|
|
||||||
Path.Combine(baseDirectory, "Launcher", LauncherExecutableName),
|
|
||||||
Path.Combine(baseDirectory, LauncherExecutableName),
|
|
||||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", "LanMountainDesktop.Launcher", LauncherExecutableName)),
|
|
||||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Debug", "net10.0", LauncherExecutableName)),
|
|
||||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Release", "net10.0", LauncherExecutableName))
|
|
||||||
};
|
|
||||||
|
|
||||||
return candidates.FirstOrDefault(File.Exists) ?? candidates[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string QuoteArgument(string value)
|
private static string QuoteArgument(string value)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(value))
|
if (string.IsNullOrEmpty(value))
|
||||||
|
|||||||
90
LanMountainDesktop/Services/LauncherPathResolver.cs
Normal file
90
LanMountainDesktop/Services/LauncherPathResolver.cs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 统一解析 Launcher 可执行文件路径的工具类。
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 安装后的目录结构:
|
||||||
|
/// <code>
|
||||||
|
/// {AppRoot}/ ← 应用安装根目录
|
||||||
|
/// LanMountainDesktop.Launcher.exe ← Launcher 可执行文件
|
||||||
|
/// .Launcher/ ← Launcher 数据目录(日志、状态、配置等)
|
||||||
|
/// app-{version}/ ← Host 部署目录
|
||||||
|
/// LanMountainDesktop.exe
|
||||||
|
/// ...
|
||||||
|
/// </code>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class LauncherPathResolver
|
||||||
|
{
|
||||||
|
private const string WindowsLauncherExeName = "LanMountainDesktop.Launcher.exe";
|
||||||
|
private const string UnixLauncherExeName = "LanMountainDesktop.Launcher";
|
||||||
|
|
||||||
|
private static string LauncherExecutableName =>
|
||||||
|
OperatingSystem.IsWindows() ? WindowsLauncherExeName : UnixLauncherExeName;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析 Launcher 可执行文件的完整路径。如果找不到则返回 null。
|
||||||
|
/// </summary>
|
||||||
|
public static string? ResolveLauncherExecutablePath()
|
||||||
|
{
|
||||||
|
var baseDirectory = AppContext.BaseDirectory;
|
||||||
|
|
||||||
|
var candidates = new[]
|
||||||
|
{
|
||||||
|
// 1. 发布版(安装版):Host 在 app-* 子目录中,Launcher 在父目录(应用根目录)
|
||||||
|
Path.GetFullPath(Path.Combine(baseDirectory, "..", LauncherExecutableName)),
|
||||||
|
|
||||||
|
// 2. 便携版 / 单文件发布:Launcher 与 Host 在同一目录
|
||||||
|
Path.Combine(baseDirectory, LauncherExecutableName),
|
||||||
|
|
||||||
|
// 3. 开发环境:Launcher 项目输出目录与 Host 项目输出目录同级
|
||||||
|
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Debug", "net10.0", LauncherExecutableName)),
|
||||||
|
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Release", "net10.0", LauncherExecutableName)),
|
||||||
|
};
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
.Select(Path.GetFullPath)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.FirstOrDefault(File.Exists);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析 Launcher 数据目录(.Launcher)的路径。
|
||||||
|
/// 该目录与 app-* 文件夹同级,位于应用安装根目录下。
|
||||||
|
/// </summary>
|
||||||
|
public static string ResolveLauncherDataDirectory()
|
||||||
|
{
|
||||||
|
var baseDirectory = AppContext.BaseDirectory;
|
||||||
|
|
||||||
|
// 优先尝试应用安装根目录(Host 的父目录)
|
||||||
|
var appRootCandidate = Path.GetFullPath(Path.Combine(baseDirectory, ".."));
|
||||||
|
var launcherDataDir = Path.Combine(appRootCandidate, ".Launcher");
|
||||||
|
|
||||||
|
if (Directory.Exists(launcherDataDir) || CanWriteToDirectory(appRootCandidate))
|
||||||
|
{
|
||||||
|
return launcherDataDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退到 Host 所在目录(便携模式或开发环境)
|
||||||
|
return Path.Combine(baseDirectory, ".Launcher");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CanWriteToDirectory(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var testFile = Path.Combine(path, $".write-test-{Guid.NewGuid():N}.tmp");
|
||||||
|
File.WriteAllText(testFile, string.Empty);
|
||||||
|
File.Delete(testFile);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -292,7 +292,7 @@ public sealed class ResumableDownloadService
|
|||||||
ParallelDownload = useParallelDownload,
|
ParallelDownload = useParallelDownload,
|
||||||
MinimumSizeOfChunking = options.ParallelThresholdBytes,
|
MinimumSizeOfChunking = options.ParallelThresholdBytes,
|
||||||
MaxTryAgainOnFailure = 3,
|
MaxTryAgainOnFailure = 3,
|
||||||
ResumeDownloadIfCan = true,
|
EnableAutoResumeDownload = true,
|
||||||
ClearPackageOnCompletionWithFailure = false,
|
ClearPackageOnCompletionWithFailure = false,
|
||||||
FileExistPolicy = FileExistPolicy.Delete,
|
FileExistPolicy = FileExistPolicy.Delete,
|
||||||
DownloadFileExtension = ".part"
|
DownloadFileExtension = ".part"
|
||||||
|
|||||||
@@ -337,12 +337,10 @@ public sealed class SentryCrashTelemetryService : IDisposable
|
|||||||
{
|
{
|
||||||
scope.SetExtra("log_tail", logTail);
|
scope.SetExtra("log_tail", logTail);
|
||||||
scope.SetExtra("log_tail_line_count", logTail.Count(character => character == '\n') + 1);
|
scope.SetExtra("log_tail_line_count", logTail.Count(character => character == '\n') + 1);
|
||||||
var attachment = new Attachment(
|
scope.AddAttachment(
|
||||||
AttachmentType.Default,
|
Encoding.UTF8.GetBytes(logTail),
|
||||||
new ByteAttachmentContent(Encoding.UTF8.GetBytes(logTail)),
|
|
||||||
"log-tail.txt",
|
"log-tail.txt",
|
||||||
"text/plain");
|
contentType: "text/plain");
|
||||||
scope.AddAttachment(attachment);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ public sealed record UpdateSettingsState(
|
|||||||
string UpdateMode,
|
string UpdateMode,
|
||||||
string UpdateDownloadSource,
|
string UpdateDownloadSource,
|
||||||
int UpdateDownloadThreads,
|
int UpdateDownloadThreads,
|
||||||
|
bool UseGhProxyMirror,
|
||||||
string? PendingUpdateInstallerPath,
|
string? PendingUpdateInstallerPath,
|
||||||
string? PendingUpdateVersion,
|
string? PendingUpdateVersion,
|
||||||
long? PendingUpdatePublishedAtUtcMs,
|
long? PendingUpdatePublishedAtUtcMs,
|
||||||
|
|||||||
@@ -789,6 +789,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
UpdateSettingsValues.NormalizeMode(snapshot.UpdateMode),
|
UpdateSettingsValues.NormalizeMode(snapshot.UpdateMode),
|
||||||
UpdateSettingsValues.NormalizeDownloadSource(snapshot.UpdateDownloadSource),
|
UpdateSettingsValues.NormalizeDownloadSource(snapshot.UpdateDownloadSource),
|
||||||
UpdateSettingsValues.NormalizeDownloadThreads(snapshot.UpdateDownloadThreads),
|
UpdateSettingsValues.NormalizeDownloadThreads(snapshot.UpdateDownloadThreads),
|
||||||
|
snapshot.UseGhProxyMirror,
|
||||||
snapshot.PendingUpdateInstallerPath,
|
snapshot.PendingUpdateInstallerPath,
|
||||||
snapshot.PendingUpdateVersion,
|
snapshot.PendingUpdateVersion,
|
||||||
snapshot.PendingUpdatePublishedAtUtcMs,
|
snapshot.PendingUpdatePublishedAtUtcMs,
|
||||||
@@ -810,6 +811,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
snapshot.UpdateMode = UpdateSettingsValues.NormalizeMode(state.UpdateMode);
|
snapshot.UpdateMode = UpdateSettingsValues.NormalizeMode(state.UpdateMode);
|
||||||
snapshot.UpdateDownloadSource = UpdateSettingsValues.NormalizeDownloadSource(state.UpdateDownloadSource);
|
snapshot.UpdateDownloadSource = UpdateSettingsValues.NormalizeDownloadSource(state.UpdateDownloadSource);
|
||||||
snapshot.UpdateDownloadThreads = UpdateSettingsValues.NormalizeDownloadThreads(state.UpdateDownloadThreads);
|
snapshot.UpdateDownloadThreads = UpdateSettingsValues.NormalizeDownloadThreads(state.UpdateDownloadThreads);
|
||||||
|
snapshot.UseGhProxyMirror = state.UseGhProxyMirror;
|
||||||
snapshot.PendingUpdateInstallerPath = string.IsNullOrWhiteSpace(state.PendingUpdateInstallerPath)
|
snapshot.PendingUpdateInstallerPath = string.IsNullOrWhiteSpace(state.PendingUpdateInstallerPath)
|
||||||
? null
|
? null
|
||||||
: state.PendingUpdateInstallerPath.Trim();
|
: state.PendingUpdateInstallerPath.Trim();
|
||||||
@@ -836,6 +838,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
nameof(AppSettingsSnapshot.UpdateMode),
|
nameof(AppSettingsSnapshot.UpdateMode),
|
||||||
nameof(AppSettingsSnapshot.UpdateDownloadSource),
|
nameof(AppSettingsSnapshot.UpdateDownloadSource),
|
||||||
nameof(AppSettingsSnapshot.UpdateDownloadThreads),
|
nameof(AppSettingsSnapshot.UpdateDownloadThreads),
|
||||||
|
nameof(AppSettingsSnapshot.UseGhProxyMirror),
|
||||||
nameof(AppSettingsSnapshot.PendingUpdateInstallerPath),
|
nameof(AppSettingsSnapshot.PendingUpdateInstallerPath),
|
||||||
nameof(AppSettingsSnapshot.PendingUpdateVersion),
|
nameof(AppSettingsSnapshot.PendingUpdateVersion),
|
||||||
nameof(AppSettingsSnapshot.PendingUpdatePublishedAtUtcMs),
|
nameof(AppSettingsSnapshot.PendingUpdatePublishedAtUtcMs),
|
||||||
@@ -917,9 +920,6 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
bool includePrerelease,
|
bool includePrerelease,
|
||||||
bool isForce,
|
bool isForce,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
|
||||||
var source = UpdateSettingsValues.NormalizeDownloadSource(_settingsService.Load().UpdateDownloadSource);
|
|
||||||
if (string.Equals(source, UpdateSettingsValues.DownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
var plondsResult = isForce
|
var plondsResult = isForce
|
||||||
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
||||||
@@ -953,11 +953,6 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
|
|
||||||
return githubFallbackResult;
|
return githubFallbackResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
return isForce
|
|
||||||
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
|
|
||||||
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class LauncherCatalogService : ILauncherCatalogService
|
internal sealed class LauncherCatalogService : ILauncherCatalogService
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
|
internal sealed class CliLauncherUpdateBridge : ILauncherUpdateBridge
|
||||||
|
{
|
||||||
|
public Task<LaunchResult> LaunchInstallerAsync(InstallRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
||||||
|
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
|
||||||
|
{
|
||||||
|
return Task.FromResult(new LaunchResult(false, "Launcher executable not found.", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedLauncherRoot = Path.GetDirectoryName(launcherPath)!;
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = launcherPath,
|
||||||
|
Arguments = $"apply-update --app-root \"{resolvedLauncherRoot}\" --launch-source {request.LaunchSource ?? "apply-update"}",
|
||||||
|
UseShellExecute = false,
|
||||||
|
WorkingDirectory = resolvedLauncherRoot
|
||||||
|
};
|
||||||
|
|
||||||
|
var process = Process.Start(startInfo);
|
||||||
|
if (process is null)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new LaunchResult(false, "Failed to start Launcher process.", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(new LaunchResult(true, null, process.Id));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new LaunchResult(false, ex.Message, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IObservable<InstallProgressReport> ProgressStream => ObservableHelper<InstallProgressReport>.Empty;
|
||||||
|
|
||||||
|
public Task<bool> SupportsIpcAsync() => Task.FromResult(false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
|
internal sealed class CompositeManifestProvider : IUpdateManifestProvider
|
||||||
|
{
|
||||||
|
private readonly IUpdateManifestProvider _primary;
|
||||||
|
private readonly IUpdateManifestProvider _fallback;
|
||||||
|
|
||||||
|
public string ProviderName => $"{_primary.ProviderName}+{_fallback.ProviderName}";
|
||||||
|
|
||||||
|
public CompositeManifestProvider(IUpdateManifestProvider primary, IUpdateManifestProvider fallback)
|
||||||
|
{
|
||||||
|
_primary = primary ?? throw new ArgumentNullException(nameof(primary));
|
||||||
|
_fallback = fallback ?? throw new ArgumentNullException(nameof(fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UpdateManifest?> GetLatestAsync(
|
||||||
|
string channel,
|
||||||
|
string platform,
|
||||||
|
Version currentVersion,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _primary.GetLatestAsync(channel, platform, currentVersion, ct);
|
||||||
|
if (result is not null)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("Update", $"{_primary.ProviderName} GetLatestAsync failed: {ex.Message}", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Info("Update", $"Falling back to {_fallback.ProviderName} for GetLatestAsync");
|
||||||
|
return await _fallback.GetLatestAsync(channel, platform, currentVersion, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UpdateManifest?> GetByVersionAsync(
|
||||||
|
string version,
|
||||||
|
string channel,
|
||||||
|
string platform,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _primary.GetByVersionAsync(version, channel, platform, ct);
|
||||||
|
if (result is not null)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("Update", $"{_primary.ProviderName} GetByVersionAsync failed: {ex.Message}", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Info("Update", $"Falling back to {_fallback.ProviderName} for GetByVersionAsync");
|
||||||
|
return await _fallback.GetByVersionAsync(version, channel, platform, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<UpdateManifest>> GetIncrementalChainAsync(
|
||||||
|
string channel,
|
||||||
|
string platform,
|
||||||
|
Version fromVersion,
|
||||||
|
Version toVersion,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _primary.GetIncrementalChainAsync(channel, platform, fromVersion, toVersion, ct);
|
||||||
|
if (result is not null && result.Count > 0)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("Update", $"{_primary.ProviderName} GetIncrementalChainAsync failed: {ex.Message}", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Info("Update", $"Falling back to {_fallback.ProviderName} for GetIncrementalChainAsync");
|
||||||
|
return await _fallback.GetIncrementalChainAsync(channel, platform, fromVersion, toVersion, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
using LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
|
internal sealed class GithubReleaseManifestProvider : IUpdateManifestProvider
|
||||||
|
{
|
||||||
|
private readonly GitHubReleaseUpdateService _githubService;
|
||||||
|
private readonly bool _ownsService;
|
||||||
|
|
||||||
|
public string ProviderName => "github-release";
|
||||||
|
|
||||||
|
public GithubReleaseManifestProvider(string owner, string repo, GitHubReleaseUpdateService? githubService = null)
|
||||||
|
{
|
||||||
|
if (githubService is null)
|
||||||
|
{
|
||||||
|
_githubService = new GitHubReleaseUpdateService(owner, repo);
|
||||||
|
_ownsService = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_githubService = githubService;
|
||||||
|
_ownsService = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UpdateManifest?> GetLatestAsync(
|
||||||
|
string channel,
|
||||||
|
string platform,
|
||||||
|
Version currentVersion,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var includePrerelease = string.Equals(channel, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var result = await _githubService.CheckForUpdatesAsync(currentVersion, includePrerelease, ct);
|
||||||
|
if (!result.Success || !result.IsUpdateAvailable || result.Release is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return UpdateManifestMapper.FromGitHubRelease(result.Release, result.PlondsPayload, channel, platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UpdateManifest?> GetByVersionAsync(
|
||||||
|
string version,
|
||||||
|
string channel,
|
||||||
|
string platform,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var tag = version.StartsWith("v", StringComparison.OrdinalIgnoreCase) ? version : $"v{version}";
|
||||||
|
var release = await _githubService.GetReleaseByTagAsync(tag, ct);
|
||||||
|
if (release is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var plondsPayload = TryResolvePlondsPayload(release);
|
||||||
|
return UpdateManifestMapper.FromGitHubRelease(release, plondsPayload, channel, platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<UpdateManifest>> GetIncrementalChainAsync(
|
||||||
|
string channel,
|
||||||
|
string platform,
|
||||||
|
Version fromVersion,
|
||||||
|
Version toVersion,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
return Task.FromResult<IReadOnlyList<UpdateManifest>>([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlondsUpdatePayload? TryResolvePlondsPayload(GitHubReleaseInfo release)
|
||||||
|
{
|
||||||
|
if (release.Assets is null || release.Assets.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var platformSuffix = GetPlatformAssetSuffix();
|
||||||
|
var fileMapAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json");
|
||||||
|
var signatureAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json.sig")
|
||||||
|
?? FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.sig");
|
||||||
|
var archiveAsset = FindAsset(release.Assets, $"update-{platformSuffix}.zip");
|
||||||
|
|
||||||
|
if (fileMapAsset is null || signatureAsset is null || archiveAsset is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var distributionId = $"plonds-{release.TagName.Trim().TrimStart('v')}-{platformSuffix}";
|
||||||
|
var channelId = release.IsPrerelease
|
||||||
|
? UpdateSettingsValues.ChannelPreview
|
||||||
|
: UpdateSettingsValues.ChannelStable;
|
||||||
|
|
||||||
|
return new PlondsUpdatePayload(
|
||||||
|
DistributionId: distributionId,
|
||||||
|
ChannelId: channelId,
|
||||||
|
SubChannel: platformSuffix,
|
||||||
|
FileMapJson: null,
|
||||||
|
FileMapSignature: null,
|
||||||
|
FileMapJsonUrl: fileMapAsset.BrowserDownloadUrl,
|
||||||
|
FileMapSignatureUrl: signatureAsset.BrowserDownloadUrl,
|
||||||
|
UpdateArchiveUrl: archiveAsset.BrowserDownloadUrl,
|
||||||
|
UpdateArchiveSha256: archiveAsset.Sha256,
|
||||||
|
UpdateArchiveSizeBytes: archiveAsset.SizeBytes > 0 ? archiveAsset.SizeBytes : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GitHubReleaseAsset? FindAsset(IReadOnlyList<GitHubReleaseAsset> assets, string assetName)
|
||||||
|
{
|
||||||
|
return assets.FirstOrDefault(a => string.Equals(a.Name, assetName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetPlatformAssetSuffix()
|
||||||
|
{
|
||||||
|
var os = OperatingSystem.IsWindows()
|
||||||
|
? "windows"
|
||||||
|
: OperatingSystem.IsLinux()
|
||||||
|
? "linux"
|
||||||
|
: OperatingSystem.IsMacOS()
|
||||||
|
? "macos"
|
||||||
|
: "unknown";
|
||||||
|
|
||||||
|
var arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch
|
||||||
|
{
|
||||||
|
System.Runtime.InteropServices.Architecture.X86 => "x86",
|
||||||
|
System.Runtime.InteropServices.Architecture.Arm => "arm",
|
||||||
|
System.Runtime.InteropServices.Architecture.Arm64 => "arm64",
|
||||||
|
_ => "x64"
|
||||||
|
};
|
||||||
|
|
||||||
|
return $"{os}-{arch}";
|
||||||
|
}
|
||||||
|
}
|
||||||
10
LanMountainDesktop/Services/Update/ILauncherUpdateBridge.cs
Normal file
10
LanMountainDesktop/Services/Update/ILauncherUpdateBridge.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
|
public interface ILauncherUpdateBridge
|
||||||
|
{
|
||||||
|
Task<LaunchResult> LaunchInstallerAsync(InstallRequest request, CancellationToken ct);
|
||||||
|
IObservable<InstallProgressReport> ProgressStream { get; }
|
||||||
|
Task<bool> SupportsIpcAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
|
public interface IUpdateManifestProvider
|
||||||
|
{
|
||||||
|
string ProviderName { get; }
|
||||||
|
|
||||||
|
Task<UpdateManifest?> GetLatestAsync(
|
||||||
|
string channel,
|
||||||
|
string platform,
|
||||||
|
Version currentVersion,
|
||||||
|
CancellationToken ct);
|
||||||
|
|
||||||
|
Task<UpdateManifest?> GetByVersionAsync(
|
||||||
|
string version,
|
||||||
|
string channel,
|
||||||
|
string platform,
|
||||||
|
CancellationToken ct);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<UpdateManifest>> GetIncrementalChainAsync(
|
||||||
|
string channel,
|
||||||
|
string platform,
|
||||||
|
Version fromVersion,
|
||||||
|
Version toVersion,
|
||||||
|
CancellationToken ct);
|
||||||
|
}
|
||||||
171
LanMountainDesktop/Services/Update/IpcLauncherUpdateBridge.cs
Normal file
171
LanMountainDesktop/Services/Update/IpcLauncherUpdateBridge.cs
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
using System.Buffers;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Pipes;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
|
internal sealed class IpcLauncherUpdateBridge : ILauncherUpdateBridge, IDisposable
|
||||||
|
{
|
||||||
|
private const int LengthPrefixSize = 4;
|
||||||
|
private const int MaxPayloadLength = 1024 * 1024;
|
||||||
|
private static readonly TimeSpan PipeConnectTimeout = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
private readonly UpdateProgressSubject _progressSubject = new();
|
||||||
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
private int? _launcherPid;
|
||||||
|
|
||||||
|
public IObservable<InstallProgressReport> ProgressStream => _progressSubject;
|
||||||
|
|
||||||
|
public async Task<LaunchResult> LaunchInstallerAsync(InstallRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
||||||
|
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
|
||||||
|
{
|
||||||
|
return new LaunchResult(false, "Launcher executable not found.", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedLauncherRoot = Path.GetDirectoryName(launcherPath)!;
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = launcherPath,
|
||||||
|
Arguments = $"apply-update --app-root \"{resolvedLauncherRoot}\" --launch-source {request.LaunchSource ?? "apply-update"}",
|
||||||
|
UseShellExecute = false,
|
||||||
|
WorkingDirectory = resolvedLauncherRoot
|
||||||
|
};
|
||||||
|
|
||||||
|
var process = Process.Start(startInfo);
|
||||||
|
if (process is null)
|
||||||
|
{
|
||||||
|
return new LaunchResult(false, "Failed to start Launcher process.", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
_launcherPid = process.Id;
|
||||||
|
|
||||||
|
_ = Task.Run(() => ConnectAndReadProgressAsync(process.Id, ct), ct);
|
||||||
|
|
||||||
|
return new LaunchResult(true, null, process.Id);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new LaunchResult(false, ex.Message, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> SupportsIpcAsync()
|
||||||
|
{
|
||||||
|
return Task.FromResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ConnectAndReadProgressAsync(int launcherPid, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var pipeName = $"LanMountainDesktop_Update_{launcherPid}";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var pipe = new NamedPipeClientStream(
|
||||||
|
".",
|
||||||
|
pipeName,
|
||||||
|
PipeDirection.In,
|
||||||
|
PipeOptions.Asynchronous);
|
||||||
|
|
||||||
|
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token);
|
||||||
|
using var timeoutCts = new CancellationTokenSource(PipeConnectTimeout);
|
||||||
|
using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(linkedCts.Token, timeoutCts.Token);
|
||||||
|
|
||||||
|
await pipe.ConnectAsync(combinedCts.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await ReadProgressFromPipeAsync(pipe, linkedCts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("IpcLauncherUpdateBridge", $"Progress pipe connection failed (fire-and-forget): {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReadProgressFromPipeAsync(NamedPipeClientStream pipe, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var lengthBuffer = ArrayPool<byte>.Shared.Rent(LengthPrefixSize);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (pipe.IsConnected && !ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var totalRead = 0;
|
||||||
|
while (totalRead < LengthPrefixSize)
|
||||||
|
{
|
||||||
|
var read = await pipe.ReadAsync(lengthBuffer.AsMemory(totalRead, LengthPrefixSize - totalRead), ct).ConfigureAwait(false);
|
||||||
|
if (read == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalRead += read;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payloadLength = BitConverter.ToInt32(lengthBuffer, 0);
|
||||||
|
if (payloadLength <= 0 || payloadLength > MaxPayloadLength)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payloadBuffer = ArrayPool<byte>.Shared.Rent(payloadLength);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
totalRead = 0;
|
||||||
|
while (totalRead < payloadLength)
|
||||||
|
{
|
||||||
|
var read = await pipe.ReadAsync(payloadBuffer.AsMemory(totalRead, payloadLength - totalRead), ct).ConfigureAwait(false);
|
||||||
|
if (read == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalRead += read;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = Encoding.UTF8.GetString(payloadBuffer, 0, payloadLength);
|
||||||
|
var report = JsonSerializer.Deserialize(json, UpdateJsonContext.Default.InstallProgressReport);
|
||||||
|
if (report is not null)
|
||||||
|
{
|
||||||
|
_progressSubject.OnNext(report);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ArrayPool<byte>.Shared.Return(payloadBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ArrayPool<byte>.Shared.Return(lengthBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_cts.Cancel();
|
||||||
|
_progressSubject.OnCompleted();
|
||||||
|
_cts.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
31
LanMountainDesktop/Services/Update/ObservableHelper.cs
Normal file
31
LanMountainDesktop/Services/Update/ObservableHelper.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
|
internal static class ObservableHelper<T>
|
||||||
|
{
|
||||||
|
private sealed class EmptyObservable : IObservable<T>
|
||||||
|
{
|
||||||
|
public IDisposable Subscribe(IObserver<T> observer) => EmptyDisposable.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class EmptyDisposable : IDisposable
|
||||||
|
{
|
||||||
|
public static readonly EmptyDisposable Instance = new();
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static readonly IObservable<T> Empty = new EmptyObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ActionObserver<T> : IObserver<T>
|
||||||
|
{
|
||||||
|
private readonly Action<T> _onNext;
|
||||||
|
|
||||||
|
public ActionObserver(Action<T> onNext)
|
||||||
|
{
|
||||||
|
_onNext = onNext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnCompleted() { }
|
||||||
|
public void OnError(Exception error) { }
|
||||||
|
public void OnNext(T value) => _onNext(value);
|
||||||
|
}
|
||||||
247
LanMountainDesktop/Services/Update/PlondsApiManifestProvider.cs
Normal file
247
LanMountainDesktop/Services/Update/PlondsApiManifestProvider.cs
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
|
internal sealed class PlondsApiManifestProvider : IUpdateManifestProvider
|
||||||
|
{
|
||||||
|
private const string ApiBasePath = "/api/plonds/v1";
|
||||||
|
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly bool _ownsHttpClient;
|
||||||
|
|
||||||
|
public string ProviderName => "plonds-api";
|
||||||
|
|
||||||
|
public PlondsApiManifestProvider(string baseUrl, HttpClient? httpClient = null)
|
||||||
|
{
|
||||||
|
if (httpClient is null)
|
||||||
|
{
|
||||||
|
_httpClient = new HttpClient
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(baseUrl.TrimEnd('/')),
|
||||||
|
Timeout = TimeSpan.FromSeconds(30)
|
||||||
|
};
|
||||||
|
_ownsHttpClient = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_httpClient.BaseAddress ??= new Uri(baseUrl.TrimEnd('/'));
|
||||||
|
_ownsHttpClient = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_httpClient.DefaultRequestHeaders.UserAgent.Any())
|
||||||
|
{
|
||||||
|
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-Updater/1.0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UpdateManifest?> GetLatestAsync(
|
||||||
|
string channel,
|
||||||
|
string platform,
|
||||||
|
Version currentVersion,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var pointer = await GetChannelPointerAsync(channel, platform, currentVersion, ct);
|
||||||
|
if (pointer is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await FetchDistributionManifestAsync(pointer.DistributionId, pointer.Version, channel, platform, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UpdateManifest?> GetByVersionAsync(
|
||||||
|
string version,
|
||||||
|
string channel,
|
||||||
|
string platform,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var distributionId = $"{channel}-{platform}-{version}";
|
||||||
|
return await FetchDistributionManifestAsync(distributionId, version, channel, platform, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<UpdateManifest>> GetIncrementalChainAsync(
|
||||||
|
string channel,
|
||||||
|
string platform,
|
||||||
|
Version fromVersion,
|
||||||
|
Version toVersion,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
return Task.FromResult<IReadOnlyList<UpdateManifest>>([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PlondsChannelPointerDto?> GetChannelPointerAsync(
|
||||||
|
string channel,
|
||||||
|
string platform,
|
||||||
|
Version currentVersion,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var url = $"{ApiBasePath}/channels/{Uri.EscapeDataString(channel)}/{Uri.EscapeDataString(platform)}/latest?currentVersion={Uri.EscapeDataString(currentVersion.ToString())}";
|
||||||
|
|
||||||
|
using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||||
|
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var errorBody = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
AppLogger.Warn("Update", $"PLONDS API latest endpoint returned HTTP {(int)response.StatusCode}: {Truncate(errorBody, 256)}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
return JsonSerializer.Deserialize<PlondsChannelPointerDto>(json, PlondsJsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<UpdateManifest?> FetchDistributionManifestAsync(
|
||||||
|
string distributionId,
|
||||||
|
string targetVersion,
|
||||||
|
string channel,
|
||||||
|
string platform,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var url = $"{ApiBasePath}/distributions/{Uri.EscapeDataString(distributionId)}";
|
||||||
|
|
||||||
|
using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var errorBody = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
AppLogger.Warn("Update", $"PLONDS API distribution endpoint returned HTTP {(int)response.StatusCode}: {Truncate(errorBody, 256)}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
var dto = JsonSerializer.Deserialize<PlondsDistributionDto>(json, PlondsJsonOptions);
|
||||||
|
if (dto is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return MapDistribution(dto, channel, platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UpdateManifest MapDistribution(PlondsDistributionDto dto, string channel, string platform)
|
||||||
|
{
|
||||||
|
var files = new List<UpdateFileEntry>();
|
||||||
|
if (dto.Components is not null)
|
||||||
|
{
|
||||||
|
foreach (var component in dto.Components)
|
||||||
|
{
|
||||||
|
if (component.Files is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var f in component.Files)
|
||||||
|
{
|
||||||
|
files.Add(new UpdateFileEntry(
|
||||||
|
Path: f.Path ?? string.Empty,
|
||||||
|
Action: f.Op ?? "add",
|
||||||
|
Sha256: f.ContentHash ?? string.Empty,
|
||||||
|
Size: f.Size,
|
||||||
|
Mode: f.Mode ?? "file-object",
|
||||||
|
ObjectKey: f.ObjectKey,
|
||||||
|
ObjectUrl: null,
|
||||||
|
ArchiveSha256: null,
|
||||||
|
Metadata: null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var mirrors = dto.InstallerMirrors?.Select(m => new UpdateMirrorAsset(
|
||||||
|
Platform: m.Platform ?? platform,
|
||||||
|
Url: m.Url,
|
||||||
|
Name: m.FileName,
|
||||||
|
Sha256: m.Sha256,
|
||||||
|
Size: m.Size)).ToArray();
|
||||||
|
|
||||||
|
var fileMapSignatureUrl = dto.Signatures?.FirstOrDefault()?.Signature;
|
||||||
|
|
||||||
|
return new UpdateManifest(
|
||||||
|
DistributionId: dto.DistributionId ?? string.Empty,
|
||||||
|
FromVersion: dto.SourceVersion ?? string.Empty,
|
||||||
|
ToVersion: dto.Version ?? string.Empty,
|
||||||
|
Platform: platform,
|
||||||
|
Channel: channel,
|
||||||
|
PublishedAt: dto.PublishedAt,
|
||||||
|
Kind: UpdatePayloadKind.DeltaPlonds,
|
||||||
|
FileMapUrl: dto.FileMapUrl,
|
||||||
|
FileMapSignatureUrl: fileMapSignatureUrl,
|
||||||
|
FileMapSha256: null,
|
||||||
|
Files: files,
|
||||||
|
InstallerMirrors: mirrors,
|
||||||
|
Metadata: dto.Metadata as IReadOnlyDictionary<string, string> ?? new Dictionary<string, string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string value, int maxLength)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value[..maxLength];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions PlondsJsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
AllowTrailingCommas = true
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed record PlondsChannelPointerDto(
|
||||||
|
string? Channel,
|
||||||
|
string? Platform,
|
||||||
|
string? DistributionId,
|
||||||
|
string? Version,
|
||||||
|
DateTimeOffset PublishedAt);
|
||||||
|
|
||||||
|
private sealed record PlondsDistributionDto(
|
||||||
|
string? DistributionId,
|
||||||
|
string? Version,
|
||||||
|
string? SourceVersion,
|
||||||
|
string? Channel,
|
||||||
|
string? Platform,
|
||||||
|
DateTimeOffset PublishedAt,
|
||||||
|
string? FileMapUrl,
|
||||||
|
List<PlondsComponentDto>? Components,
|
||||||
|
List<PlondsMirrorDto>? InstallerMirrors,
|
||||||
|
List<PlondsSignatureDto>? Signatures,
|
||||||
|
Dictionary<string, string>? Metadata);
|
||||||
|
|
||||||
|
private sealed record PlondsComponentDto(
|
||||||
|
string? Id,
|
||||||
|
string? Root,
|
||||||
|
string? Mode,
|
||||||
|
List<PlondsFileDto>? Files);
|
||||||
|
|
||||||
|
private sealed record PlondsFileDto(
|
||||||
|
string? Path,
|
||||||
|
string? Op,
|
||||||
|
string? ContentHash,
|
||||||
|
long Size,
|
||||||
|
string? Mode,
|
||||||
|
string? ObjectKey);
|
||||||
|
|
||||||
|
private sealed record PlondsMirrorDto(
|
||||||
|
string? Platform,
|
||||||
|
string? Url,
|
||||||
|
string? FileName,
|
||||||
|
string? Sha256,
|
||||||
|
long Size);
|
||||||
|
|
||||||
|
private sealed record PlondsSignatureDto(
|
||||||
|
string? Algorithm,
|
||||||
|
string? KeyId,
|
||||||
|
string? Signature);
|
||||||
|
}
|
||||||
384
LanMountainDesktop/Services/Update/UpdateDownloadEngine.cs
Normal file
384
LanMountainDesktop/Services/Update/UpdateDownloadEngine.cs
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
|
public sealed record DownloadResult(bool Success, string? FilePath, string? ErrorMessage, bool HashVerified);
|
||||||
|
|
||||||
|
internal sealed class UpdateDownloadEngine
|
||||||
|
{
|
||||||
|
private readonly IUpdateManifestProvider _manifestProvider;
|
||||||
|
private readonly ResumableDownloadService _downloadService;
|
||||||
|
|
||||||
|
private const int MaxRetryAttempts = 3;
|
||||||
|
private const int RetryDelayMs = 1000;
|
||||||
|
|
||||||
|
public UpdateDownloadEngine(
|
||||||
|
IUpdateManifestProvider manifestProvider,
|
||||||
|
ResumableDownloadService downloadService)
|
||||||
|
{
|
||||||
|
_manifestProvider = manifestProvider ?? throw new ArgumentNullException(nameof(manifestProvider));
|
||||||
|
_downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DownloadResult> DownloadPayloadAsync(
|
||||||
|
UpdateManifest manifest,
|
||||||
|
string incomingDirectory,
|
||||||
|
string objectsDirectory,
|
||||||
|
int maxConcurrency,
|
||||||
|
IProgress<DownloadProgressReport>? progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(manifest);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(incomingDirectory);
|
||||||
|
Directory.CreateDirectory(objectsDirectory);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new DownloadResult(false, null, $"Failed to create download directories: {ex.Message}", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileMapPath = Path.Combine(incomingDirectory, UpdatePaths.GetPlondsFileMapName());
|
||||||
|
var signaturePath = Path.Combine(incomingDirectory, UpdatePaths.GetPlondsSignatureName());
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (manifest.FileMapUrl is not null)
|
||||||
|
{
|
||||||
|
await DownloadWithRetryAsync(manifest.FileMapUrl, fileMapPath, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest.FileMapSignatureUrl is not null)
|
||||||
|
{
|
||||||
|
await DownloadWithRetryAsync(manifest.FileMapSignatureUrl, signaturePath, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new DownloadResult(false, null, $"Failed to download file map: {ex.Message}", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var downloadableFiles = manifest.Files
|
||||||
|
.Where(f => f.Action is not ("reuse" or "delete") && !string.IsNullOrWhiteSpace(f.ObjectUrl))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var totalFiles = downloadableFiles.Count + 2;
|
||||||
|
var completedFiles = 2;
|
||||||
|
var seenHashes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var semaphore = new SemaphoreSlim(Math.Max(1, maxConcurrency), Math.Max(1, maxConcurrency));
|
||||||
|
var errors = new List<string>();
|
||||||
|
long totalBytes = downloadableFiles.Sum(f => f.Size);
|
||||||
|
long downloadedBytes = 0;
|
||||||
|
var lockObj = new object();
|
||||||
|
|
||||||
|
var tasks = downloadableFiles.Select(async entry =>
|
||||||
|
{
|
||||||
|
await semaphore.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!seenHashes.Add(entry.Sha256))
|
||||||
|
{
|
||||||
|
lock (lockObj)
|
||||||
|
{
|
||||||
|
completedFiles++;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReportProgress(progress, entry.Path, downloadedBytes, totalBytes, completedFiles, totalFiles);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var objectPath = GetObjectDestinationPath(objectsDirectory, entry.Sha256);
|
||||||
|
var objectDir = Path.GetDirectoryName(objectPath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(objectDir))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(objectDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(objectPath))
|
||||||
|
{
|
||||||
|
var existingHash = await ComputeFileSha256Async(objectPath, ct);
|
||||||
|
if (string.Equals(existingHash, entry.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
lock (lockObj)
|
||||||
|
{
|
||||||
|
completedFiles++;
|
||||||
|
downloadedBytes += entry.Size;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReportProgress(progress, entry.Path, downloadedBytes, totalBytes, completedFiles, totalFiles);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(entry.ObjectUrl))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var attempt = 1; attempt <= MaxRetryAttempts; attempt++)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var result = await _downloadService.DownloadAsync(
|
||||||
|
entry.ObjectUrl,
|
||||||
|
objectPath,
|
||||||
|
cancellationToken: ct);
|
||||||
|
|
||||||
|
if (result.Success)
|
||||||
|
{
|
||||||
|
var actualHash = await ComputeFileSha256Async(objectPath, ct);
|
||||||
|
var hashVerified = string.Equals(actualHash, entry.Sha256, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (!hashVerified)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UpdateDownloadEngine",
|
||||||
|
$"Object {entry.Path} hash mismatch after download. Expected: {entry.Sha256}, Actual: {actualHash}");
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (lockObj)
|
||||||
|
{
|
||||||
|
completedFiles++;
|
||||||
|
downloadedBytes += entry.Size;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReportProgress(progress, entry.Path, downloadedBytes, totalBytes, completedFiles, totalFiles);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < MaxRetryAttempts)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UpdateDownloadEngine",
|
||||||
|
$"Object {entry.Path} download attempt {attempt}/{MaxRetryAttempts} failed: {result.ErrorMessage}. Retrying.");
|
||||||
|
await Task.Delay(RetryDelayMs * attempt, ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
lock (lockObj)
|
||||||
|
{
|
||||||
|
errors.Add($"Failed to download {entry.Path}: {result.ErrorMessage}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
semaphore.Release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (AggregateException ae) when (ae.InnerExceptions.All(e => e is OperationCanceledException))
|
||||||
|
{
|
||||||
|
throw new OperationCanceledException(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.Count > 0)
|
||||||
|
{
|
||||||
|
return new DownloadResult(false, null, string.Join("; ", errors), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var markerPath = Path.Combine(incomingDirectory, ".download-complete");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var manifestSha256 = ComputeStringSha256(System.Text.Json.JsonSerializer.Serialize(manifest));
|
||||||
|
var markerContent = UpdatePaths.GetDownloadMarkerContent(manifestSha256, manifest.ToVersion, downloadableFiles.Count);
|
||||||
|
await File.WriteAllTextAsync(markerPath, markerContent, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UpdateDownloadEngine", $"Failed to write download marker: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Info("UpdateDownloadEngine", $"Delta payload downloaded to {incomingDirectory}. {downloadableFiles.Count} objects processed.");
|
||||||
|
return new DownloadResult(true, incomingDirectory, null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DownloadResult> DownloadFullInstallerAsync(
|
||||||
|
UpdateManifest manifest,
|
||||||
|
string destinationPath,
|
||||||
|
int maxThreads,
|
||||||
|
IProgress<DownloadProgressReport>? progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(manifest);
|
||||||
|
|
||||||
|
if (manifest.InstallerMirrors is null || manifest.InstallerMirrors.Count == 0)
|
||||||
|
{
|
||||||
|
return new DownloadResult(false, null, "No installer mirrors available.", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var mirror = manifest.InstallerMirrors.FirstOrDefault(m => !string.IsNullOrWhiteSpace(m.Url));
|
||||||
|
if (mirror is null || string.IsNullOrWhiteSpace(mirror.Url))
|
||||||
|
{
|
||||||
|
return new DownloadResult(false, null, "No usable installer mirror URL found.", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var dir = Path.GetDirectoryName(destinationPath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(dir))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(destinationPath) && !string.IsNullOrWhiteSpace(mirror.Sha256))
|
||||||
|
{
|
||||||
|
var existingHash = await ComputeFileSha256Async(destinationPath, ct);
|
||||||
|
if (string.Equals(existingHash, mirror.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
AppLogger.Info("UpdateDownloadEngine", "Full installer already downloaded with matching hash, skipping.");
|
||||||
|
return new DownloadResult(true, destinationPath, null, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var downloadProgress = progress is null ? null : new Progress<DownloadProgressInfo>(p =>
|
||||||
|
{
|
||||||
|
progress.Report(new DownloadProgressReport(
|
||||||
|
Path.GetFileName(destinationPath),
|
||||||
|
p.DownloadedBytes,
|
||||||
|
p.TotalBytes ?? 0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
p.Progress));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (var attempt = 1; attempt <= MaxRetryAttempts; attempt++)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var result = await _downloadService.DownloadAsync(
|
||||||
|
mirror.Url,
|
||||||
|
destinationPath,
|
||||||
|
new DownloadOptions(MaxParallelSegments: Math.Max(1, maxThreads)),
|
||||||
|
downloadProgress,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
if (result.Success)
|
||||||
|
{
|
||||||
|
bool hashVerified;
|
||||||
|
if (!string.IsNullOrWhiteSpace(mirror.Sha256))
|
||||||
|
{
|
||||||
|
var actualHash = await ComputeFileSha256Async(destinationPath, ct);
|
||||||
|
hashVerified = string.Equals(actualHash, mirror.Sha256, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (!hashVerified)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UpdateDownloadEngine",
|
||||||
|
$"Full installer hash mismatch. Expected: {mirror.Sha256}, Actual: {actualHash}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
hashVerified = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Info("UpdateDownloadEngine", $"Full installer downloaded to {destinationPath}");
|
||||||
|
return new DownloadResult(true, destinationPath, null, hashVerified);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < MaxRetryAttempts)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UpdateDownloadEngine",
|
||||||
|
$"Full installer download attempt {attempt}/{MaxRetryAttempts} failed: {result.ErrorMessage}. Retrying.");
|
||||||
|
await Task.Delay(RetryDelayMs * attempt, ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return new DownloadResult(false, null, $"Failed to download full installer after {MaxRetryAttempts} attempts: {result.ErrorMessage}", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DownloadResult(false, null, "Failed to download full installer.", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetObjectDestinationPath(string objectsDirectory, string objectHashHex)
|
||||||
|
{
|
||||||
|
var normalized = objectHashHex.Trim().ToLowerInvariant();
|
||||||
|
var shard = normalized.Length >= 2 ? normalized[..2] : normalized;
|
||||||
|
return Path.Combine(objectsDirectory, shard, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ReportProgress(
|
||||||
|
IProgress<DownloadProgressReport>? progress,
|
||||||
|
string currentFile,
|
||||||
|
long bytesDownloaded,
|
||||||
|
long bytesTotal,
|
||||||
|
int filesCompleted,
|
||||||
|
int filesTotal)
|
||||||
|
{
|
||||||
|
if (progress is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fraction = filesTotal > 0 ? (double)filesCompleted / filesTotal : 0;
|
||||||
|
progress.Report(new DownloadProgressReport(
|
||||||
|
currentFile,
|
||||||
|
bytesDownloaded,
|
||||||
|
bytesTotal,
|
||||||
|
0,
|
||||||
|
filesCompleted,
|
||||||
|
filesTotal,
|
||||||
|
fraction));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DownloadWithRetryAsync(string url, string destinationPath, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Exception? lastError = null;
|
||||||
|
for (var attempt = 1; attempt <= MaxRetryAttempts; attempt++)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var result = await _downloadService.DownloadAsync(url, destinationPath, cancellationToken: ct);
|
||||||
|
if (result.Success)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastError = new InvalidOperationException(result.ErrorMessage ?? "Download failed.");
|
||||||
|
|
||||||
|
if (attempt < MaxRetryAttempts)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UpdateDownloadEngine",
|
||||||
|
$"Download of {url} attempt {attempt}/{MaxRetryAttempts} failed. Retrying.");
|
||||||
|
await Task.Delay(RetryDelayMs * attempt, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ComputeFileSha256Async(string filePath, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 8192, true);
|
||||||
|
using var hasher = SHA256.Create();
|
||||||
|
var hash = await hasher.ComputeHashAsync(stream, ct);
|
||||||
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeStringSha256(string content)
|
||||||
|
{
|
||||||
|
using var hasher = SHA256.Create();
|
||||||
|
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
|
||||||
|
var hash = hasher.ComputeHash(bytes);
|
||||||
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
159
LanMountainDesktop/Services/Update/UpdateInstallGateway.cs
Normal file
159
LanMountainDesktop/Services/Update/UpdateInstallGateway.cs
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
|
public sealed record InstallResult(bool Success, string? ErrorMessage, bool UserCancelledElevation);
|
||||||
|
|
||||||
|
internal sealed class UpdateInstallGateway
|
||||||
|
{
|
||||||
|
public async Task<InstallResult> InstallAsync(
|
||||||
|
UpdatePayloadKind payloadKind,
|
||||||
|
string launcherRoot,
|
||||||
|
IProgress<InstallProgressReport>? progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
progress?.Report(new InstallProgressReport(
|
||||||
|
InstallStage.VerifySignature,
|
||||||
|
"Verifying payload...",
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
0));
|
||||||
|
|
||||||
|
if (payloadKind is UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy)
|
||||||
|
{
|
||||||
|
var launched = LaunchLauncherForApplyUpdate(launcherRoot);
|
||||||
|
if (!launched)
|
||||||
|
{
|
||||||
|
return new InstallResult(false, "Failed to launch Launcher for delta update application.", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
progress?.Report(new InstallProgressReport(
|
||||||
|
InstallStage.ActivateDeployment,
|
||||||
|
"Launcher launched for apply-update.",
|
||||||
|
100,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
0));
|
||||||
|
|
||||||
|
return new InstallResult(true, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var installerPath = FindPendingInstaller(launcherRoot);
|
||||||
|
if (installerPath is null)
|
||||||
|
{
|
||||||
|
return new InstallResult(false, "No pending installer found.", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var installerLaunched = LaunchFullInstaller(installerPath);
|
||||||
|
if (!installerLaunched.Success)
|
||||||
|
{
|
||||||
|
return installerLaunched;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress?.Report(new InstallProgressReport(
|
||||||
|
InstallStage.ActivateDeployment,
|
||||||
|
"Full installer launched.",
|
||||||
|
100,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
0));
|
||||||
|
|
||||||
|
return new InstallResult(true, null, false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UpdateInstallGateway", $"Install failed: {ex.Message}");
|
||||||
|
return new InstallResult(false, ex.Message, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool LaunchLauncherForApplyUpdate(string launcherRoot)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
||||||
|
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UpdateInstallGateway", "Launcher executable not found. Falling back to next-startup apply.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedLauncherRoot = Path.GetDirectoryName(launcherPath)!;
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = launcherPath,
|
||||||
|
Arguments = $"apply-update --app-root \"{resolvedLauncherRoot}\" --launch-source apply-update",
|
||||||
|
UseShellExecute = false,
|
||||||
|
WorkingDirectory = resolvedLauncherRoot
|
||||||
|
};
|
||||||
|
|
||||||
|
Process.Start(startInfo);
|
||||||
|
AppLogger.Info("UpdateInstallGateway", $"Launched Launcher for apply-update: {launcherPath}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UpdateInstallGateway", $"Failed to launch Launcher for apply-update: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private InstallResult LaunchFullInstaller(string installerPath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
AppLogger.Info("UpdateInstallGateway", "Launching full installer with elevation.");
|
||||||
|
var workingDir = Path.GetDirectoryName(installerPath) ?? Path.GetDirectoryName(installerPath)!;
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = installerPath,
|
||||||
|
WorkingDirectory = workingDir,
|
||||||
|
UseShellExecute = true,
|
||||||
|
Verb = OperatingSystem.IsWindows() ? "runas" : string.Empty,
|
||||||
|
Arguments = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART"
|
||||||
|
};
|
||||||
|
|
||||||
|
Process.Start(startInfo);
|
||||||
|
return new InstallResult(true, null, false);
|
||||||
|
}
|
||||||
|
catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
|
||||||
|
{
|
||||||
|
return new InstallResult(false, ex.Message, true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UpdateInstallGateway", $"Failed to launch full installer: {ex.Message}");
|
||||||
|
return new InstallResult(false, ex.Message, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FindPendingInstaller(string launcherRoot)
|
||||||
|
{
|
||||||
|
var incomingDir = UpdatePaths.GetIncomingDirectory(launcherRoot);
|
||||||
|
if (!Directory.Exists(incomingDir))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var executables = Directory.GetFiles(incomingDir, "*.exe");
|
||||||
|
return executables.Length > 0 ? executables[0] : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
LanMountainDesktop/Services/Update/UpdateJsonContext.cs
Normal file
15
LanMountainDesktop/Services/Update/UpdateJsonContext.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(
|
||||||
|
WriteIndented = false,
|
||||||
|
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true)]
|
||||||
|
[JsonSerializable(typeof(InstallProgressReport))]
|
||||||
|
[JsonSerializable(typeof(InstallCompleteReport))]
|
||||||
|
[JsonSerializable(typeof(InstallRequest))]
|
||||||
|
[JsonSerializable(typeof(LaunchResult))]
|
||||||
|
internal sealed partial class UpdateJsonContext : JsonSerializerContext;
|
||||||
220
LanMountainDesktop/Services/Update/UpdateManifestMapper.cs
Normal file
220
LanMountainDesktop/Services/Update/UpdateManifestMapper.cs
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
|
internal static class UpdateManifestMapper
|
||||||
|
{
|
||||||
|
public static UpdateManifest FromGitHubRelease(
|
||||||
|
GitHubReleaseInfo release,
|
||||||
|
PlondsUpdatePayload? plondsPayload,
|
||||||
|
string channel,
|
||||||
|
string platform)
|
||||||
|
{
|
||||||
|
if (plondsPayload is not null)
|
||||||
|
{
|
||||||
|
return FromPlondsPayload(plondsPayload, release, channel, platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
return FromFullInstaller(release, channel, platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static UpdateManifest FromPlondsPayload(
|
||||||
|
PlondsUpdatePayload payload,
|
||||||
|
GitHubReleaseInfo release,
|
||||||
|
string channel,
|
||||||
|
string platform)
|
||||||
|
{
|
||||||
|
var files = new List<UpdateFileEntry>();
|
||||||
|
|
||||||
|
if (payload.UpdateArchiveUrl is not null)
|
||||||
|
{
|
||||||
|
files.Add(new UpdateFileEntry(
|
||||||
|
Path: "update.zip",
|
||||||
|
Action: "add",
|
||||||
|
Sha256: payload.UpdateArchiveSha256 ?? string.Empty,
|
||||||
|
Size: payload.UpdateArchiveSizeBytes ?? 0,
|
||||||
|
Mode: "compressed-object",
|
||||||
|
ObjectKey: null,
|
||||||
|
ObjectUrl: payload.UpdateArchiveUrl,
|
||||||
|
ArchiveSha256: null,
|
||||||
|
Metadata: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
var mirrors = release.Assets
|
||||||
|
.Where(IsInstallerAsset)
|
||||||
|
.Select(a => new UpdateMirrorAsset(
|
||||||
|
Platform: platform,
|
||||||
|
Url: a.BrowserDownloadUrl,
|
||||||
|
Name: a.Name,
|
||||||
|
Sha256: a.Sha256,
|
||||||
|
Size: a.SizeBytes))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var metadata = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["source"] = "github-plonds",
|
||||||
|
["releaseTag"] = release.TagName
|
||||||
|
};
|
||||||
|
|
||||||
|
return new UpdateManifest(
|
||||||
|
DistributionId: payload.DistributionId,
|
||||||
|
FromVersion: string.Empty,
|
||||||
|
ToVersion: NormalizeTagVersion(release.TagName),
|
||||||
|
Platform: platform,
|
||||||
|
Channel: channel,
|
||||||
|
PublishedAt: release.PublishedAt,
|
||||||
|
Kind: UpdatePayloadKind.DeltaPlonds,
|
||||||
|
FileMapUrl: payload.FileMapJsonUrl,
|
||||||
|
FileMapSignatureUrl: payload.FileMapSignatureUrl,
|
||||||
|
FileMapSha256: null,
|
||||||
|
Files: files,
|
||||||
|
InstallerMirrors: mirrors,
|
||||||
|
Metadata: metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static UpdateManifest FromFullInstaller(
|
||||||
|
GitHubReleaseInfo release,
|
||||||
|
string channel,
|
||||||
|
string platform)
|
||||||
|
{
|
||||||
|
var installerAsset = SelectPreferredInstallerAsset(release.Assets);
|
||||||
|
|
||||||
|
var files = new List<UpdateFileEntry>();
|
||||||
|
var mirrors = new List<UpdateMirrorAsset>();
|
||||||
|
|
||||||
|
if (installerAsset is not null)
|
||||||
|
{
|
||||||
|
files.Add(new UpdateFileEntry(
|
||||||
|
Path: installerAsset.Name,
|
||||||
|
Action: "add",
|
||||||
|
Sha256: installerAsset.Sha256 ?? string.Empty,
|
||||||
|
Size: installerAsset.SizeBytes,
|
||||||
|
Mode: "file-object",
|
||||||
|
ObjectKey: null,
|
||||||
|
ObjectUrl: installerAsset.BrowserDownloadUrl,
|
||||||
|
ArchiveSha256: null,
|
||||||
|
Metadata: null));
|
||||||
|
|
||||||
|
foreach (var asset in release.Assets)
|
||||||
|
{
|
||||||
|
if (IsInstallerAsset(asset) && asset != installerAsset)
|
||||||
|
{
|
||||||
|
mirrors.Add(new UpdateMirrorAsset(
|
||||||
|
Platform: platform,
|
||||||
|
Url: asset.BrowserDownloadUrl,
|
||||||
|
Name: asset.Name,
|
||||||
|
Sha256: asset.Sha256,
|
||||||
|
Size: asset.SizeBytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var distributionId = $"github-{release.TagName.Trim().TrimStart('v')}-{platform}";
|
||||||
|
|
||||||
|
var metadata = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["source"] = "github-release",
|
||||||
|
["releaseTag"] = release.TagName
|
||||||
|
};
|
||||||
|
|
||||||
|
return new UpdateManifest(
|
||||||
|
DistributionId: distributionId,
|
||||||
|
FromVersion: string.Empty,
|
||||||
|
ToVersion: NormalizeTagVersion(release.TagName),
|
||||||
|
Platform: platform,
|
||||||
|
Channel: channel,
|
||||||
|
PublishedAt: release.PublishedAt,
|
||||||
|
Kind: UpdatePayloadKind.FullInstaller,
|
||||||
|
FileMapUrl: null,
|
||||||
|
FileMapSignatureUrl: null,
|
||||||
|
FileMapSha256: null,
|
||||||
|
Files: files,
|
||||||
|
InstallerMirrors: mirrors,
|
||||||
|
Metadata: metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeTagVersion(string tagName)
|
||||||
|
{
|
||||||
|
var v = tagName.Trim();
|
||||||
|
if (v.StartsWith("v", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
v = v[1..];
|
||||||
|
}
|
||||||
|
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsInstallerAsset(GitHubReleaseAsset asset)
|
||||||
|
{
|
||||||
|
var name = asset.Name;
|
||||||
|
return name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| name.EndsWith(".msi", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| name.EndsWith(".dmg", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| name.EndsWith(".deb", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| name.EndsWith(".rpm", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| name.EndsWith(".AppImage", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
|
||||||
|
{
|
||||||
|
if (assets is null || assets.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var architectureToken = RuntimeInformation.OSArchitecture switch
|
||||||
|
{
|
||||||
|
Architecture.Arm64 => "arm64",
|
||||||
|
Architecture.X86 => "x86",
|
||||||
|
_ => "x64"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return assets
|
||||||
|
.Where(a => a.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| a.Name.EndsWith(".msi", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderByDescending(a => ScoreAsset(a.Name, architectureToken))
|
||||||
|
.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
return assets
|
||||||
|
.Where(a => a.Name.EndsWith(".deb", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| a.Name.EndsWith(".rpm", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| a.Name.EndsWith(".AppImage", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderByDescending(a => ScoreAsset(a.Name, architectureToken))
|
||||||
|
.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OperatingSystem.IsMacOS())
|
||||||
|
{
|
||||||
|
return assets
|
||||||
|
.Where(a => a.Name.EndsWith(".dmg", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| a.Name.EndsWith(".pkg", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderByDescending(a => ScoreAsset(a.Name, architectureToken))
|
||||||
|
.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ScoreAsset(string name, string archToken)
|
||||||
|
{
|
||||||
|
var score = 0;
|
||||||
|
if (name.Contains(archToken, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
score += 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.Contains("setup", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| name.Contains("installer", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
score += 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
}
|
||||||
483
LanMountainDesktop/Services/Update/UpdateOrchestrator.cs
Normal file
483
LanMountainDesktop/Services/Update/UpdateOrchestrator.cs
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
using SettingsUpdateSettingsState = LanMountainDesktop.Services.Settings.UpdateSettingsState;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
|
public sealed class UpdateOrchestrator : IDisposable
|
||||||
|
{
|
||||||
|
private readonly IUpdateManifestProvider _manifestProvider;
|
||||||
|
private readonly UpdateDownloadEngine _downloadEngine;
|
||||||
|
private readonly UpdateInstallGateway _installGateway;
|
||||||
|
private readonly UpdateStateStore _stateStore;
|
||||||
|
private readonly SemaphoreSlim _operationGate = new(1, 1);
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
internal UpdateOrchestrator(
|
||||||
|
IUpdateManifestProvider manifestProvider,
|
||||||
|
UpdateDownloadEngine downloadEngine,
|
||||||
|
UpdateInstallGateway installGateway,
|
||||||
|
UpdateStateStore stateStore)
|
||||||
|
{
|
||||||
|
_manifestProvider = manifestProvider ?? throw new ArgumentNullException(nameof(manifestProvider));
|
||||||
|
_downloadEngine = downloadEngine ?? throw new ArgumentNullException(nameof(downloadEngine));
|
||||||
|
_installGateway = installGateway ?? throw new ArgumentNullException(nameof(installGateway));
|
||||||
|
_stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
|
||||||
|
|
||||||
|
_stateStore.PhaseChanged += OnPhaseChanged;
|
||||||
|
_stateStore.ProgressChanged += OnProgressChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UpdatePhase CurrentPhase => _stateStore.CurrentPhase;
|
||||||
|
|
||||||
|
public UpdateManifest? CurrentManifest => _stateStore.PendingManifest;
|
||||||
|
|
||||||
|
public event Action<UpdatePhase>? PhaseChanged;
|
||||||
|
public event Action<UpdateProgressReport>? ProgressChanged;
|
||||||
|
|
||||||
|
public async Task<UpdateCheckReport> CheckAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _operationGate.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!CurrentPhase.CanCheck())
|
||||||
|
{
|
||||||
|
return new UpdateCheckReport(
|
||||||
|
false, null, null, null, null, null, null, null, null,
|
||||||
|
$"Cannot check in phase {CurrentPhase}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_stateStore.TransitionTo(UpdatePhase.Checking);
|
||||||
|
|
||||||
|
var settings = _stateStore.GetSettings();
|
||||||
|
var channel = UpdateSettingsValues.NormalizeChannel(settings.UpdateChannel);
|
||||||
|
var currentVersionText = _stateStore.GetSettings().PendingUpdateVersion
|
||||||
|
?? AppVersionProvider.ResolveForCurrentProcess().Version;
|
||||||
|
|
||||||
|
if (!Version.TryParse(currentVersionText, out var currentVersion))
|
||||||
|
{
|
||||||
|
currentVersion = new Version(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateManifest? manifest;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
manifest = await _manifestProvider.GetLatestAsync(
|
||||||
|
channel,
|
||||||
|
"win-x64",
|
||||||
|
currentVersion,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_stateStore.TransitionTo(UpdatePhase.Idle);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_stateStore.TransitionTo(UpdatePhase.Failed);
|
||||||
|
_stateStore.RecordFailure(ex.Message);
|
||||||
|
return new UpdateCheckReport(false, null, currentVersionText, null, null, null, null, null, null, ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest is null)
|
||||||
|
{
|
||||||
|
_stateStore.TransitionTo(UpdatePhase.Checked);
|
||||||
|
return new UpdateCheckReport(
|
||||||
|
false, null, currentVersionText, null, null, null, null, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
_stateStore.PendingManifest = manifest;
|
||||||
|
_stateStore.TransitionTo(UpdatePhase.Checked);
|
||||||
|
|
||||||
|
long? totalBytes = manifest.IsDelta ? manifest.EstimatedDeltaBytes : null;
|
||||||
|
long? installerBytes = manifest.InstallerMirrors?.Count > 0
|
||||||
|
? manifest.InstallerMirrors[0].Size
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return new UpdateCheckReport(
|
||||||
|
true,
|
||||||
|
manifest.ToVersion,
|
||||||
|
currentVersionText,
|
||||||
|
manifest.Kind,
|
||||||
|
manifest.DistributionId,
|
||||||
|
manifest.Channel,
|
||||||
|
manifest.PublishedAt,
|
||||||
|
totalBytes,
|
||||||
|
installerBytes,
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_operationGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DownloadResult> DownloadAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _operationGate.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!CurrentPhase.CanDownload())
|
||||||
|
{
|
||||||
|
return new DownloadResult(false, null, $"Cannot download in phase {CurrentPhase}.", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest = _stateStore.PendingManifest;
|
||||||
|
if (manifest is null)
|
||||||
|
{
|
||||||
|
return new DownloadResult(false, null, "No manifest available for download.", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_stateStore.TransitionTo(UpdatePhase.Downloading);
|
||||||
|
|
||||||
|
var settings = _stateStore.GetSettings();
|
||||||
|
var maxThreads = UpdateSettingsValues.NormalizeDownloadThreads(settings.UpdateDownloadThreads);
|
||||||
|
var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory);
|
||||||
|
|
||||||
|
var downloadProgress = new Progress<DownloadProgressReport>(p =>
|
||||||
|
{
|
||||||
|
var overallFraction = manifest.IsDelta
|
||||||
|
? (double)p.FilesCompleted / Math.Max(1, p.FilesTotal)
|
||||||
|
: p.OverallFraction;
|
||||||
|
|
||||||
|
ProgressChanged?.Invoke(new UpdateProgressReport(
|
||||||
|
UpdatePhase.Downloading,
|
||||||
|
$"Downloading {p.CurrentFile}",
|
||||||
|
overallFraction,
|
||||||
|
p,
|
||||||
|
null));
|
||||||
|
});
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DownloadResult result;
|
||||||
|
|
||||||
|
if (manifest.IsDelta)
|
||||||
|
{
|
||||||
|
var incomingDir = UpdatePaths.GetIncomingDirectory(launcherRoot);
|
||||||
|
var objectsDir = UpdatePaths.GetObjectsDirectory(launcherRoot);
|
||||||
|
result = await _downloadEngine.DownloadPayloadAsync(
|
||||||
|
manifest,
|
||||||
|
incomingDir,
|
||||||
|
objectsDir,
|
||||||
|
maxThreads,
|
||||||
|
downloadProgress,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var fileName = $"{manifest.DistributionId}-{manifest.ToVersion}-installer.exe";
|
||||||
|
var destinationPath = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"LanMountainDesktop",
|
||||||
|
"Updates",
|
||||||
|
fileName);
|
||||||
|
result = await _downloadEngine.DownloadFullInstallerAsync(
|
||||||
|
manifest,
|
||||||
|
destinationPath,
|
||||||
|
maxThreads,
|
||||||
|
downloadProgress,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.Success)
|
||||||
|
{
|
||||||
|
_stateStore.TransitionTo(UpdatePhase.Downloaded);
|
||||||
|
|
||||||
|
var state = _stateStore.GetSettings();
|
||||||
|
_stateStore.SaveSettings(state with
|
||||||
|
{
|
||||||
|
PendingUpdateInstallerPath = result.FilePath,
|
||||||
|
PendingUpdateVersion = manifest.ToVersion,
|
||||||
|
PendingUpdatePublishedAtUtcMs = manifest.PublishedAt.ToUnixTimeMilliseconds(),
|
||||||
|
PendingUpdateSha256 = null
|
||||||
|
});
|
||||||
|
|
||||||
|
AppLogger.Info("UpdateOrchestrator", $"Update downloaded successfully: {manifest.ToVersion}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_stateStore.TransitionTo(UpdatePhase.Failed);
|
||||||
|
_stateStore.RecordFailure(result.ErrorMessage ?? "Download failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_stateStore.TransitionTo(UpdatePhase.Idle);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_stateStore.TransitionTo(UpdatePhase.Failed);
|
||||||
|
_stateStore.RecordFailure(ex.Message);
|
||||||
|
return new DownloadResult(false, null, ex.Message, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_operationGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<InstallResult> InstallAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _operationGate.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!CurrentPhase.CanInstall())
|
||||||
|
{
|
||||||
|
return new InstallResult(false, $"Cannot install in phase {CurrentPhase}.", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest = _stateStore.PendingManifest;
|
||||||
|
if (manifest is null)
|
||||||
|
{
|
||||||
|
return new InstallResult(false, "No manifest available for install.", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_stateStore.TransitionTo(UpdatePhase.Installing);
|
||||||
|
|
||||||
|
var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory);
|
||||||
|
|
||||||
|
var installProgress = new Progress<InstallProgressReport>(p =>
|
||||||
|
{
|
||||||
|
var fraction = p.FilesTotal > 0 ? (double)p.FilesCompleted / p.FilesTotal : p.ProgressPercent / 100.0;
|
||||||
|
ProgressChanged?.Invoke(new UpdateProgressReport(
|
||||||
|
UpdatePhase.Installing,
|
||||||
|
p.Message,
|
||||||
|
fraction,
|
||||||
|
null,
|
||||||
|
p));
|
||||||
|
});
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _installGateway.InstallAsync(
|
||||||
|
manifest.Kind,
|
||||||
|
launcherRoot,
|
||||||
|
installProgress,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
if (result.Success)
|
||||||
|
{
|
||||||
|
_stateStore.TransitionTo(UpdatePhase.Installed);
|
||||||
|
_stateStore.RecordSuccess(manifest.ToVersion);
|
||||||
|
AppLogger.Info("UpdateOrchestrator", $"Update install initiated: {manifest.ToVersion}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_stateStore.TransitionTo(UpdatePhase.Failed);
|
||||||
|
_stateStore.RecordFailure(result.ErrorMessage ?? "Install failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_stateStore.TransitionTo(UpdatePhase.Failed);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_stateStore.TransitionTo(UpdatePhase.Failed);
|
||||||
|
_stateStore.RecordFailure(ex.Message);
|
||||||
|
return new InstallResult(false, ex.Message, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_operationGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RollbackAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _operationGate.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!CurrentPhase.CanRollback())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_stateStore.TransitionTo(UpdatePhase.RollingBack);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
||||||
|
if (!string.IsNullOrWhiteSpace(launcherPath) && File.Exists(launcherPath))
|
||||||
|
{
|
||||||
|
var launcherRoot = Path.GetDirectoryName(launcherPath)!;
|
||||||
|
var startInfo = new System.Diagnostics.ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = launcherPath,
|
||||||
|
Arguments = $"rollback --app-root \"{launcherRoot}\"",
|
||||||
|
UseShellExecute = false,
|
||||||
|
WorkingDirectory = launcherRoot
|
||||||
|
};
|
||||||
|
|
||||||
|
System.Diagnostics.Process.Start(startInfo);
|
||||||
|
AppLogger.Info("UpdateOrchestrator", "Launched Launcher for rollback.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_stateStore.TransitionTo(UpdatePhase.RolledBack);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UpdateOrchestrator", $"Rollback failed: {ex.Message}");
|
||||||
|
_stateStore.TransitionTo(UpdatePhase.Failed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_operationGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CancelAsync()
|
||||||
|
{
|
||||||
|
if (!CurrentPhase.IsBusy())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_stateStore.TransitionTo(UpdatePhase.Idle);
|
||||||
|
_stateStore.PendingManifest = null;
|
||||||
|
AppLogger.Info("UpdateOrchestrator", "Update operation cancelled.");
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AutoCheckIfEnabledAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var settings = _stateStore.GetSettings();
|
||||||
|
var mode = UpdateSettingsValues.NormalizeMode(settings.UpdateMode);
|
||||||
|
|
||||||
|
if (string.Equals(mode, UpdateSettingsValues.ModeManual, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CheckAsync(ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UpdateOrchestrator", "Automatic update check failed.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryApplyOnExit()
|
||||||
|
{
|
||||||
|
var settings = _stateStore.GetSettings();
|
||||||
|
var mode = UpdateSettingsValues.NormalizeMode(settings.UpdateMode);
|
||||||
|
|
||||||
|
if (!string.Equals(mode, UpdateSettingsValues.ModeSilentOnExit, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest = _stateStore.PendingManifest;
|
||||||
|
if (manifest is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var launcherRoot = UpdatePaths.ResolveLauncherRoot(AppContext.BaseDirectory);
|
||||||
|
|
||||||
|
if (manifest.IsDelta)
|
||||||
|
{
|
||||||
|
AppLogger.Info("UpdateOrchestrator", "Delta update pending. Launching Launcher to apply on exit.");
|
||||||
|
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
||||||
|
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resolvedRoot = Path.GetDirectoryName(launcherPath)!;
|
||||||
|
var startInfo = new System.Diagnostics.ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = launcherPath,
|
||||||
|
Arguments = $"apply-update --app-root \"{resolvedRoot}\" --launch-source apply-update",
|
||||||
|
UseShellExecute = false,
|
||||||
|
WorkingDirectory = resolvedRoot
|
||||||
|
};
|
||||||
|
|
||||||
|
System.Diagnostics.Process.Start(startInfo);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UpdateOrchestrator", $"Failed to launch Launcher on exit: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var installerPath = settings.PendingUpdateInstallerPath?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(installerPath) || !File.Exists(installerPath))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var startInfo = new System.Diagnostics.ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = installerPath,
|
||||||
|
WorkingDirectory = Path.GetDirectoryName(installerPath)!,
|
||||||
|
UseShellExecute = true,
|
||||||
|
Verb = System.OperatingSystem.IsWindows() ? "runas" : string.Empty,
|
||||||
|
Arguments = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART"
|
||||||
|
};
|
||||||
|
|
||||||
|
System.Diagnostics.Process.Start(startInfo);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 1223)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UpdateOrchestrator", $"Failed to launch installer on exit: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPhaseChanged(UpdatePhase phase)
|
||||||
|
{
|
||||||
|
PhaseChanged?.Invoke(phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnProgressChanged(UpdateProgressReport report)
|
||||||
|
{
|
||||||
|
ProgressChanged?.Invoke(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
_stateStore.PhaseChanged -= OnPhaseChanged;
|
||||||
|
_stateStore.ProgressChanged -= OnProgressChanged;
|
||||||
|
_operationGate.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
105
LanMountainDesktop/Services/Update/UpdateProgressSubject.cs
Normal file
105
LanMountainDesktop/Services/Update/UpdateProgressSubject.cs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
using LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
|
internal sealed class UpdateProgressSubject : IObservable<InstallProgressReport>, IObserver<InstallProgressReport>
|
||||||
|
{
|
||||||
|
private readonly List<IObserver<InstallProgressReport>> _observers = [];
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private bool _completed;
|
||||||
|
|
||||||
|
public IDisposable Subscribe(IObserver<InstallProgressReport> observer)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_completed)
|
||||||
|
{
|
||||||
|
observer.OnCompleted();
|
||||||
|
return EmptyDisposable.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
_observers.Add(observer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Subscription(this, observer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnNext(InstallProgressReport value)
|
||||||
|
{
|
||||||
|
IObserver<InstallProgressReport>[] snapshot;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
snapshot = _observers.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var observer in snapshot)
|
||||||
|
{
|
||||||
|
observer.OnNext(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnError(Exception error)
|
||||||
|
{
|
||||||
|
IObserver<InstallProgressReport>[] snapshot;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_completed = true;
|
||||||
|
snapshot = _observers.ToArray();
|
||||||
|
_observers.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var observer in snapshot)
|
||||||
|
{
|
||||||
|
observer.OnError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnCompleted()
|
||||||
|
{
|
||||||
|
IObserver<InstallProgressReport>[] snapshot;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_completed = true;
|
||||||
|
snapshot = _observers.ToArray();
|
||||||
|
_observers.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var observer in snapshot)
|
||||||
|
{
|
||||||
|
observer.OnCompleted();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Subscription : IDisposable
|
||||||
|
{
|
||||||
|
private readonly UpdateProgressSubject _subject;
|
||||||
|
private IObserver<InstallProgressReport>? _observer;
|
||||||
|
|
||||||
|
public Subscription(UpdateProgressSubject subject, IObserver<InstallProgressReport> observer)
|
||||||
|
{
|
||||||
|
_subject = subject;
|
||||||
|
_observer = observer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_observer is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_subject._gate)
|
||||||
|
{
|
||||||
|
_subject._observers.Remove(_observer);
|
||||||
|
}
|
||||||
|
|
||||||
|
_observer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class EmptyDisposable : IDisposable
|
||||||
|
{
|
||||||
|
public static readonly EmptyDisposable Instance = new();
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
79
LanMountainDesktop/Services/Update/UpdateStateStore.cs
Normal file
79
LanMountainDesktop/Services/Update/UpdateStateStore.cs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
using System;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
using SettingsUpdateSettingsState = LanMountainDesktop.Services.Settings.UpdateSettingsState;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
|
internal sealed class UpdateStateStore
|
||||||
|
{
|
||||||
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
|
private readonly object _sync = new();
|
||||||
|
|
||||||
|
private const int AutoDowngradeThreshold = 3;
|
||||||
|
private int _consecutiveFailCount;
|
||||||
|
|
||||||
|
public UpdateStateStore(ISettingsFacadeService settingsFacade)
|
||||||
|
{
|
||||||
|
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||||
|
CurrentPhase = UpdatePhase.Idle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UpdatePhase CurrentPhase { get; private set; }
|
||||||
|
|
||||||
|
public event Action<UpdatePhase>? PhaseChanged;
|
||||||
|
public event Action<UpdateProgressReport>? ProgressChanged;
|
||||||
|
|
||||||
|
public void TransitionTo(UpdatePhase newPhase)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (CurrentPhase == newPhase)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CurrentPhase = newPhase;
|
||||||
|
}
|
||||||
|
|
||||||
|
PhaseChanged?.Invoke(newPhase);
|
||||||
|
ProgressChanged?.Invoke(new UpdateProgressReport(
|
||||||
|
newPhase,
|
||||||
|
$"Phase changed to {newPhase}",
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public SettingsUpdateSettingsState GetSettings()
|
||||||
|
{
|
||||||
|
return _settingsFacade.Update.Get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SaveSettings(SettingsUpdateSettingsState state)
|
||||||
|
{
|
||||||
|
_settingsFacade.Update.Save(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UpdateManifest? PendingManifest { get; set; }
|
||||||
|
|
||||||
|
public void RecordFailure(string errorMessage)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _consecutiveFailCount);
|
||||||
|
AppLogger.Warn("UpdateStateStore", $"Update failure recorded (consecutive: {_consecutiveFailCount}): {errorMessage}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordSuccess(string appliedVersion)
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _consecutiveFailCount, 0);
|
||||||
|
|
||||||
|
var state = GetSettings();
|
||||||
|
SaveSettings(state with
|
||||||
|
{
|
||||||
|
PendingUpdateVersion = appliedVersion,
|
||||||
|
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ShouldAutoDowngrade => Volatile.Read(ref _consecutiveFailCount) >= AutoDowngradeThreshold;
|
||||||
|
}
|
||||||
@@ -12,11 +12,12 @@ public static class UpdateSettingsValues
|
|||||||
public const string ModeSilentOnExit = "silent_on_exit";
|
public const string ModeSilentOnExit = "silent_on_exit";
|
||||||
|
|
||||||
// NOTE: keep constant name for compatibility with existing call sites.
|
// NOTE: keep constant name for compatibility with existing call sites.
|
||||||
public const string DownloadSourcePlonds = "stcn";
|
public const string DownloadSourcePlonds = "plonds-api";
|
||||||
public const string DownloadSourcePdc = DownloadSourcePlonds;
|
public const string DownloadSourcePdc = DownloadSourcePlonds;
|
||||||
public const string DownloadSourceStcn = DownloadSourcePlonds;
|
public const string DownloadSourceStcn = DownloadSourcePlonds;
|
||||||
public const string LegacyDownloadSourcePlonds = "pdc";
|
public const string LegacyDownloadSourcePlonds = "pdc";
|
||||||
public const string LegacyDownloadSourcePdc = LegacyDownloadSourcePlonds;
|
public const string LegacyDownloadSourcePdc = LegacyDownloadSourcePlonds;
|
||||||
|
public const string LegacyDownloadSourceStcn = "stcn";
|
||||||
public const string DownloadSourceGitHub = "github";
|
public const string DownloadSourceGitHub = "github";
|
||||||
public const string DownloadSourceGhProxy = "gh-proxy";
|
public const string DownloadSourceGhProxy = "gh-proxy";
|
||||||
|
|
||||||
@@ -59,7 +60,12 @@ public static class UpdateSettingsValues
|
|||||||
{
|
{
|
||||||
if (string.Equals(value, LegacyDownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(value, LegacyDownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return DownloadSourceStcn;
|
return DownloadSourcePlonds;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(value, LegacyDownloadSourceStcn, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return DownloadSourcePlonds;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(value, DownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(value, DownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -77,8 +83,7 @@ public static class UpdateSettingsValues
|
|||||||
return DownloadSourceGitHub;
|
return DownloadSourceGitHub;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to STCN(PLONDS/S3). Runtime will fallback to GitHub if STCN is unavailable.
|
return DownloadSourcePlonds;
|
||||||
return DownloadSourceStcn;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static int NormalizeDownloadThreads(int value)
|
public static int NormalizeDownloadThreads(int value)
|
||||||
|
|||||||
@@ -171,7 +171,9 @@ public sealed class UpdateWorkflowService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var state = _settingsFacade.Update.Get();
|
var state = _settingsFacade.Update.Get();
|
||||||
var downloadSource = state.UpdateDownloadSource;
|
var downloadSource = state.UseGhProxyMirror
|
||||||
|
? UpdateSettingsValues.DownloadSourceGhProxy
|
||||||
|
: UpdateSettingsValues.DownloadSourceGitHub;
|
||||||
var downloadThreads = state.UpdateDownloadThreads;
|
var downloadThreads = state.UpdateDownloadThreads;
|
||||||
|
|
||||||
var requiredAssets = new List<(GitHubReleaseAsset Asset, string DestinationFileName)>
|
var requiredAssets = new List<(GitHubReleaseAsset Asset, string DestinationFileName)>
|
||||||
@@ -312,7 +314,9 @@ public sealed class UpdateWorkflowService
|
|||||||
payload,
|
payload,
|
||||||
incomingDir,
|
incomingDir,
|
||||||
objectsDir,
|
objectsDir,
|
||||||
state.UpdateDownloadSource,
|
state.UseGhProxyMirror
|
||||||
|
? UpdateSettingsValues.DownloadSourceGhProxy
|
||||||
|
: UpdateSettingsValues.DownloadSourceGitHub,
|
||||||
downloadThreads,
|
downloadThreads,
|
||||||
progress,
|
progress,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
@@ -502,7 +506,9 @@ public sealed class UpdateWorkflowService
|
|||||||
var result = await _settingsFacade.Update.DownloadAssetAsync(
|
var result = await _settingsFacade.Update.DownloadAssetAsync(
|
||||||
checkResult.PreferredAsset,
|
checkResult.PreferredAsset,
|
||||||
destinationPath,
|
destinationPath,
|
||||||
state.UpdateDownloadSource,
|
state.UseGhProxyMirror
|
||||||
|
? UpdateSettingsValues.DownloadSourceGhProxy
|
||||||
|
: UpdateSettingsValues.DownloadSourceGitHub,
|
||||||
state.UpdateDownloadThreads,
|
state.UpdateDownloadThreads,
|
||||||
progress,
|
progress,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
@@ -1431,26 +1437,15 @@ public sealed class UpdateWorkflowService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var launcherExeName = OperatingSystem.IsWindows()
|
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
||||||
? "LanMountainDesktop.Launcher.exe"
|
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
|
||||||
: "LanMountainDesktop.Launcher";
|
|
||||||
|
|
||||||
// The Launcher is in the parent directory of the app's base directory
|
|
||||||
// (app runs from app-{version}/ subdirectory, Launcher is at root)
|
|
||||||
var appBaseDir = AppContext.BaseDirectory;
|
|
||||||
var launcherRoot = Path.GetDirectoryName(appBaseDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
|
||||||
if (string.IsNullOrWhiteSpace(launcherRoot))
|
|
||||||
{
|
{
|
||||||
launcherRoot = appBaseDir;
|
AppLogger.Warn("UpdateWorkflow", "Launcher executable not found. Falling back to next-startup apply.");
|
||||||
}
|
|
||||||
|
|
||||||
var launcherPath = Path.Combine(launcherRoot, launcherExeName);
|
|
||||||
if (!File.Exists(launcherPath))
|
|
||||||
{
|
|
||||||
AppLogger.Warn("UpdateWorkflow", $"Launcher executable not found at '{launcherPath}'. Falling back to next-startup apply.");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var launcherRoot = Path.GetDirectoryName(launcherPath)!;
|
||||||
|
|
||||||
var startInfo = new ProcessStartInfo
|
var startInfo = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = launcherPath,
|
FileName = launcherPath,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Collections.ObjectModel;
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
@@ -1609,8 +1610,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
|
|
||||||
public IReadOnlyList<SelectionOption> UpdateChannelOptions { get; }
|
public IReadOnlyList<SelectionOption> UpdateChannelOptions { get; }
|
||||||
|
|
||||||
public IReadOnlyList<SelectionOption> UpdateSourceOptions { get; }
|
|
||||||
|
|
||||||
public IReadOnlyList<SelectionOption> UpdateModeOptions { get; }
|
public IReadOnlyList<SelectionOption> UpdateModeOptions { get; }
|
||||||
|
|
||||||
public IReadOnlyList<SelectionOption> DownloadThreadOptions { get; }
|
public IReadOnlyList<SelectionOption> DownloadThreadOptions { get; }
|
||||||
@@ -1624,7 +1623,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
|
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
|
||||||
RefreshLocalizedText();
|
RefreshLocalizedText();
|
||||||
UpdateChannelOptions = CreateUpdateChannelOptions();
|
UpdateChannelOptions = CreateUpdateChannelOptions();
|
||||||
UpdateSourceOptions = CreateUpdateSourceOptions();
|
|
||||||
UpdateModeOptions = CreateUpdateModeOptions();
|
UpdateModeOptions = CreateUpdateModeOptions();
|
||||||
DownloadThreadOptions = CreateDownloadThreadOptions();
|
DownloadThreadOptions = CreateDownloadThreadOptions();
|
||||||
|
|
||||||
@@ -1640,9 +1638,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
|
private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _selectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm;
|
private string _selectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm;
|
||||||
|
|
||||||
@@ -1667,6 +1662,18 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _downloadProgressText = string.Empty;
|
private string _downloadProgressText = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _updatePhaseText = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private double _phaseProgressValue;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _updateTypeText = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _useGhProxyMirror;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _pageTitle = string.Empty;
|
private string _pageTitle = string.Empty;
|
||||||
|
|
||||||
@@ -1688,9 +1695,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _updateChannelLabel = string.Empty;
|
private string _updateChannelLabel = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string _updateSourceLabel = string.Empty;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _updateModeLabel = string.Empty;
|
private string _updateModeLabel = string.Empty;
|
||||||
|
|
||||||
@@ -1754,9 +1758,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _selectedUpdateModeDescription = string.Empty;
|
private string _selectedUpdateModeDescription = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string _selectedUpdateSourceDescription = string.Empty;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _downloadThreadsLabel = string.Empty;
|
private string _downloadThreadsLabel = string.Empty;
|
||||||
|
|
||||||
@@ -1769,21 +1770,24 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _forceCheckUpdateDescription = string.Empty;
|
private string _forceCheckUpdateDescription = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _forceFullUpdateLabel = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _forceFullUpdateDescription = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _networkAccelerationLabel = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _networkAccelerationDescription = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _stableChannelText = string.Empty;
|
private string _stableChannelText = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _previewChannelText = string.Empty;
|
private string _previewChannelText = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string _pdcSourceText = string.Empty;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string _gitHubSourceText = string.Empty;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string _ghProxySourceText = string.Empty;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _manualModeText = string.Empty;
|
private string _manualModeText = string.Empty;
|
||||||
|
|
||||||
@@ -1796,9 +1800,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private SelectionOption? _selectedUpdateChannelOption;
|
private SelectionOption? _selectedUpdateChannelOption;
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private SelectionOption? _selectedUpdateSourceOption;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private SelectionOption? _selectedUpdateModeOption;
|
private SelectionOption? _selectedUpdateModeOption;
|
||||||
|
|
||||||
@@ -1814,15 +1815,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
public bool IsPreviewChannelSelected =>
|
public bool IsPreviewChannelSelected =>
|
||||||
string.Equals(SelectedUpdateChannelValue, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase);
|
string.Equals(SelectedUpdateChannelValue, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public bool IsPdcSourceSelected =>
|
|
||||||
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourcePdc, StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
public bool IsGitHubSourceSelected =>
|
|
||||||
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
public bool IsGhProxySourceSelected =>
|
|
||||||
string.Equals(SelectedUpdateSourceValue, UpdateSettingsValues.DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
public bool IsManualModeSelected =>
|
public bool IsManualModeSelected =>
|
||||||
string.Equals(SelectedUpdateModeValue, UpdateSettingsValues.ModeManual, StringComparison.OrdinalIgnoreCase);
|
string.Equals(SelectedUpdateModeValue, UpdateSettingsValues.ModeManual, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
@@ -1840,6 +1832,8 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
|
|
||||||
public bool IsRedownloadButtonVisible => HasPendingInstaller && !IsDownloading;
|
public bool IsRedownloadButtonVisible => HasPendingInstaller && !IsDownloading;
|
||||||
|
|
||||||
|
public bool IsUpdateTypeVisible => !string.IsNullOrEmpty(UpdateTypeText) && !HasPendingInstaller;
|
||||||
|
|
||||||
public string DownloadThreadsValueText =>
|
public string DownloadThreadsValueText =>
|
||||||
UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue)).ToString(CultureInfo.CurrentCulture);
|
UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue)).ToString(CultureInfo.CurrentCulture);
|
||||||
|
|
||||||
@@ -1854,15 +1848,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnSelectedUpdateSourceOptionChanged(SelectionOption? value)
|
|
||||||
{
|
|
||||||
if (value is not null &&
|
|
||||||
!string.Equals(SelectedUpdateSourceValue, value.Value, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
SelectedUpdateSourceValue = value.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
partial void OnSelectedUpdateModeOptionChanged(SelectionOption? value)
|
partial void OnSelectedUpdateModeOptionChanged(SelectionOption? value)
|
||||||
{
|
{
|
||||||
if (value is not null &&
|
if (value is not null &&
|
||||||
@@ -1910,19 +1895,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
RefreshActionState();
|
RefreshActionState();
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnSelectedUpdateSourceValueChanged(string value)
|
|
||||||
{
|
|
||||||
if (_isInitializing)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
SaveUpdateSettings();
|
|
||||||
SelectedUpdateSourceDescription = BuildUpdateSourceDescription(value);
|
|
||||||
UpdateStatus = L("settings.update.status_preferences_saved", "Update preferences saved.");
|
|
||||||
SyncSelectedOptions();
|
|
||||||
}
|
|
||||||
|
|
||||||
partial void OnSelectedUpdateModeValueChanged(string value)
|
partial void OnSelectedUpdateModeValueChanged(string value)
|
||||||
{
|
{
|
||||||
if (_isInitializing)
|
if (_isInitializing)
|
||||||
@@ -1988,6 +1960,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
CheckForUpdatesCommand.NotifyCanExecuteChanged();
|
CheckForUpdatesCommand.NotifyCanExecuteChanged();
|
||||||
DownloadLatestReleaseCommand.NotifyCanExecuteChanged();
|
DownloadLatestReleaseCommand.NotifyCanExecuteChanged();
|
||||||
InstallPendingUpdateCommand.NotifyCanExecuteChanged();
|
InstallPendingUpdateCommand.NotifyCanExecuteChanged();
|
||||||
|
ForceFullUpdateCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnIsDownloadingChanged(bool value)
|
partial void OnIsDownloadingChanged(bool value)
|
||||||
@@ -1995,6 +1968,18 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
CheckForUpdatesCommand.NotifyCanExecuteChanged();
|
CheckForUpdatesCommand.NotifyCanExecuteChanged();
|
||||||
DownloadLatestReleaseCommand.NotifyCanExecuteChanged();
|
DownloadLatestReleaseCommand.NotifyCanExecuteChanged();
|
||||||
InstallPendingUpdateCommand.NotifyCanExecuteChanged();
|
InstallPendingUpdateCommand.NotifyCanExecuteChanged();
|
||||||
|
ForceFullUpdateCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnUseGhProxyMirrorChanged(bool value)
|
||||||
|
{
|
||||||
|
if (_isInitializing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SaveUpdateSettings();
|
||||||
|
UpdateStatus = L("settings.update.status_preferences_saved", "Update preferences saved.");
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -2009,24 +1994,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
SelectedUpdateChannelValue = UpdateSettingsValues.ChannelPreview;
|
SelectedUpdateChannelValue = UpdateSettingsValues.ChannelPreview;
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void SelectPdcSource()
|
|
||||||
{
|
|
||||||
SelectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void SelectGitHubSource()
|
|
||||||
{
|
|
||||||
SelectedUpdateSourceValue = UpdateSettingsValues.DownloadSourceGitHub;
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void SelectGhProxySource()
|
|
||||||
{
|
|
||||||
SelectedUpdateSourceValue = UpdateSettingsValues.DownloadSourceGhProxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void SelectManualMode()
|
private void SelectManualMode()
|
||||||
{
|
{
|
||||||
@@ -2056,7 +2023,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
StringComparison.OrdinalIgnoreCase),
|
StringComparison.OrdinalIgnoreCase),
|
||||||
UpdateChannel = SelectedUpdateChannelValue,
|
UpdateChannel = SelectedUpdateChannelValue,
|
||||||
UpdateMode = SelectedUpdateModeValue,
|
UpdateMode = SelectedUpdateModeValue,
|
||||||
UpdateDownloadSource = SelectedUpdateSourceValue,
|
UseGhProxyMirror = UseGhProxyMirror,
|
||||||
UpdateDownloadThreads = UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue))
|
UpdateDownloadThreads = UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2077,6 +2044,86 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
await CheckForUpdatesCoreAsync(isForce: true);
|
await CheckForUpdatesCoreAsync(isForce: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool CanForceFullUpdate() => !IsBusy;
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanForceFullUpdate))]
|
||||||
|
private async Task ForceFullUpdateAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IsCheckingForUpdates = true;
|
||||||
|
IsDownloadProgressVisible = true;
|
||||||
|
UpdatePhaseText = L("settings.update.phase_force_full", "Forcing full update...");
|
||||||
|
PhaseProgressValue = 0;
|
||||||
|
DownloadProgressValue = 0;
|
||||||
|
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
||||||
|
UpdateStatus = L("settings.update.status_force_full_checking", "Checking for full installer...");
|
||||||
|
|
||||||
|
var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion, isForce: true);
|
||||||
|
_lastCheckResult = result.Success ? result : null;
|
||||||
|
|
||||||
|
if (!result.Success || result.PreferredAsset is null)
|
||||||
|
{
|
||||||
|
UpdateStatus = L("settings.update.status_force_full_failed", "No full installer available.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateTypeText = L("settings.update.type_full", "Full Update");
|
||||||
|
await DownloadFullInstallerCoreAsync(result);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsCheckingForUpdates = false;
|
||||||
|
IsDownloadProgressVisible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DownloadFullInstallerCoreAsync(UpdateCheckResult result)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IsDownloading = true;
|
||||||
|
IsDownloadProgressVisible = true;
|
||||||
|
UpdatePhaseText = L("settings.update.phase_downloading_full", "Downloading full installer...");
|
||||||
|
DownloadProgressValue = 0;
|
||||||
|
PhaseProgressValue = 0;
|
||||||
|
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
||||||
|
UpdateStatus = L("settings.update.status_downloading_full", "Downloading full installer...");
|
||||||
|
|
||||||
|
var progress = new Progress<double>(value =>
|
||||||
|
{
|
||||||
|
DownloadProgressValue = Math.Clamp(value * 100d, 0d, 100d);
|
||||||
|
PhaseProgressValue = DownloadProgressValue;
|
||||||
|
DownloadProgressText = string.Format(
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
L("settings.update.download_progress_format", "Download progress: {0:F0}%"),
|
||||||
|
DownloadProgressValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
var downloadResult = await _updateWorkflowService.DownloadReleaseAsync(result, progress, CancellationToken.None);
|
||||||
|
if (!downloadResult.Success)
|
||||||
|
{
|
||||||
|
UpdateStatus = string.Format(
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
L("settings.update.status_download_failed_format", "Download failed: {0}"),
|
||||||
|
downloadResult.ErrorMessage ?? L("settings.update.status_check_failed", "Failed to check for updates."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyPendingState(_settingsFacade.Update.Get());
|
||||||
|
UpdateStatus = downloadResult.HashVerified
|
||||||
|
? BuildPendingReadyStatus()
|
||||||
|
: string.Format(
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
L("settings.update.status_downloaded_no_hash_format", "Update downloaded. Hash: {0}"),
|
||||||
|
downloadResult.ActualHash ?? "N/A");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsDownloading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task CheckForUpdatesCoreAsync(bool isForce)
|
private async Task CheckForUpdatesCoreAsync(bool isForce)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -2085,6 +2132,10 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
IsDownloadProgressVisible = false;
|
IsDownloadProgressVisible = false;
|
||||||
DownloadProgressValue = 0;
|
DownloadProgressValue = 0;
|
||||||
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
||||||
|
UpdatePhaseText = isForce
|
||||||
|
? L("settings.update.phase_force_scanning", "Force scanning update source...")
|
||||||
|
: L("settings.update.phase_scanning", "Scanning update source...");
|
||||||
|
PhaseProgressValue = 0;
|
||||||
UpdateStatus = isForce
|
UpdateStatus = isForce
|
||||||
? L("settings.update.status_force_checking", "Force checking update source...")
|
? L("settings.update.status_force_checking", "Force checking update source...")
|
||||||
: L("settings.update.status_checking", "Checking update source...");
|
: L("settings.update.status_checking", "Checking update source...");
|
||||||
@@ -2093,6 +2144,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
_lastCheckResult = result.Success ? result : null;
|
_lastCheckResult = result.Success ? result : null;
|
||||||
RefreshLastCheckedFromSettings();
|
RefreshLastCheckedFromSettings();
|
||||||
|
|
||||||
|
UpdatePhaseText = L("settings.update.phase_locating_resources", "Locating update resources...");
|
||||||
|
PhaseProgressValue = 10;
|
||||||
|
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
{
|
{
|
||||||
UpdateStatus = string.IsNullOrWhiteSpace(result.ErrorMessage)
|
UpdateStatus = string.IsNullOrWhiteSpace(result.ErrorMessage)
|
||||||
@@ -2105,6 +2159,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
ApplyCheckResultDisplay(result);
|
ApplyCheckResultDisplay(result);
|
||||||
|
UpdateTypeText = UpdateWorkflowService.IsDeltaUpdateAvailable(result)
|
||||||
|
? L("settings.update.type_delta", "Incremental Update")
|
||||||
|
: L("settings.update.type_full", "Full Update");
|
||||||
if (!result.IsUpdateAvailable && !isForce)
|
if (!result.IsUpdateAvailable && !isForce)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -2255,12 +2312,15 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
PreferencesHeader = L("settings.update.preferences_header", "Update Preferences");
|
PreferencesHeader = L("settings.update.preferences_header", "Update Preferences");
|
||||||
PreferencesDescription = L("settings.update.preferences_description", "Choose your release channel, download source, behavior, and download speed.");
|
PreferencesDescription = L("settings.update.preferences_description", "Choose your release channel, download source, behavior, and download speed.");
|
||||||
UpdateChannelLabel = L("settings.update.channel_label", "Update Channel");
|
UpdateChannelLabel = L("settings.update.channel_label", "Update Channel");
|
||||||
UpdateSourceLabel = L("settings.update.source_label", "Download Source");
|
|
||||||
UpdateModeLabel = L("settings.update.mode_label", "Update Mode");
|
UpdateModeLabel = L("settings.update.mode_label", "Update Mode");
|
||||||
DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads");
|
DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads");
|
||||||
DownloadThreadsDescription = L("settings.update.download_threads_desc", "Choose how many parallel download threads are used for application updates.");
|
DownloadThreadsDescription = L("settings.update.download_threads_desc", "Choose how many parallel download threads are used for application updates.");
|
||||||
ForceCheckUpdateLabel = L("settings.update.force_check_label", "Force Check Update");
|
ForceCheckUpdateLabel = L("settings.update.force_check_label", "Force Check Update");
|
||||||
ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates, ignoring version comparison.");
|
ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates, ignoring version comparison.");
|
||||||
|
ForceFullUpdateLabel = L("settings.update.force_full_label", "Force Full Update");
|
||||||
|
ForceFullUpdateDescription = L("settings.update.force_full_desc", "Skip incremental update and force download the full installer. Use this if incremental update fails repeatedly.");
|
||||||
|
NetworkAccelerationLabel = L("settings.update.network_accel_label", "Network Acceleration");
|
||||||
|
NetworkAccelerationDescription = L("settings.update.network_accel_desc", "Use gh-proxy mirror to accelerate GitHub downloads. Only applies when falling back to GitHub for full updates.");
|
||||||
CheckForUpdatesButtonText = L("settings.update.check_button", "Check for Updates");
|
CheckForUpdatesButtonText = L("settings.update.check_button", "Check for Updates");
|
||||||
DownloadButtonText = L("settings.update.download_install_button", "Download & Install");
|
DownloadButtonText = L("settings.update.download_install_button", "Download & Install");
|
||||||
InstallNowButtonText = L("settings.update.install_now_button", "Install Now");
|
InstallNowButtonText = L("settings.update.install_now_button", "Install Now");
|
||||||
@@ -2272,15 +2332,11 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
UpdateTypeLabel = L("settings.update.type_label", "Update Type");
|
UpdateTypeLabel = L("settings.update.type_label", "Update Type");
|
||||||
StableChannelText = L("settings.update.channel_stable", "Stable");
|
StableChannelText = L("settings.update.channel_stable", "Stable");
|
||||||
PreviewChannelText = L("settings.update.channel_preview", "Preview");
|
PreviewChannelText = L("settings.update.channel_preview", "Preview");
|
||||||
PdcSourceText = L("settings.update.source_pdc", "PDC");
|
|
||||||
GitHubSourceText = L("settings.update.source_github", "GitHub");
|
|
||||||
GhProxySourceText = L("settings.update.source_ghproxy", "gh-proxy");
|
|
||||||
ManualModeText = L("settings.update.mode_manual", "Manual Update");
|
ManualModeText = L("settings.update.mode_manual", "Manual Update");
|
||||||
DownloadThenConfirmModeText = L("settings.update.mode_download_then_confirm", "Silent Download");
|
DownloadThenConfirmModeText = L("settings.update.mode_download_then_confirm", "Silent Download");
|
||||||
SilentOnExitModeText = L("settings.update.mode_silent_on_exit", "Silent Install");
|
SilentOnExitModeText = L("settings.update.mode_silent_on_exit", "Silent Install");
|
||||||
SelectedUpdateChannelDescription = BuildUpdateChannelDescription(SelectedUpdateChannelValue);
|
SelectedUpdateChannelDescription = BuildUpdateChannelDescription(SelectedUpdateChannelValue);
|
||||||
SelectedUpdateModeDescription = BuildUpdateModeDescription(SelectedUpdateModeValue);
|
SelectedUpdateModeDescription = BuildUpdateModeDescription(SelectedUpdateModeValue);
|
||||||
SelectedUpdateSourceDescription = BuildUpdateSourceDescription(SelectedUpdateSourceValue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LoadStateFromSettings()
|
private void LoadStateFromSettings()
|
||||||
@@ -2288,7 +2344,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
var update = _settingsFacade.Update.Get();
|
var update = _settingsFacade.Update.Get();
|
||||||
_isInitializing = true;
|
_isInitializing = true;
|
||||||
SelectedUpdateChannelValue = UpdateSettingsValues.NormalizeChannel(update.UpdateChannel, update.IncludePrereleaseUpdates);
|
SelectedUpdateChannelValue = UpdateSettingsValues.NormalizeChannel(update.UpdateChannel, update.IncludePrereleaseUpdates);
|
||||||
SelectedUpdateSourceValue = UpdateSettingsValues.NormalizeDownloadSource(update.UpdateDownloadSource);
|
UseGhProxyMirror = update.UseGhProxyMirror;
|
||||||
SelectedUpdateModeValue = UpdateSettingsValues.NormalizeMode(update.UpdateMode);
|
SelectedUpdateModeValue = UpdateSettingsValues.NormalizeMode(update.UpdateMode);
|
||||||
DownloadThreadsSliderValue = UpdateSettingsValues.NormalizeDownloadThreads(update.UpdateDownloadThreads);
|
DownloadThreadsSliderValue = UpdateSettingsValues.NormalizeDownloadThreads(update.UpdateDownloadThreads);
|
||||||
DownloadThreadsText = ((int)Math.Round(DownloadThreadsSliderValue)).ToString(CultureInfo.CurrentCulture);
|
DownloadThreadsText = ((int)Math.Round(DownloadThreadsSliderValue)).ToString(CultureInfo.CurrentCulture);
|
||||||
@@ -2368,10 +2424,14 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
IsDownloadProgressVisible = true;
|
IsDownloadProgressVisible = true;
|
||||||
DownloadProgressValue = 0;
|
DownloadProgressValue = 0;
|
||||||
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
||||||
|
UpdatePhaseText = UpdateWorkflowService.IsDeltaUpdateAvailable(result)
|
||||||
|
? L("settings.update.phase_downloading_delta", "Downloading incremental update...")
|
||||||
|
: L("settings.update.phase_downloading_full", "Downloading full installer...");
|
||||||
|
|
||||||
var progress = new Progress<double>(value =>
|
var progress = new Progress<double>(value =>
|
||||||
{
|
{
|
||||||
DownloadProgressValue = Math.Clamp(value * 100d, 0d, 100d);
|
DownloadProgressValue = Math.Clamp(value * 100d, 0d, 100d);
|
||||||
|
PhaseProgressValue = DownloadProgressValue;
|
||||||
DownloadProgressText = string.Format(
|
DownloadProgressText = string.Format(
|
||||||
CultureInfo.CurrentCulture,
|
CultureInfo.CurrentCulture,
|
||||||
L("settings.update.download_progress_format", "Download progress: {0:F0}%"),
|
L("settings.update.download_progress_format", "Download progress: {0:F0}%"),
|
||||||
@@ -2466,22 +2526,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private string BuildUpdateSourceDescription(string? value)
|
|
||||||
{
|
|
||||||
return UpdateSettingsValues.NormalizeDownloadSource(value) switch
|
|
||||||
{
|
|
||||||
UpdateSettingsValues.DownloadSourcePdc => L(
|
|
||||||
"settings.update.source_pdc_desc",
|
|
||||||
"Prefer PDC metadata and distribution endpoints, then automatically fallback to GitHub."),
|
|
||||||
UpdateSettingsValues.DownloadSourceGhProxy => L(
|
|
||||||
"settings.update.source_ghproxy_desc",
|
|
||||||
"Use the gh-proxy mirror when downloading GitHub release assets."),
|
|
||||||
_ => L(
|
|
||||||
"settings.update.source_github_desc",
|
|
||||||
"Download release assets directly from GitHub.")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FormatTimestamp(long? utcMs)
|
private string FormatTimestamp(long? utcMs)
|
||||||
{
|
{
|
||||||
if (utcMs is not > 0)
|
if (utcMs is not > 0)
|
||||||
@@ -2509,6 +2553,7 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(IsRedownloadButtonVisible));
|
OnPropertyChanged(nameof(IsRedownloadButtonVisible));
|
||||||
OnPropertyChanged(nameof(DownloadThreadsValueText));
|
OnPropertyChanged(nameof(DownloadThreadsValueText));
|
||||||
RedownloadUpdateCommand.NotifyCanExecuteChanged();
|
RedownloadUpdateCommand.NotifyCanExecuteChanged();
|
||||||
|
ForceFullUpdateCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private IReadOnlyList<SelectionOption> CreateUpdateChannelOptions()
|
private IReadOnlyList<SelectionOption> CreateUpdateChannelOptions()
|
||||||
@@ -2520,16 +2565,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private IReadOnlyList<SelectionOption> CreateUpdateSourceOptions()
|
|
||||||
{
|
|
||||||
return
|
|
||||||
[
|
|
||||||
new SelectionOption(UpdateSettingsValues.DownloadSourcePdc, PdcSourceText),
|
|
||||||
new SelectionOption(UpdateSettingsValues.DownloadSourceGitHub, GitHubSourceText),
|
|
||||||
new SelectionOption(UpdateSettingsValues.DownloadSourceGhProxy, GhProxySourceText)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private IReadOnlyList<SelectionOption> CreateUpdateModeOptions()
|
private IReadOnlyList<SelectionOption> CreateUpdateModeOptions()
|
||||||
{
|
{
|
||||||
return
|
return
|
||||||
@@ -2554,8 +2589,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
SelectedUpdateChannelOption = UpdateChannelOptions.FirstOrDefault(option =>
|
SelectedUpdateChannelOption = UpdateChannelOptions.FirstOrDefault(option =>
|
||||||
string.Equals(option.Value, SelectedUpdateChannelValue, StringComparison.OrdinalIgnoreCase));
|
string.Equals(option.Value, SelectedUpdateChannelValue, StringComparison.OrdinalIgnoreCase));
|
||||||
SelectedUpdateSourceOption = UpdateSourceOptions.FirstOrDefault(option =>
|
|
||||||
string.Equals(option.Value, SelectedUpdateSourceValue, StringComparison.OrdinalIgnoreCase));
|
|
||||||
SelectedUpdateModeOption = UpdateModeOptions.FirstOrDefault(option =>
|
SelectedUpdateModeOption = UpdateModeOptions.FirstOrDefault(option =>
|
||||||
string.Equals(option.Value, SelectedUpdateModeValue, StringComparison.OrdinalIgnoreCase));
|
string.Equals(option.Value, SelectedUpdateModeValue, StringComparison.OrdinalIgnoreCase));
|
||||||
SelectedDownloadThreadsOption = DownloadThreadOptions.FirstOrDefault(option =>
|
SelectedDownloadThreadsOption = DownloadThreadOptions.FirstOrDefault(option =>
|
||||||
|
|||||||
94
LanMountainDesktop/ViewModels/UpdateProgressViewModel.cs
Normal file
94
LanMountainDesktop/ViewModels/UpdateProgressViewModel.cs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
using System;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LanMountainDesktop.Services.Update;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.ViewModels;
|
||||||
|
|
||||||
|
public sealed partial class UpdateProgressViewModel : ViewModelBase, IDisposable
|
||||||
|
{
|
||||||
|
private readonly IDisposable _subscription;
|
||||||
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public UpdateProgressViewModel(IObservable<InstallProgressReport> progressStream)
|
||||||
|
{
|
||||||
|
_subscription = progressStream.Subscribe(new ActionObserver<InstallProgressReport>(OnNext));
|
||||||
|
}
|
||||||
|
|
||||||
|
[ObservableProperty] private string _stageText = string.Empty;
|
||||||
|
[ObservableProperty] private double _progressFraction;
|
||||||
|
[ObservableProperty] private string _currentFile = string.Empty;
|
||||||
|
[ObservableProperty] private int _filesCompleted;
|
||||||
|
[ObservableProperty] private int _filesTotal;
|
||||||
|
[ObservableProperty] private bool _isCompleted;
|
||||||
|
[ObservableProperty] private bool _isSuccess;
|
||||||
|
[ObservableProperty] private string _errorMessage = string.Empty;
|
||||||
|
|
||||||
|
public int ProgressPercent => (int)Math.Clamp(ProgressFraction * 100, 0, 100);
|
||||||
|
|
||||||
|
partial void OnProgressFractionChanged(double value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(ProgressPercent));
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Cancel()
|
||||||
|
{
|
||||||
|
_cts.Cancel();
|
||||||
|
IsCompleted = true;
|
||||||
|
IsSuccess = false;
|
||||||
|
ErrorMessage = "Cancelled by user.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public CancellationToken CancellationToken => _cts.Token;
|
||||||
|
|
||||||
|
private void OnNext(InstallProgressReport report)
|
||||||
|
{
|
||||||
|
StageText = report.Message;
|
||||||
|
ProgressFraction = report.FilesTotal > 0
|
||||||
|
? (double)report.FilesCompleted / report.FilesTotal
|
||||||
|
: report.ProgressPercent / 100.0;
|
||||||
|
CurrentFile = report.CurrentFile ?? string.Empty;
|
||||||
|
FilesCompleted = report.FilesCompleted;
|
||||||
|
FilesTotal = report.FilesTotal;
|
||||||
|
|
||||||
|
if (report.Stage is InstallStage.Completed)
|
||||||
|
{
|
||||||
|
IsCompleted = true;
|
||||||
|
IsSuccess = true;
|
||||||
|
}
|
||||||
|
else if (report.Stage is InstallStage.Failed)
|
||||||
|
{
|
||||||
|
IsCompleted = true;
|
||||||
|
IsSuccess = false;
|
||||||
|
ErrorMessage = report.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnError(Exception ex)
|
||||||
|
{
|
||||||
|
IsCompleted = true;
|
||||||
|
IsSuccess = false;
|
||||||
|
ErrorMessage = ex.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCompleted()
|
||||||
|
{
|
||||||
|
IsCompleted = true;
|
||||||
|
IsSuccess = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
_subscription.Dispose();
|
||||||
|
_cts.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
208
LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs
Normal file
208
LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using LanMountainDesktop.Services.Update;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Update;
|
||||||
|
using UpdateSettingsValues = LanMountainDesktop.Services.UpdateSettingsValues;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.ViewModels;
|
||||||
|
|
||||||
|
public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
||||||
|
{
|
||||||
|
private readonly UpdateOrchestrator _orchestrator;
|
||||||
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public UpdateSettingsViewModel(UpdateOrchestrator orchestrator, ISettingsFacadeService settingsFacade)
|
||||||
|
{
|
||||||
|
_orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator));
|
||||||
|
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||||
|
|
||||||
|
CurrentPhase = _orchestrator.CurrentPhase;
|
||||||
|
CurrentVersionText = _settingsFacade.ApplicationInfo.GetAppVersionText();
|
||||||
|
LoadPreferenceState();
|
||||||
|
|
||||||
|
_orchestrator.PhaseChanged += OnOrchestratorPhaseChanged;
|
||||||
|
_orchestrator.ProgressChanged += OnOrchestratorProgressChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
[ObservableProperty] private UpdatePhase _currentPhase = UpdatePhase.Idle;
|
||||||
|
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||||
|
[ObservableProperty] private double _progressFraction;
|
||||||
|
[ObservableProperty] private string _progressDetail = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty] private string _currentVersionText = string.Empty;
|
||||||
|
[ObservableProperty] private string _latestVersionText = string.Empty;
|
||||||
|
[ObservableProperty] private string _publishedAtText = string.Empty;
|
||||||
|
[ObservableProperty] private string _lastCheckedText = string.Empty;
|
||||||
|
[ObservableProperty] private string _updateTypeText = string.Empty;
|
||||||
|
[ObservableProperty] private bool _isUpdateAvailable;
|
||||||
|
[ObservableProperty] private bool _isDeltaUpdate;
|
||||||
|
|
||||||
|
[ObservableProperty] private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
|
||||||
|
[ObservableProperty] private string _selectedUpdateSourceValue = UpdateSettingsValues.DownloadSourcePdc;
|
||||||
|
[ObservableProperty] private string _selectedUpdateModeValue = UpdateSettingsValues.ModeDownloadThenConfirm;
|
||||||
|
[ObservableProperty] private double _downloadThreadsSliderValue = UpdateSettingsValues.DefaultDownloadThreads;
|
||||||
|
|
||||||
|
public bool IsBusy => CurrentPhase.IsBusy();
|
||||||
|
public bool CanCheck => CurrentPhase.CanCheck();
|
||||||
|
public bool CanDownload => CurrentPhase.CanDownload();
|
||||||
|
public bool CanInstall => CurrentPhase.CanInstall();
|
||||||
|
public bool CanRollback => CurrentPhase.CanRollback();
|
||||||
|
public bool IsProgressVisible => CurrentPhase is UpdatePhase.Checking or UpdatePhase.Downloading or UpdatePhase.Installing or UpdatePhase.Verifying or UpdatePhase.RollingBack;
|
||||||
|
|
||||||
|
partial void OnCurrentPhaseChanged(UpdatePhase value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsBusy));
|
||||||
|
OnPropertyChanged(nameof(CanCheck));
|
||||||
|
OnPropertyChanged(nameof(CanDownload));
|
||||||
|
OnPropertyChanged(nameof(CanInstall));
|
||||||
|
OnPropertyChanged(nameof(CanRollback));
|
||||||
|
OnPropertyChanged(nameof(IsProgressVisible));
|
||||||
|
CheckCommand.NotifyCanExecuteChanged();
|
||||||
|
DownloadCommand.NotifyCanExecuteChanged();
|
||||||
|
InstallCommand.NotifyCanExecuteChanged();
|
||||||
|
RollbackCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedUpdateChannelValueChanged(string value)
|
||||||
|
{
|
||||||
|
SavePreferenceState();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedUpdateSourceValueChanged(string value)
|
||||||
|
{
|
||||||
|
SavePreferenceState();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedUpdateModeValueChanged(string value)
|
||||||
|
{
|
||||||
|
SavePreferenceState();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnDownloadThreadsSliderValueChanged(double value)
|
||||||
|
{
|
||||||
|
SavePreferenceState();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanCheck))]
|
||||||
|
private async Task CheckAsync()
|
||||||
|
{
|
||||||
|
var report = await _orchestrator.CheckAsync(CancellationToken.None);
|
||||||
|
if (report.IsUpdateAvailable)
|
||||||
|
{
|
||||||
|
IsUpdateAvailable = true;
|
||||||
|
LatestVersionText = report.LatestVersion ?? string.Empty;
|
||||||
|
PublishedAtText = report.PublishedAt?.ToLocalTime().ToString("g") ?? string.Empty;
|
||||||
|
UpdateTypeText = report.PayloadKind?.ToString() ?? string.Empty;
|
||||||
|
IsDeltaUpdate = report.PayloadKind is UpdatePayloadKind.DeltaPlonds or UpdatePayloadKind.DeltaLegacy;
|
||||||
|
StatusMessage = $"New version {report.LatestVersion} is available.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
IsUpdateAvailable = false;
|
||||||
|
LatestVersionText = string.Empty;
|
||||||
|
PublishedAtText = string.Empty;
|
||||||
|
UpdateTypeText = string.Empty;
|
||||||
|
IsDeltaUpdate = false;
|
||||||
|
StatusMessage = report.ErrorMessage ?? "You are up to date.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanDownload))]
|
||||||
|
private async Task DownloadAsync()
|
||||||
|
{
|
||||||
|
StatusMessage = "Downloading update...";
|
||||||
|
var result = await _orchestrator.DownloadAsync(CancellationToken.None);
|
||||||
|
if (result.Success)
|
||||||
|
{
|
||||||
|
StatusMessage = "Download complete. Ready to install.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
StatusMessage = result.ErrorMessage ?? "Download failed.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanInstall))]
|
||||||
|
private async Task InstallAsync()
|
||||||
|
{
|
||||||
|
StatusMessage = "Installing update...";
|
||||||
|
var result = await _orchestrator.InstallAsync(CancellationToken.None);
|
||||||
|
if (result.Success)
|
||||||
|
{
|
||||||
|
StatusMessage = "Update installed successfully.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
StatusMessage = result.ErrorMessage ?? "Install failed.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanRollback))]
|
||||||
|
private async Task RollbackAsync()
|
||||||
|
{
|
||||||
|
StatusMessage = "Rolling back...";
|
||||||
|
await _orchestrator.RollbackAsync(CancellationToken.None);
|
||||||
|
StatusMessage = "Rollback complete.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnOrchestratorPhaseChanged(UpdatePhase phase)
|
||||||
|
{
|
||||||
|
CurrentPhase = phase;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnOrchestratorProgressChanged(UpdateProgressReport report)
|
||||||
|
{
|
||||||
|
ProgressFraction = report.ProgressFraction;
|
||||||
|
StatusMessage = report.Message;
|
||||||
|
if (report.DownloadDetail is not null)
|
||||||
|
{
|
||||||
|
ProgressDetail = $"{report.DownloadDetail.CurrentFile} ({report.DownloadDetail.OverallPercent}%)";
|
||||||
|
}
|
||||||
|
else if (report.InstallDetail is not null)
|
||||||
|
{
|
||||||
|
ProgressDetail = report.InstallDetail.CurrentFile ?? report.InstallDetail.Message;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ProgressDetail = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadPreferenceState()
|
||||||
|
{
|
||||||
|
var state = _settingsFacade.Update.Get();
|
||||||
|
SelectedUpdateChannelValue = state.UpdateChannel;
|
||||||
|
SelectedUpdateSourceValue = state.UpdateDownloadSource;
|
||||||
|
SelectedUpdateModeValue = state.UpdateMode;
|
||||||
|
DownloadThreadsSliderValue = UpdateSettingsValues.NormalizeDownloadThreads(state.UpdateDownloadThreads);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SavePreferenceState()
|
||||||
|
{
|
||||||
|
var current = _settingsFacade.Update.Get();
|
||||||
|
_settingsFacade.Update.Save(current with
|
||||||
|
{
|
||||||
|
UpdateChannel = SelectedUpdateChannelValue,
|
||||||
|
UpdateDownloadSource = SelectedUpdateSourceValue,
|
||||||
|
UpdateMode = SelectedUpdateModeValue,
|
||||||
|
UpdateDownloadThreads = UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
_orchestrator.PhaseChanged -= OnOrchestratorPhaseChanged;
|
||||||
|
_orchestrator.ProgressChanged -= OnOrchestratorProgressChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,7 @@ public partial class MainWindow : Window
|
|||||||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateChannel), StringComparison.OrdinalIgnoreCase) ||
|
string.Equals(key, nameof(AppSettingsSnapshot.UpdateChannel), StringComparison.OrdinalIgnoreCase) ||
|
||||||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateMode), StringComparison.OrdinalIgnoreCase) ||
|
string.Equals(key, nameof(AppSettingsSnapshot.UpdateMode), StringComparison.OrdinalIgnoreCase) ||
|
||||||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadSource), StringComparison.OrdinalIgnoreCase) ||
|
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadSource), StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(key, nameof(AppSettingsSnapshot.UseGhProxyMirror), StringComparison.OrdinalIgnoreCase) ||
|
||||||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadThreads), StringComparison.OrdinalIgnoreCase) ||
|
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadThreads), StringComparison.OrdinalIgnoreCase) ||
|
||||||
string.Equals(key, nameof(AppSettingsSnapshot.EnableThreeFingerSwipe), StringComparison.OrdinalIgnoreCase) ||
|
string.Equals(key, nameof(AppSettingsSnapshot.EnableThreeFingerSwipe), StringComparison.OrdinalIgnoreCase) ||
|
||||||
string.Equals(key, nameof(AppSettingsSnapshot.EnableFadeTransition), StringComparison.OrdinalIgnoreCase) ||
|
string.Equals(key, nameof(AppSettingsSnapshot.EnableFadeTransition), StringComparison.OrdinalIgnoreCase) ||
|
||||||
@@ -661,6 +662,7 @@ public partial class MainWindow : Window
|
|||||||
UpdateMode = latestUpdateState.UpdateMode,
|
UpdateMode = latestUpdateState.UpdateMode,
|
||||||
UpdateDownloadSource = latestUpdateState.UpdateDownloadSource,
|
UpdateDownloadSource = latestUpdateState.UpdateDownloadSource,
|
||||||
UpdateDownloadThreads = latestUpdateState.UpdateDownloadThreads,
|
UpdateDownloadThreads = latestUpdateState.UpdateDownloadThreads,
|
||||||
|
UseGhProxyMirror = latestUpdateState.UseGhProxyMirror,
|
||||||
PendingUpdateInstallerPath = latestUpdateState.PendingUpdateInstallerPath,
|
PendingUpdateInstallerPath = latestUpdateState.PendingUpdateInstallerPath,
|
||||||
PendingUpdateVersion = latestUpdateState.PendingUpdateVersion,
|
PendingUpdateVersion = latestUpdateState.PendingUpdateVersion,
|
||||||
PendingUpdatePublishedAtUtcMs = latestUpdateState.PendingUpdatePublishedAtUtcMs,
|
PendingUpdatePublishedAtUtcMs = latestUpdateState.PendingUpdatePublishedAtUtcMs,
|
||||||
|
|||||||
@@ -33,6 +33,13 @@
|
|||||||
<Setter Property="MaxWidth" Value="200" />
|
<Setter Property="MaxWidth" Value="200" />
|
||||||
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.update-phase-text">
|
||||||
|
<Setter Property="FontSize" Value="13" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||||
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
|
</Style>
|
||||||
</UserControl.Styles>
|
</UserControl.Styles>
|
||||||
|
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
@@ -65,7 +72,7 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid ColumnDefinitions="Auto,*"
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
RowDefinitions="Auto,Auto,Auto"
|
RowDefinitions="Auto,Auto,Auto,Auto"
|
||||||
ColumnSpacing="20"
|
ColumnSpacing="20"
|
||||||
RowSpacing="16">
|
RowSpacing="16">
|
||||||
<StackPanel Grid.Row="0"
|
<StackPanel Grid.Row="0"
|
||||||
@@ -116,9 +123,19 @@
|
|||||||
<TextBlock Classes="update-kv-value"
|
<TextBlock Classes="update-kv-value"
|
||||||
Text="{Binding PendingUpdateTypeText}" />
|
Text="{Binding PendingUpdateTypeText}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="2"
|
||||||
|
Grid.Column="1"
|
||||||
|
Spacing="4"
|
||||||
|
IsVisible="{Binding IsUpdateTypeVisible}">
|
||||||
|
<TextBlock Classes="update-kv-label"
|
||||||
|
Text="{Binding UpdateTypeLabel}" />
|
||||||
|
<TextBlock Classes="update-kv-value"
|
||||||
|
Text="{Binding UpdateTypeText}" />
|
||||||
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<StackPanel Spacing="12"
|
<StackPanel Spacing="8"
|
||||||
HorizontalAlignment="Left">
|
HorizontalAlignment="Left">
|
||||||
<TextBlock Classes="settings-item-description"
|
<TextBlock Classes="settings-item-description"
|
||||||
Text="{Binding UpdateStatus}"
|
Text="{Binding UpdateStatus}"
|
||||||
@@ -126,12 +143,19 @@
|
|||||||
HorizontalAlignment="Left"
|
HorizontalAlignment="Left"
|
||||||
MaxWidth="500" />
|
MaxWidth="500" />
|
||||||
|
|
||||||
|
<TextBlock Classes="update-phase-text"
|
||||||
|
IsVisible="{Binding IsDownloadProgressVisible}"
|
||||||
|
Text="{Binding UpdatePhaseText}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
HorizontalAlignment="Left" />
|
||||||
|
|
||||||
<ProgressBar Minimum="0"
|
<ProgressBar Minimum="0"
|
||||||
Maximum="100"
|
Maximum="100"
|
||||||
Value="{Binding DownloadProgressValue}"
|
Value="{Binding PhaseProgressValue}"
|
||||||
IsVisible="{Binding IsDownloadProgressVisible}"
|
IsVisible="{Binding IsDownloadProgressVisible}"
|
||||||
HorizontalAlignment="Stretch"
|
HorizontalAlignment="Stretch"
|
||||||
Margin="0,4,0,4" />
|
Margin="0,4,0,4"
|
||||||
|
ShowProgressText="True" />
|
||||||
|
|
||||||
<TextBlock Classes="settings-item-description"
|
<TextBlock Classes="settings-item-description"
|
||||||
IsVisible="{Binding IsDownloadProgressVisible}"
|
IsVisible="{Binding IsDownloadProgressVisible}"
|
||||||
@@ -144,15 +168,27 @@
|
|||||||
<StackPanel Orientation="Horizontal"
|
<StackPanel Orientation="Horizontal"
|
||||||
Spacing="10">
|
Spacing="10">
|
||||||
<Button Command="{Binding DownloadLatestReleaseCommand}"
|
<Button Command="{Binding DownloadLatestReleaseCommand}"
|
||||||
Content="{Binding DownloadButtonText}"
|
IsVisible="{Binding IsDownloadButtonVisible}">
|
||||||
IsVisible="{Binding IsDownloadButtonVisible}" />
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<fi:SymbolIcon Symbol="ArrowDownload" FontSize="14" />
|
||||||
|
<TextBlock Text="{Binding DownloadButtonText}" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
<Button Command="{Binding RedownloadUpdateCommand}"
|
<Button Command="{Binding RedownloadUpdateCommand}"
|
||||||
Content="{Binding RedownloadButtonText}"
|
IsVisible="{Binding IsRedownloadButtonVisible}">
|
||||||
IsVisible="{Binding IsRedownloadButtonVisible}" />
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<fi:SymbolIcon Symbol="ArrowUndo" FontSize="14" />
|
||||||
|
<TextBlock Text="{Binding RedownloadButtonText}" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
<Button Classes="settings-accent-button"
|
<Button Classes="settings-accent-button"
|
||||||
Command="{Binding InstallPendingUpdateCommand}"
|
Command="{Binding InstallPendingUpdateCommand}"
|
||||||
Content="{Binding InstallNowButtonText}"
|
IsVisible="{Binding IsInstallButtonVisible}">
|
||||||
IsVisible="{Binding IsInstallButtonVisible}" />
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<fi:SymbolIcon Symbol="Play" FontSize="14" />
|
||||||
|
<TextBlock Text="{Binding InstallNowButtonText}" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -188,25 +224,14 @@
|
|||||||
<ui:FAFontIconSource Glyph="󰿔" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
<ui:FAFontIconSource Glyph="󰿔" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||||
</ui:FASettingsExpanderItem.IconSource>
|
</ui:FASettingsExpanderItem.IconSource>
|
||||||
</ui:FASettingsExpanderItem>
|
</ui:FASettingsExpanderItem>
|
||||||
</ui:FASettingsExpander>
|
<ui:FASettingsExpanderItem Content="{Binding ForceFullUpdateLabel}"
|
||||||
|
Description="{Binding ForceFullUpdateDescription}"
|
||||||
<ui:FASettingsExpander Classes="settings-expander-card"
|
IsClickEnabled="True"
|
||||||
Header="{Binding UpdateSourceLabel}"
|
Command="{Binding ForceFullUpdateCommand}">
|
||||||
Description="{Binding SelectedUpdateSourceDescription}">
|
<ui:FASettingsExpanderItem.IconSource>
|
||||||
<ui:FASettingsExpander.IconSource>
|
<ui:FAFontIconSource Glyph="󰺟" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||||
<ui:FAFontIconSource Glyph="󱠬" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
</ui:FASettingsExpanderItem.IconSource>
|
||||||
</ui:FASettingsExpander.IconSource>
|
</ui:FASettingsExpanderItem>
|
||||||
<ui:FASettingsExpander.Footer>
|
|
||||||
<ComboBox Width="220"
|
|
||||||
ItemsSource="{Binding UpdateSourceOptions}"
|
|
||||||
SelectedItem="{Binding SelectedUpdateSourceOption}">
|
|
||||||
<ComboBox.ItemTemplate>
|
|
||||||
<DataTemplate>
|
|
||||||
<TextBlock Text="{Binding Label}" />
|
|
||||||
</DataTemplate>
|
|
||||||
</ComboBox.ItemTemplate>
|
|
||||||
</ComboBox>
|
|
||||||
</ui:FASettingsExpander.Footer>
|
|
||||||
</ui:FASettingsExpander>
|
</ui:FASettingsExpander>
|
||||||
|
|
||||||
<ui:FASettingsExpander Classes="settings-expander-card"
|
<ui:FASettingsExpander Classes="settings-expander-card"
|
||||||
@@ -228,6 +253,17 @@
|
|||||||
</ui:FASettingsExpander.Footer>
|
</ui:FASettingsExpander.Footer>
|
||||||
</ui:FASettingsExpander>
|
</ui:FASettingsExpander>
|
||||||
|
|
||||||
|
<ui:FASettingsExpander Classes="settings-expander-card"
|
||||||
|
Header="{Binding NetworkAccelerationLabel}"
|
||||||
|
Description="{Binding NetworkAccelerationDescription}">
|
||||||
|
<ui:FASettingsExpander.IconSource>
|
||||||
|
<ui:FAFontIconSource Glyph="󰆻" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
|
||||||
|
</ui:FASettingsExpander.IconSource>
|
||||||
|
<ui:FASettingsExpander.Footer>
|
||||||
|
<ToggleSwitch IsChecked="{Binding UseGhProxyMirror}" />
|
||||||
|
</ui:FASettingsExpander.Footer>
|
||||||
|
</ui:FASettingsExpander>
|
||||||
|
|
||||||
<ui:FASettingsExpander Classes="settings-expander-card"
|
<ui:FASettingsExpander Classes="settings-expander-card"
|
||||||
Header="{Binding DownloadThreadsLabel}"
|
Header="{Binding DownloadThreadsLabel}"
|
||||||
Description="{Binding DownloadThreadsDescription}">
|
Description="{Binding DownloadThreadsDescription}">
|
||||||
|
|||||||
87
LanMountainDesktop/Views/UpdateProgressDialog.axaml
Normal file
87
LanMountainDesktop/Views/UpdateProgressDialog.axaml
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||||
|
x:Class="LanMountainDesktop.Views.UpdateProgressDialog"
|
||||||
|
x:DataType="vm:UpdateProgressViewModel"
|
||||||
|
Title="阑山桌面 - Installing Update"
|
||||||
|
Width="480"
|
||||||
|
Height="320"
|
||||||
|
CanResize="False"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
WindowDecorations="None"
|
||||||
|
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||||
|
TransparencyLevelHint="None">
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid VerticalAlignment="Top" Margin="24,24,24,0">
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left" VerticalAlignment="Center" Spacing="8">
|
||||||
|
<TextBlock Text="阑山桌面"
|
||||||
|
FontSize="24"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||||
|
<Border Background="{DynamicResource AccentFillColorDefaultBrush}"
|
||||||
|
CornerRadius="4"
|
||||||
|
Padding="6,2"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="Update"
|
||||||
|
FontSize="11"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}" />
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid VerticalAlignment="Center" Margin="24,0,24,0">
|
||||||
|
<StackPanel Spacing="12" HorizontalAlignment="Stretch">
|
||||||
|
<TextBlock Text="{Binding StageText}"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
HorizontalAlignment="Center" />
|
||||||
|
|
||||||
|
<ProgressBar Minimum="0"
|
||||||
|
Maximum="100"
|
||||||
|
Value="{Binding ProgressPercent}"
|
||||||
|
Height="4"
|
||||||
|
IsIndeterminate="False"
|
||||||
|
Foreground="{DynamicResource AccentFillColorDefaultBrush}"
|
||||||
|
Background="{DynamicResource ControlStrokeColorDefaultBrush}"
|
||||||
|
HorizontalAlignment="Stretch" />
|
||||||
|
|
||||||
|
<TextBlock Text="{Binding CurrentFile}"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
|
Opacity="0.8"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
HorizontalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid VerticalAlignment="Bottom" Margin="24,0,24,24">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4" VerticalAlignment="Bottom">
|
||||||
|
<TextBlock FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
|
Opacity="0.8"
|
||||||
|
VerticalAlignment="Bottom">
|
||||||
|
<TextBlock.Text>
|
||||||
|
<MultiBinding StringFormat="{}{0} / {1} files">
|
||||||
|
<Binding Path="FilesCompleted" />
|
||||||
|
<Binding Path="FilesTotal" />
|
||||||
|
</MultiBinding>
|
||||||
|
</TextBlock.Text>
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Classes="settings-accent-button"
|
||||||
|
Content="Cancel"
|
||||||
|
Command="{Binding CancelCommand}"
|
||||||
|
IsVisible="{Binding !IsCompleted}" />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
26
LanMountainDesktop/Views/UpdateProgressDialog.axaml.cs
Normal file
26
LanMountainDesktop/Views/UpdateProgressDialog.axaml.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using LanMountainDesktop.ViewModels;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views;
|
||||||
|
|
||||||
|
public partial class UpdateProgressDialog : Window
|
||||||
|
{
|
||||||
|
public UpdateProgressDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public UpdateProgressDialog(UpdateProgressViewModel viewModel)
|
||||||
|
{
|
||||||
|
DataContext = viewModel;
|
||||||
|
InitializeComponent();
|
||||||
|
viewModel.PropertyChanged += (s, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(UpdateProgressViewModel.IsCompleted) && viewModel.IsCompleted)
|
||||||
|
{
|
||||||
|
Close(viewModel.IsSuccess);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,8 +36,12 @@ AppPublisher={#MyAppPublisher}
|
|||||||
DefaultDirName={autopf}\{#MyAppName}
|
DefaultDirName={autopf}\{#MyAppName}
|
||||||
DisableDirPage=no
|
DisableDirPage=no
|
||||||
UsePreviousAppDir=no
|
UsePreviousAppDir=no
|
||||||
ShowLanguageDialog=yes
|
; 语言对话框行为:
|
||||||
UsePreviousLanguage=no
|
; - 全新安装:显示语言选择对话框
|
||||||
|
; - 升级安装:自动沿用之前选择的语言,不弹出对话框
|
||||||
|
; - 用户可以在欢迎页面点击语言按钮手动切换
|
||||||
|
ShowLanguageDialog=auto
|
||||||
|
UsePreviousLanguage=yes
|
||||||
LanguageDetectionMethod=uilanguage
|
LanguageDetectionMethod=uilanguage
|
||||||
DefaultGroupName={cm:AppShortcutName}
|
DefaultGroupName={cm:AppShortcutName}
|
||||||
UninstallDisplayIcon={app}\{#MyAppExeName}
|
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||||
@@ -112,6 +116,10 @@ english.DotNetRuntimeOpenFailedMessage=Unable to open the download page automati
|
|||||||
chinesesimplified.DotNetRuntimeOpenFailedMessage=无法自动打开下载页面。
|
chinesesimplified.DotNetRuntimeOpenFailedMessage=无法自动打开下载页面。
|
||||||
english.DotNetRuntimeOpenFailedAction=Please open this URL manually:
|
english.DotNetRuntimeOpenFailedAction=Please open this URL manually:
|
||||||
chinesesimplified.DotNetRuntimeOpenFailedAction=请手动打开以下链接:
|
chinesesimplified.DotNetRuntimeOpenFailedAction=请手动打开以下链接:
|
||||||
|
english.LanguageButtonCaption=Language
|
||||||
|
chinesesimplified.LanguageButtonCaption=语言
|
||||||
|
english.LanguageButtonHint=Click to change the installation language
|
||||||
|
chinesesimplified.LanguageButtonHint=点击更改安装语言
|
||||||
|
|
||||||
[Tasks]
|
[Tasks]
|
||||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
|
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
|
||||||
@@ -158,6 +166,7 @@ var
|
|||||||
ExistingInstallWas64Bit: Boolean;
|
ExistingInstallWas64Bit: Boolean;
|
||||||
ExistingInstallIsPerUser: Boolean;
|
ExistingInstallIsPerUser: Boolean;
|
||||||
ExistingInstallRemoved: Boolean;
|
ExistingInstallRemoved: Boolean;
|
||||||
|
LanguageButton: TNewButton;
|
||||||
|
|
||||||
function NormalizePathValue(const Value: String): String;
|
function NormalizePathValue(const Value: String): String;
|
||||||
begin
|
begin
|
||||||
@@ -341,6 +350,59 @@ begin
|
|||||||
TryLoadExistingInstallation(HKCU32, False, True);
|
TryLoadExistingInstallation(HKCU32, False, True);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
{ 语言切换按钮点击处理 }
|
||||||
|
{ 注意:Inno Setup 不支持运行时切换语言,所以我们显示一个简单对话框,
|
||||||
|
让用户选择语言,然后重启安装程序以应用新语言 }
|
||||||
|
procedure LanguageButtonClick(Sender: TObject);
|
||||||
|
var
|
||||||
|
NewLanguage: String;
|
||||||
|
Params: String;
|
||||||
|
ResultCode: Integer;
|
||||||
|
begin
|
||||||
|
{ 根据当前语言显示对应的提示 }
|
||||||
|
if ActiveLanguage = 'chinesesimplified' then
|
||||||
|
begin
|
||||||
|
{ 当前是中文,询问是否切换到英文 }
|
||||||
|
if MsgBox('当前语言:简体中文' + #13#10#13#10 + '是否切换到 English?' + #13#10 + '(安装程序将重新启动以应用新语言)', mbConfirmation, MB_YESNO) = IDYES then
|
||||||
|
begin
|
||||||
|
NewLanguage := 'english';
|
||||||
|
end
|
||||||
|
else
|
||||||
|
begin
|
||||||
|
exit;
|
||||||
|
end;
|
||||||
|
end
|
||||||
|
else
|
||||||
|
begin
|
||||||
|
{ 当前是英文,询问是否切换到中文 }
|
||||||
|
if MsgBox('Current language: English' + #13#10#13#10 + 'Switch to 简体中文?' + #13#10 + '(The setup will restart to apply the new language)', mbConfirmation, MB_YESNO) = IDYES then
|
||||||
|
begin
|
||||||
|
NewLanguage := 'chinesesimplified';
|
||||||
|
end
|
||||||
|
else
|
||||||
|
begin
|
||||||
|
exit;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
{ 构建重启参数,带上新语言设置 }
|
||||||
|
Params := '/LANG="' + NewLanguage + '"';
|
||||||
|
if WizardSilent then
|
||||||
|
begin
|
||||||
|
Params := Params + ' /SILENT';
|
||||||
|
end;
|
||||||
|
if WizardVerySilent then
|
||||||
|
begin
|
||||||
|
Params := Params + ' /VERYSILENT';
|
||||||
|
end;
|
||||||
|
|
||||||
|
{ 重启安装程序并退出当前实例 }
|
||||||
|
if Exec(ExpandConstant('{srcexe}'), Params, '', SW_SHOWNORMAL, ewNoWait, ResultCode) then
|
||||||
|
begin
|
||||||
|
WizardForm.Close;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
function SelectedUpgradeChoice(): Integer;
|
function SelectedUpgradeChoice(): Integer;
|
||||||
begin
|
begin
|
||||||
if UpgradeModePage <> nil then
|
if UpgradeModePage <> nil then
|
||||||
@@ -589,6 +651,16 @@ var
|
|||||||
begin
|
begin
|
||||||
DetectExistingInstallation;
|
DetectExistingInstallation;
|
||||||
|
|
||||||
|
{ 在欢迎页面添加语言切换按钮 }
|
||||||
|
LanguageButton := TNewButton.Create(WizardForm);
|
||||||
|
LanguageButton.Parent := WizardForm.WelcomePage;
|
||||||
|
LanguageButton.Caption := CustomMessage('LanguageButtonCaption');
|
||||||
|
LanguageButton.Hint := CustomMessage('LanguageButtonHint');
|
||||||
|
LanguageButton.ShowHint := True;
|
||||||
|
LanguageButton.Left := WizardForm.WelcomePage.ClientWidth - LanguageButton.Width - 20;
|
||||||
|
LanguageButton.Top := 12;
|
||||||
|
LanguageButton.OnClick := @LanguageButtonClick;
|
||||||
|
|
||||||
if not ExistingInstallFound then
|
if not ExistingInstallFound then
|
||||||
begin
|
begin
|
||||||
exit;
|
exit;
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ namespace LanMountainDesktop.Services.PluginMarket;
|
|||||||
|
|
||||||
internal sealed class AirAppMarketInstallService : IDisposable
|
internal sealed class AirAppMarketInstallService : IDisposable
|
||||||
{
|
{
|
||||||
private const string LauncherExecutableName = "LanMountainDesktop.Launcher.exe";
|
|
||||||
|
|
||||||
private readonly PluginRuntimeService _runtime;
|
private readonly PluginRuntimeService _runtime;
|
||||||
private readonly LauncherClient _launcherClient = new();
|
private readonly LauncherClient _launcherClient = new();
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
@@ -83,13 +81,13 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
|||||||
{
|
{
|
||||||
if (OperatingSystem.IsWindows())
|
if (OperatingSystem.IsWindows())
|
||||||
{
|
{
|
||||||
var launcherPath = ResolveLauncherPath();
|
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
|
||||||
if (!File.Exists(launcherPath))
|
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
|
||||||
{
|
{
|
||||||
return new AirAppMarketInstallResult(
|
return new AirAppMarketInstallResult(
|
||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
$"Launcher executable was not found at '{launcherPath}'.");
|
"Launcher executable was not found. Expected it to be located in the application root directory (sibling to the app-* deployment folder).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,21 +362,6 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
|||||||
return new AirAppMarketVerificationResult(true, null);
|
return new AirAppMarketVerificationResult(true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ResolveLauncherPath()
|
|
||||||
{
|
|
||||||
var baseDirectory = AppContext.BaseDirectory;
|
|
||||||
var candidates = new[]
|
|
||||||
{
|
|
||||||
Path.Combine(baseDirectory, "Launcher", LauncherExecutableName),
|
|
||||||
Path.Combine(baseDirectory, LauncherExecutableName),
|
|
||||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", "LanMountainDesktop.Launcher", LauncherExecutableName)),
|
|
||||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Debug", "net10.0", LauncherExecutableName)),
|
|
||||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Release", "net10.0", LauncherExecutableName))
|
|
||||||
};
|
|
||||||
|
|
||||||
return candidates.FirstOrDefault(File.Exists) ?? candidates[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void TryDeleteFile(string path)
|
private static void TryDeleteFile(string path)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -49,19 +50,25 @@ public sealed class PlondsGenerator
|
|||||||
var fileEntries = BuildFileEntries(previousManifest, currentManifest, repoRoot, options.RepoBaseUrl);
|
var fileEntries = BuildFileEntries(previousManifest, currentManifest, repoRoot, options.RepoBaseUrl);
|
||||||
var installerMirrors = BuildInstallerMirrors(options.Platform, installerMirrorRoot, options.InstallerDirectory, options.InstallerBaseUrl);
|
var installerMirrors = BuildInstallerMirrors(options.Platform, installerMirrorRoot, options.InstallerDirectory, options.InstallerBaseUrl);
|
||||||
var publishedAt = DateTimeOffset.UtcNow;
|
var publishedAt = DateTimeOffset.UtcNow;
|
||||||
|
var generatedAt = DateTimeOffset.UtcNow;
|
||||||
var baselineVersion = string.IsNullOrWhiteSpace(options.BaselineVersion)
|
var baselineVersion = string.IsNullOrWhiteSpace(options.BaselineVersion)
|
||||||
? options.PreviousVersion
|
? options.PreviousVersion
|
||||||
: options.BaselineVersion;
|
: options.BaselineVersion;
|
||||||
|
var arch = ResolveArch(options.Platform);
|
||||||
|
|
||||||
var fileMap = new FileMapDocument(
|
var fileMap = new FileMapDocument(
|
||||||
FormatVersion: "1.0",
|
FormatVersion: "2.0",
|
||||||
DistributionId: distributionId,
|
DistributionId: distributionId,
|
||||||
FromVersion: options.PreviousVersion,
|
FromVersion: options.PreviousVersion,
|
||||||
ToVersion: options.CurrentVersion,
|
ToVersion: options.CurrentVersion,
|
||||||
|
Version: options.CurrentVersion,
|
||||||
Platform: options.Platform,
|
Platform: options.Platform,
|
||||||
|
Arch: arch,
|
||||||
Channel: options.Channel,
|
Channel: options.Channel,
|
||||||
PublishedAt: publishedAt,
|
PublishedAt: publishedAt,
|
||||||
Capabilities: ["file-object"],
|
GeneratedAt: generatedAt,
|
||||||
|
BaselineVersion: baselineVersion,
|
||||||
|
Capabilities: ["file-object", "compressed-object"],
|
||||||
Components:
|
Components:
|
||||||
[
|
[
|
||||||
new ComponentDocument(
|
new ComponentDocument(
|
||||||
@@ -89,12 +96,13 @@ public sealed class PlondsGenerator
|
|||||||
Version: options.CurrentVersion,
|
Version: options.CurrentVersion,
|
||||||
Channel: options.Channel,
|
Channel: options.Channel,
|
||||||
Platform: options.Platform,
|
Platform: options.Platform,
|
||||||
|
Arch: arch,
|
||||||
PublishedAt: publishedAt,
|
PublishedAt: publishedAt,
|
||||||
FileMapUrl: options.FileMapUrl,
|
FileMapUrl: options.FileMapUrl,
|
||||||
FileMapSignatureUrl: options.FileMapSignatureUrl,
|
FileMapSignatureUrl: options.FileMapSignatureUrl,
|
||||||
Components: fileMap.Components,
|
Components: fileMap.Components,
|
||||||
InstallerMirrors: installerMirrors,
|
InstallerMirrors: installerMirrors,
|
||||||
Capabilities: ["file-object"],
|
Capabilities: ["file-object", "compressed-object"],
|
||||||
Metadata: new Dictionary<string, string>
|
Metadata: new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["protocol"] = "PLONDS",
|
["protocol"] = "PLONDS",
|
||||||
@@ -135,6 +143,12 @@ public sealed class PlondsGenerator
|
|||||||
installerMirrors.Select(x => x.FileName ?? string.Empty).Where(x => !string.IsNullOrWhiteSpace(x)).ToArray());
|
installerMirrors.Select(x => x.FileName ?? string.Empty).Where(x => !string.IsNullOrWhiteSpace(x)).ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void WriteBundle(string fileMapPath, string signatureBase64)
|
||||||
|
{
|
||||||
|
var fileMapJson = File.ReadAllText(fileMapPath);
|
||||||
|
WriteBundle(fileMapPath, fileMapJson, signatureBase64);
|
||||||
|
}
|
||||||
|
|
||||||
private static Dictionary<string, FileFingerprint> ScanDirectory(string? root)
|
private static Dictionary<string, FileFingerprint> ScanDirectory(string? root)
|
||||||
{
|
{
|
||||||
var manifest = new Dictionary<string, FileFingerprint>(StringComparer.OrdinalIgnoreCase);
|
var manifest = new Dictionary<string, FileFingerprint>(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -181,12 +195,14 @@ public sealed class PlondsGenerator
|
|||||||
Mode: "file-object",
|
Mode: "file-object",
|
||||||
ObjectKey: null,
|
ObjectKey: null,
|
||||||
ObjectUrl: null,
|
ObjectUrl: null,
|
||||||
Metadata: null));
|
ArchiveSha256: null,
|
||||||
|
Metadata: new Dictionary<string, string> { ["reuseVerified"] = "true" }));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var action = previousManifest.ContainsKey(path) ? "replace" : "add";
|
var action = previousManifest.ContainsKey(path) ? "replace" : "add";
|
||||||
var objectKey = CopyContentObject(current.FullPath, repoRoot, current.Sha256);
|
var (objectKey, archiveSha256, mode) = CopyContentObjectWithCompression(
|
||||||
|
current.FullPath, repoRoot, current.Sha256, current.Size);
|
||||||
var objectUrl = string.IsNullOrWhiteSpace(repoBaseUrl)
|
var objectUrl = string.IsNullOrWhiteSpace(repoBaseUrl)
|
||||||
? null
|
? null
|
||||||
: $"{repoBaseUrl.TrimEnd('/')}/{objectKey}";
|
: $"{repoBaseUrl.TrimEnd('/')}/{objectKey}";
|
||||||
@@ -196,10 +212,11 @@ public sealed class PlondsGenerator
|
|||||||
Action: action,
|
Action: action,
|
||||||
Sha256: current.Sha256,
|
Sha256: current.Sha256,
|
||||||
Size: current.Size,
|
Size: current.Size,
|
||||||
Mode: "file-object",
|
Mode: mode,
|
||||||
ObjectKey: objectKey,
|
ObjectKey: objectKey,
|
||||||
ObjectUrl: objectUrl,
|
ObjectUrl: objectUrl,
|
||||||
Metadata: new Dictionary<string, string> { ["mode"] = "file-object" }));
|
ArchiveSha256: string.IsNullOrEmpty(archiveSha256) ? null : archiveSha256,
|
||||||
|
Metadata: new Dictionary<string, string> { ["mode"] = mode }));
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var path in previousManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
foreach (var path in previousManifest.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
||||||
@@ -214,6 +231,7 @@ public sealed class PlondsGenerator
|
|||||||
Mode: "file-object",
|
Mode: "file-object",
|
||||||
ObjectKey: null,
|
ObjectKey: null,
|
||||||
ObjectUrl: null,
|
ObjectUrl: null,
|
||||||
|
ArchiveSha256: null,
|
||||||
Metadata: null));
|
Metadata: null));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,6 +319,56 @@ public sealed class PlondsGenerator
|
|||||||
return relativeKey.Replace('\\', '/');
|
return relativeKey.Replace('\\', '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static (string ObjectKey, string ArchiveSha256, string Mode) CopyContentObjectWithCompression(
|
||||||
|
string sourcePath, string repoRoot, string sha256, long fileSize)
|
||||||
|
{
|
||||||
|
if (fileSize > 65536)
|
||||||
|
{
|
||||||
|
var compressedBytes = CompressGzip(sourcePath);
|
||||||
|
var archiveSha256 = ComputeSha256FromBytes(compressedBytes);
|
||||||
|
var archiveKey = CopyBytesToObjectStore(compressedBytes, repoRoot, archiveSha256);
|
||||||
|
return (archiveKey, archiveSha256, "compressed-object");
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = CopyContentObject(sourcePath, repoRoot, sha256);
|
||||||
|
return (key, string.Empty, "file-object");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] CompressGzip(string filePath)
|
||||||
|
{
|
||||||
|
using var input = File.OpenRead(filePath);
|
||||||
|
using var output = new MemoryStream();
|
||||||
|
using (var gzip = new GZipStream(output, CompressionMode.Compress, leaveOpen: true))
|
||||||
|
{
|
||||||
|
input.CopyTo(gzip);
|
||||||
|
}
|
||||||
|
return output.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeSha256FromBytes(byte[] data)
|
||||||
|
{
|
||||||
|
return Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CopyBytesToObjectStore(byte[] data, string repoRoot, string sha256)
|
||||||
|
{
|
||||||
|
var prefix = sha256[..Math.Min(2, sha256.Length)];
|
||||||
|
var relativeKey = $"{prefix}/{sha256}";
|
||||||
|
var destinationPath = Path.Combine(repoRoot, prefix, sha256);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||||
|
if (!File.Exists(destinationPath))
|
||||||
|
{
|
||||||
|
File.WriteAllBytes(destinationPath, data);
|
||||||
|
}
|
||||||
|
return relativeKey.Replace('\\', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteBundle(string fileMapPath, string fileMapJson, string signatureBase64)
|
||||||
|
{
|
||||||
|
var bundle = new BundleDocument(fileMapJson, signatureBase64);
|
||||||
|
WriteJson(fileMapPath + ".bundle.json", bundle);
|
||||||
|
}
|
||||||
|
|
||||||
private static string ComputeSha256(string filePath)
|
private static string ComputeSha256(string filePath)
|
||||||
{
|
{
|
||||||
using var stream = File.OpenRead(filePath);
|
using var stream = File.OpenRead(filePath);
|
||||||
@@ -320,9 +388,13 @@ public sealed class PlondsGenerator
|
|||||||
string DistributionId,
|
string DistributionId,
|
||||||
string FromVersion,
|
string FromVersion,
|
||||||
string ToVersion,
|
string ToVersion,
|
||||||
|
string Version,
|
||||||
string Platform,
|
string Platform,
|
||||||
|
string Arch,
|
||||||
string Channel,
|
string Channel,
|
||||||
DateTimeOffset PublishedAt,
|
DateTimeOffset PublishedAt,
|
||||||
|
DateTimeOffset GeneratedAt,
|
||||||
|
string? BaselineVersion,
|
||||||
IReadOnlyList<string> Capabilities,
|
IReadOnlyList<string> Capabilities,
|
||||||
IReadOnlyList<ComponentDocument> Components,
|
IReadOnlyList<ComponentDocument> Components,
|
||||||
IReadOnlyDictionary<string, string>? Metadata);
|
IReadOnlyDictionary<string, string>? Metadata);
|
||||||
@@ -332,6 +404,7 @@ public sealed class PlondsGenerator
|
|||||||
string Version,
|
string Version,
|
||||||
string Channel,
|
string Channel,
|
||||||
string Platform,
|
string Platform,
|
||||||
|
string Arch,
|
||||||
DateTimeOffset PublishedAt,
|
DateTimeOffset PublishedAt,
|
||||||
string? FileMapUrl,
|
string? FileMapUrl,
|
||||||
string? FileMapSignatureUrl,
|
string? FileMapSignatureUrl,
|
||||||
@@ -362,6 +435,7 @@ public sealed class PlondsGenerator
|
|||||||
string Mode,
|
string Mode,
|
||||||
string? ObjectKey,
|
string? ObjectKey,
|
||||||
string? ObjectUrl,
|
string? ObjectUrl,
|
||||||
|
string? ArchiveSha256,
|
||||||
IReadOnlyDictionary<string, string>? Metadata);
|
IReadOnlyDictionary<string, string>? Metadata);
|
||||||
|
|
||||||
private sealed record InstallerMirrorDocument(
|
private sealed record InstallerMirrorDocument(
|
||||||
@@ -372,4 +446,6 @@ public sealed class PlondsGenerator
|
|||||||
string? FileName,
|
string? FileName,
|
||||||
string? Sha256,
|
string? Sha256,
|
||||||
long Size);
|
long Size);
|
||||||
|
|
||||||
|
private sealed record BundleDocument(string Manifest, string Signature);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user