mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
激进的更新
This commit is contained in:
8
LanMountainDesktop.Launcher/App.axaml
Normal file
8
LanMountainDesktop.Launcher/App.axaml
Normal file
@@ -0,0 +1,8 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LanMountainDesktop.Launcher.App"
|
||||
RequestedThemeVariant="Default">
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
50
LanMountainDesktop.Launcher/App.axaml.cs
Normal file
50
LanMountainDesktop.Launcher/App.axaml.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var context = LauncherRuntimeContext.Current;
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||
|
||||
// TODO: 从配置读取 GitHub 仓库信息
|
||||
var updateCheckService = new UpdateCheckService("ClassIsland", "LanMountainDesktop");
|
||||
|
||||
var coordinator = new LauncherFlowCoordinator(
|
||||
context,
|
||||
deploymentLocator,
|
||||
new OobeStateService(appRoot),
|
||||
new UpdateEngineService(deploymentLocator),
|
||||
updateCheckService,
|
||||
new PluginInstallerService());
|
||||
|
||||
_ = RunCoordinatorAsync(desktop, coordinator);
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private static async Task RunCoordinatorAsync(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
LauncherFlowCoordinator coordinator)
|
||||
{
|
||||
var result = await coordinator.RunAsync().ConfigureAwait(false);
|
||||
await Commands.WriteResultIfNeededAsync(LauncherRuntimeContext.Current.GetOption("result"), result).ConfigureAwait(false);
|
||||
Environment.ExitCode = result.Success ? 0 : 1;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
|
||||
}
|
||||
}
|
||||
79
LanMountainDesktop.Launcher/CommandContext.cs
Normal file
79
LanMountainDesktop.Launcher/CommandContext.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace LanMountainDesktop.Launcher;
|
||||
|
||||
internal sealed class CommandContext
|
||||
{
|
||||
public string Command { get; }
|
||||
|
||||
public string SubCommand { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> Options { get; }
|
||||
|
||||
public bool IsLegacyPluginInstall =>
|
||||
Options.ContainsKey("source") &&
|
||||
Options.ContainsKey("plugins-dir") &&
|
||||
Options.ContainsKey("result");
|
||||
|
||||
private CommandContext(string command, string subCommand, Dictionary<string, string> options)
|
||||
{
|
||||
Command = command;
|
||||
SubCommand = subCommand;
|
||||
Options = options;
|
||||
}
|
||||
|
||||
public static CommandContext FromArgs(string[] args)
|
||||
{
|
||||
var options = ParseOptions(args);
|
||||
var command = args.Length > 0 && !args[0].StartsWith("--", StringComparison.Ordinal)
|
||||
? args[0]
|
||||
: "launch";
|
||||
var subCommand = args.Length > 1 && !args[1].StartsWith("--", StringComparison.Ordinal)
|
||||
? args[1]
|
||||
: string.Empty;
|
||||
|
||||
return new CommandContext(command, subCommand, options);
|
||||
}
|
||||
|
||||
public string? GetOption(string key)
|
||||
{
|
||||
return Options.TryGetValue(key, out var value) ? value : null;
|
||||
}
|
||||
|
||||
public int GetIntOption(string key, int fallback)
|
||||
{
|
||||
var raw = GetOption(key);
|
||||
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
|
||||
? value
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseOptions(string[] args)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
var current = args[i];
|
||||
if (!current.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = current[2..];
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i + 1 < args.Length && !args[i + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
values[key] = args[++i];
|
||||
continue;
|
||||
}
|
||||
|
||||
values[key] = "true";
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk" TreatAsLocalProperty="Version;PackageVersion;InformationalVersion;AssemblyVersion;FileVersion">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<PackageVersion>$(Version)</PackageVersion>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12" />
|
||||
<PackageReference Include="Microsoft.Windows.CsWin32" Version="0.3.106" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
6
LanMountainDesktop.Launcher/LauncherRuntimeContext.cs
Normal file
6
LanMountainDesktop.Launcher/LauncherRuntimeContext.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.Launcher;
|
||||
|
||||
internal static class LauncherRuntimeContext
|
||||
{
|
||||
public static CommandContext Current { get; set; } = CommandContext.FromArgs([]);
|
||||
}
|
||||
42
LanMountainDesktop.Launcher/Models/LauncherResult.cs
Normal file
42
LanMountainDesktop.Launcher/Models/LauncherResult.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
internal sealed class LauncherResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("stage")]
|
||||
public string Stage { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; init; } = "ok";
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("currentVersion")]
|
||||
public string? CurrentVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("targetVersion")]
|
||||
public string? TargetVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("rolledBackTo")]
|
||||
public string? RolledBackTo { get; init; }
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public Dictionary<string, string> Details { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("installedPackagePath")]
|
||||
public string? InstalledPackagePath { get; init; }
|
||||
|
||||
[JsonPropertyName("manifestId")]
|
||||
public string? ManifestId { get; init; }
|
||||
|
||||
[JsonPropertyName("manifestName")]
|
||||
public string? ManifestName { get; init; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
24
LanMountainDesktop.Launcher/Models/ReleaseInfo.cs
Normal file
24
LanMountainDesktop.Launcher/Models/ReleaseInfo.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
/// <summary>
|
||||
/// GitHub Release 信息
|
||||
/// </summary>
|
||||
public sealed class ReleaseInfo
|
||||
{
|
||||
public required string TagName { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required bool Prerelease { get; init; }
|
||||
public required DateTime PublishedAt { get; init; }
|
||||
public required List<ReleaseAsset> Assets { get; init; }
|
||||
public string? Body { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Release 资源文件
|
||||
/// </summary>
|
||||
public sealed class ReleaseAsset
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string BrowserDownloadUrl { get; init; }
|
||||
public required long Size { get; init; }
|
||||
}
|
||||
17
LanMountainDesktop.Launcher/Models/UpdateChannel.cs
Normal file
17
LanMountainDesktop.Launcher/Models/UpdateChannel.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 更新频道
|
||||
/// </summary>
|
||||
public enum UpdateChannel
|
||||
{
|
||||
/// <summary>
|
||||
/// 正式版 - 只检查 prerelease=false 的版本
|
||||
/// </summary>
|
||||
Stable,
|
||||
|
||||
/// <summary>
|
||||
/// 预览版 - 检查所有版本(包括 prerelease=true)
|
||||
/// </summary>
|
||||
Preview
|
||||
}
|
||||
13
LanMountainDesktop.Launcher/Models/UpdateCheckResult.cs
Normal file
13
LanMountainDesktop.Launcher/Models/UpdateCheckResult.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 更新检查结果
|
||||
/// </summary>
|
||||
public sealed class UpdateCheckResult
|
||||
{
|
||||
public bool HasUpdate { get; init; }
|
||||
public string? LatestVersion { get; init; }
|
||||
public string? CurrentVersion { get; init; }
|
||||
public ReleaseInfo? Release { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
55
LanMountainDesktop.Launcher/Models/UpdateModels.cs
Normal file
55
LanMountainDesktop.Launcher/Models/UpdateModels.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
namespace LanMountainDesktop.Launcher.Models;
|
||||
|
||||
internal sealed class SignedFileMap
|
||||
{
|
||||
public string? FromVersion { get; set; }
|
||||
|
||||
public string? ToVersion { get; set; }
|
||||
|
||||
public string? Platform { get; set; }
|
||||
|
||||
public string? Arch { get; set; }
|
||||
|
||||
public List<UpdateFileEntry> Files { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class UpdateFileEntry
|
||||
{
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
public string? ArchivePath { get; set; }
|
||||
|
||||
public string Action { get; set; } = "replace";
|
||||
|
||||
public string? Sha256 { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class SnapshotMetadata
|
||||
{
|
||||
public string SnapshotId { get; set; } = string.Empty;
|
||||
|
||||
public string SourceVersion { get; set; } = string.Empty;
|
||||
|
||||
public string? TargetVersion { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public string SourceDirectory { get; set; } = string.Empty;
|
||||
|
||||
public string? TargetDirectory { get; set; }
|
||||
|
||||
public string Status { get; set; } = "pending";
|
||||
}
|
||||
|
||||
internal sealed class UpdateApplyResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
public string? FromVersion { get; init; }
|
||||
|
||||
public string? ToVersion { get; init; }
|
||||
|
||||
public string? RolledBackTo { get; init; }
|
||||
}
|
||||
1
LanMountainDesktop.Launcher/NativeMethods.txt
Normal file
1
LanMountainDesktop.Launcher/NativeMethods.txt
Normal file
@@ -0,0 +1 @@
|
||||
MessageBox
|
||||
191
LanMountainDesktop.Launcher/Program.cs
Normal file
191
LanMountainDesktop.Launcher/Program.cs
Normal file
@@ -0,0 +1,191 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
|
||||
#if WINDOWS
|
||||
using Windows.Win32;
|
||||
using Windows.Win32.Foundation;
|
||||
using Windows.Win32.UI.WindowsAndMessaging;
|
||||
#endif
|
||||
|
||||
namespace LanMountainDesktop.Launcher;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
private static async Task<int> Main(string[] args)
|
||||
{
|
||||
var commandContext = CommandContext.FromArgs(args);
|
||||
|
||||
// 处理遗留插件安装命令
|
||||
if (commandContext.IsLegacyPluginInstall)
|
||||
{
|
||||
var installer = new PluginInstallerService();
|
||||
return await Commands.RunLegacyPluginInstallAsync(commandContext, installer).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// 处理其他 CLI 命令 (update, plugin, rollback 等)
|
||||
if (!string.Equals(commandContext.Command, "launch", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return await Commands.RunCliCommandAsync(commandContext).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// 主启动流程: OOBE -> Splash -> 版本选择 -> 启动主程序
|
||||
LauncherRuntimeContext.Current = commandContext;
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
return Environment.ExitCode;
|
||||
}
|
||||
|
||||
private static int LaunchMainApplication(string[] args)
|
||||
{
|
||||
// 获取可执行文件名
|
||||
string executableName = OperatingSystem.IsWindows()
|
||||
? "LanMountainDesktop.exe"
|
||||
: "LanMountainDesktop";
|
||||
|
||||
// 获取安装根目录
|
||||
var rootDir = Path.GetFullPath(Path.GetDirectoryName(Environment.ProcessPath) ?? "");
|
||||
|
||||
// 查找最佳版本
|
||||
var installation = FindBestVersion(rootDir, executableName);
|
||||
|
||||
if (installation == null)
|
||||
{
|
||||
ShowError("找不到有效的 LanMountainDesktop 版本,可能是安装已损坏。\n请访问 https://github.com/ClassIsland/LanMountainDesktop 重新下载并安装。");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var exePath = Path.Combine(installation, executableName);
|
||||
|
||||
// Linux/macOS: 自动添加可执行权限
|
||||
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
|
||||
{
|
||||
try
|
||||
{
|
||||
var chmod = Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "chmod",
|
||||
Arguments = $"+x \"{exePath}\"",
|
||||
CreateNoWindow = true
|
||||
});
|
||||
chmod?.WaitForExit();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"无法设置可执行权限: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// 清理待删除的旧版本
|
||||
CleanupDestroyedVersions(rootDir);
|
||||
|
||||
// 启动主程序
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = exePath,
|
||||
WorkingDirectory = rootDir,
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
foreach (var arg in args)
|
||||
{
|
||||
startInfo.ArgumentList.Add(arg);
|
||||
}
|
||||
|
||||
// 传递包根目录环境变量
|
||||
startInfo.EnvironmentVariables["LanMountainDesktop_PackageRoot"] = rootDir;
|
||||
|
||||
try
|
||||
{
|
||||
Process.Start(startInfo);
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowError($"启动主程序失败: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindBestVersion(string rootDir, string executableName)
|
||||
{
|
||||
return Directory.GetDirectories(rootDir)
|
||||
.Where(x =>
|
||||
{
|
||||
var dirName = Path.GetFileName(x);
|
||||
return dirName.StartsWith("app-") &&
|
||||
!File.Exists(Path.Combine(x, ".destroy")) &&
|
||||
!File.Exists(Path.Combine(x, ".partial")) &&
|
||||
File.Exists(Path.Combine(x, executableName));
|
||||
})
|
||||
.OrderBy(x => File.Exists(Path.Combine(x, ".current")) ? 0 : 1) // .current 优先
|
||||
.ThenByDescending(x => ParseVersion(Path.GetFileName(x))) // 版本号降序
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static Version ParseVersion(string dirName)
|
||||
{
|
||||
// 从 "app-1.0.0" 格式解析版本号
|
||||
var parts = dirName.Split('-');
|
||||
if (parts.Length >= 2 && Version.TryParse(parts[1], out var version))
|
||||
{
|
||||
return version;
|
||||
}
|
||||
return new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
private static void CleanupDestroyedVersions(string rootDir)
|
||||
{
|
||||
try
|
||||
{
|
||||
var destroyedDirs = Directory.GetDirectories(rootDir)
|
||||
.Where(x => File.Exists(Path.Combine(x, ".destroy")));
|
||||
|
||||
foreach (var dir in destroyedDirs)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略删除失败(可能文件被占用),下次启动再试
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略清理失败
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowError(string message)
|
||||
{
|
||||
#if WINDOWS
|
||||
try
|
||||
{
|
||||
PInvoke.MessageBox(
|
||||
HWND.Null,
|
||||
message,
|
||||
"LanMountainDesktop Launcher",
|
||||
MESSAGEBOX_STYLE.MB_ICONERROR | MESSAGEBOX_STYLE.MB_OK
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Console.Error.WriteLine(message);
|
||||
}
|
||||
#else
|
||||
Console.Error.WriteLine(message);
|
||||
#endif
|
||||
}
|
||||
|
||||
private static AppBuilder BuildAvaloniaApp()
|
||||
{
|
||||
return AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
}
|
||||
29
LanMountainDesktop.Launcher/Properties/launchSettings.json
Normal file
29
LanMountainDesktop.Launcher/Properties/launchSettings.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"Launcher (Launch Mode)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "launch",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Update Check)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "update check",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Launcher (Plugin Install)": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "plugin install <path-to-plugin.laapp>",
|
||||
"workingDirectory": "$(SolutionDir)",
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
174
LanMountainDesktop.Launcher/Services/Commands.cs
Normal file
174
LanMountainDesktop.Launcher/Services/Commands.cs
Normal file
@@ -0,0 +1,174 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal static class Commands
|
||||
{
|
||||
public static async Task<int> RunLegacyPluginInstallAsync(CommandContext context, PluginInstallerService installer)
|
||||
{
|
||||
var resultPath = context.GetOption("result");
|
||||
LauncherResult result;
|
||||
try
|
||||
{
|
||||
var source = context.GetOption("source") ?? string.Empty;
|
||||
var pluginsDir = context.GetOption("plugins-dir") ?? string.Empty;
|
||||
result = installer.InstallPackage(source, pluginsDir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result = new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "plugin.install",
|
||||
Code = "failed",
|
||||
Message = ex.Message,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
|
||||
await WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false);
|
||||
return result.Success ? 0 : 1;
|
||||
}
|
||||
|
||||
public static async Task<int> RunCliCommandAsync(CommandContext context)
|
||||
{
|
||||
var appRoot = ResolveAppRoot(context);
|
||||
var deploymentLocator = new DeploymentLocator(appRoot);
|
||||
var updateEngine = new UpdateEngineService(deploymentLocator);
|
||||
var pluginInstaller = new PluginInstallerService();
|
||||
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
|
||||
|
||||
LauncherResult result;
|
||||
try
|
||||
{
|
||||
result = await ExecuteCoreAsync(context, updateEngine, pluginInstaller, pluginUpgrades).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result = new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "command",
|
||||
Code = "exception",
|
||||
Message = ex.Message,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
|
||||
await WriteResultIfNeededAsync(context.GetOption("result"), result).ConfigureAwait(false);
|
||||
return result.Success ? 0 : 1;
|
||||
}
|
||||
|
||||
private static async Task<LauncherResult> ExecuteCoreAsync(
|
||||
CommandContext context,
|
||||
UpdateEngineService updateEngine,
|
||||
PluginInstallerService pluginInstaller,
|
||||
PluginUpgradeQueueService pluginUpgrades)
|
||||
{
|
||||
switch (context.Command.ToLowerInvariant())
|
||||
{
|
||||
case "update":
|
||||
return await ExecuteUpdateAsync(context, updateEngine).ConfigureAwait(false);
|
||||
case "plugin":
|
||||
return ExecutePluginCommand(context, pluginInstaller, pluginUpgrades);
|
||||
default:
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "command",
|
||||
Code = "unsupported_command",
|
||||
Message = $"Unsupported command '{context.Command}'."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<LauncherResult> ExecuteUpdateAsync(CommandContext context, UpdateEngineService updateEngine)
|
||||
{
|
||||
return context.SubCommand.ToLowerInvariant() switch
|
||||
{
|
||||
"check" => updateEngine.CheckPendingUpdate(),
|
||||
"apply" => updateEngine.ApplyPendingUpdate(),
|
||||
"rollback" => updateEngine.RollbackLatest(),
|
||||
"download" => await updateEngine.DownloadAsync(
|
||||
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
|
||||
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
|
||||
context.GetOption("archive-url") ?? throw new InvalidOperationException("Missing --archive-url."),
|
||||
CancellationToken.None).ConfigureAwait(false),
|
||||
_ => new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "update",
|
||||
Code = "unsupported_subcommand",
|
||||
Message = $"Unsupported update sub-command '{context.SubCommand}'."
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static LauncherResult ExecutePluginCommand(
|
||||
CommandContext context,
|
||||
PluginInstallerService pluginInstaller,
|
||||
PluginUpgradeQueueService pluginUpgrades)
|
||||
{
|
||||
switch (context.SubCommand.ToLowerInvariant())
|
||||
{
|
||||
case "install":
|
||||
{
|
||||
var source = context.GetOption("source") ?? throw new InvalidOperationException("Missing --source.");
|
||||
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
||||
return pluginInstaller.InstallPackage(source, pluginsDir);
|
||||
}
|
||||
case "update":
|
||||
{
|
||||
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
||||
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
|
||||
}
|
||||
default:
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "plugin",
|
||||
Code = "unsupported_subcommand",
|
||||
Message = $"Unsupported plugin sub-command '{context.SubCommand}'."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task WriteResultIfNeededAsync(string? resultPath, LauncherResult result)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(resultPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(resultPath);
|
||||
var dir = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrWhiteSpace(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
await File.WriteAllTextAsync(fullPath, json, Encoding.UTF8).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static string ResolveAppRoot(CommandContext context)
|
||||
{
|
||||
var configured = context.GetOption("app-root");
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
return Path.GetFullPath(configured);
|
||||
}
|
||||
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
var parent = Path.GetFullPath(Path.Combine(baseDir, ".."));
|
||||
var parentHost = OperatingSystem.IsWindows()
|
||||
? Path.Combine(parent, "LanMountainDesktop.exe")
|
||||
: Path.Combine(parent, "LanMountainDesktop");
|
||||
return File.Exists(parentHost) ? parent : baseDir;
|
||||
}
|
||||
}
|
||||
160
LanMountainDesktop.Launcher/Services/DeploymentLocator.cs
Normal file
160
LanMountainDesktop.Launcher/Services/DeploymentLocator.cs
Normal file
@@ -0,0 +1,160 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class DeploymentLocator
|
||||
{
|
||||
private readonly string _appRoot;
|
||||
|
||||
public DeploymentLocator(string appRoot)
|
||||
{
|
||||
_appRoot = appRoot;
|
||||
}
|
||||
|
||||
public string GetAppRoot() => _appRoot;
|
||||
|
||||
public string? FindCurrentDeploymentDirectory()
|
||||
{
|
||||
var candidates = Directory.Exists(_appRoot)
|
||||
? Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)
|
||||
: [];
|
||||
|
||||
// 过滤掉无效的部署目录
|
||||
var validCandidates = candidates
|
||||
.Where(path =>
|
||||
!File.Exists(Path.Combine(path, ".destroy")) && // 排除待删除
|
||||
!File.Exists(Path.Combine(path, ".partial"))) // 排除未完成
|
||||
.ToList();
|
||||
|
||||
// 优先选择带 .current 标记的版本
|
||||
var withMarkers = validCandidates
|
||||
.Where(path => File.Exists(Path.Combine(path, ".current")))
|
||||
.Select(path => new
|
||||
{
|
||||
Path = path,
|
||||
Version = ParseVersionFromDirectory(path)
|
||||
})
|
||||
.OrderByDescending(item => item.Version)
|
||||
.ToList();
|
||||
|
||||
if (withMarkers.Count > 0)
|
||||
{
|
||||
return withMarkers[0].Path;
|
||||
}
|
||||
|
||||
// 如果没有 .current 标记,选择最新版本
|
||||
var byVersion = validCandidates
|
||||
.Select(path => new
|
||||
{
|
||||
Path = path,
|
||||
Version = ParseVersionFromDirectory(path)
|
||||
})
|
||||
.OrderByDescending(item => item.Version)
|
||||
.ToList();
|
||||
|
||||
return byVersion.Count > 0 ? byVersion[0].Path : null;
|
||||
}
|
||||
|
||||
public string? ResolveHostExecutablePath()
|
||||
{
|
||||
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||
var currentDeployment = FindCurrentDeploymentDirectory();
|
||||
if (!string.IsNullOrWhiteSpace(currentDeployment))
|
||||
{
|
||||
var inDeployment = Path.Combine(currentDeployment, executable);
|
||||
if (File.Exists(inDeployment))
|
||||
{
|
||||
return inDeployment;
|
||||
}
|
||||
}
|
||||
|
||||
var inRoot = Path.Combine(_appRoot, executable);
|
||||
if (File.Exists(inRoot))
|
||||
{
|
||||
return inRoot;
|
||||
}
|
||||
|
||||
var parent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
|
||||
var inParent = Path.Combine(parent, executable);
|
||||
return File.Exists(inParent) ? inParent : null;
|
||||
}
|
||||
|
||||
public string GetCurrentVersion()
|
||||
{
|
||||
var deployment = FindCurrentDeploymentDirectory();
|
||||
if (string.IsNullOrWhiteSpace(deployment))
|
||||
{
|
||||
return "0.0.0";
|
||||
}
|
||||
|
||||
return ParseVersionTextFromDirectory(deployment) ?? "0.0.0";
|
||||
}
|
||||
|
||||
public string BuildNextDeploymentDirectory(string targetVersion)
|
||||
{
|
||||
var sanitized = string.IsNullOrWhiteSpace(targetVersion) ? "0.0.0" : targetVersion.Trim();
|
||||
var index = 0;
|
||||
while (true)
|
||||
{
|
||||
var candidate = Path.Combine(_appRoot, $"app-{sanitized}-{index.ToString(CultureInfo.InvariantCulture)}");
|
||||
if (!Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
public void CleanupDestroyedDeployments()
|
||||
{
|
||||
try
|
||||
{
|
||||
var candidates = Directory.Exists(_appRoot)
|
||||
? Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)
|
||||
: [];
|
||||
|
||||
var destroyedDirs = candidates
|
||||
.Where(path => File.Exists(Path.Combine(path, ".destroy")));
|
||||
|
||||
foreach (var dir in destroyedDirs)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略删除失败(可能文件被占用),下次启动再试
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略清理失败
|
||||
}
|
||||
}
|
||||
|
||||
public static Version ParseVersionFromDirectory(string path)
|
||||
{
|
||||
var text = ParseVersionTextFromDirectory(path);
|
||||
return Version.TryParse(text, out var version) ? version : new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
private static string? ParseVersionTextFromDirectory(string path)
|
||||
{
|
||||
var fileName = Path.GetFileName(path);
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var segments = fileName.Split('-');
|
||||
if (segments.Length < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return segments[1];
|
||||
}
|
||||
}
|
||||
6
LanMountainDesktop.Launcher/Services/IOobeStep.cs
Normal file
6
LanMountainDesktop.Launcher/Services/IOobeStep.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal interface IOobeStep
|
||||
{
|
||||
Task RunAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal interface ISplashStageReporter
|
||||
{
|
||||
void Report(string stage, string message);
|
||||
}
|
||||
204
LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
Normal file
204
LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
Normal file
@@ -0,0 +1,204 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class LauncherFlowCoordinator
|
||||
{
|
||||
private readonly CommandContext _context;
|
||||
private readonly DeploymentLocator _deploymentLocator;
|
||||
private readonly OobeStateService _oobeStateService;
|
||||
private readonly UpdateEngineService _updateEngine;
|
||||
private readonly UpdateCheckService _updateCheckService;
|
||||
private readonly PluginInstallerService _pluginInstallerService;
|
||||
private readonly ISplashStageReporter _splashStageReporter;
|
||||
private readonly IReadOnlyList<IOobeStep> _oobeSteps;
|
||||
|
||||
public LauncherFlowCoordinator(
|
||||
CommandContext context,
|
||||
DeploymentLocator deploymentLocator,
|
||||
OobeStateService oobeStateService,
|
||||
UpdateEngineService updateEngine,
|
||||
UpdateCheckService updateCheckService,
|
||||
PluginInstallerService pluginInstallerService)
|
||||
{
|
||||
_context = context;
|
||||
_deploymentLocator = deploymentLocator;
|
||||
_oobeStateService = oobeStateService;
|
||||
_updateEngine = updateEngine;
|
||||
_updateCheckService = updateCheckService;
|
||||
_pluginInstallerService = pluginInstallerService;
|
||||
_splashStageReporter = new NullSplashStageReporter();
|
||||
_oobeSteps = [new WelcomeOobeStep(_oobeStateService)];
|
||||
}
|
||||
|
||||
public async Task<LauncherResult> RunAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 清理待删除的旧版本
|
||||
_deploymentLocator.CleanupDestroyedDeployments();
|
||||
|
||||
_splashStageReporter.Report("bootstrap", "bootstrap");
|
||||
if (_oobeStateService.IsFirstRun())
|
||||
{
|
||||
foreach (var step in _oobeSteps)
|
||||
{
|
||||
await step.RunAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
var splashWindow = await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
var window = new SplashWindow();
|
||||
window.Show();
|
||||
return window;
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
_splashStageReporter.Report("silentUpdate", "update");
|
||||
var updateResult = _updateEngine.ApplyPendingUpdate();
|
||||
if (!updateResult.Success)
|
||||
{
|
||||
return updateResult;
|
||||
}
|
||||
|
||||
_splashStageReporter.Report("pluginTasks", "plugins");
|
||||
var pluginsDir = _context.GetOption("plugins-dir")
|
||||
?? Path.Combine(_deploymentLocator.GetAppRoot(), "plugins");
|
||||
var queueResult = new PluginUpgradeQueueService(_pluginInstallerService).ApplyPendingUpgrades(pluginsDir);
|
||||
if (!queueResult.Success)
|
||||
{
|
||||
return queueResult;
|
||||
}
|
||||
|
||||
_splashStageReporter.Report("launchHost", "launch");
|
||||
var hostResult = LaunchHost();
|
||||
if (!hostResult.Success)
|
||||
{
|
||||
return hostResult;
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "exit",
|
||||
Code = "ok",
|
||||
Message = "Launcher completed successfully."
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Close());
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launch",
|
||||
Code = "exception",
|
||||
Message = ex.Message,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private LauncherResult LaunchHost()
|
||||
{
|
||||
var hostPath = _deploymentLocator.ResolveHostExecutablePath();
|
||||
if (string.IsNullOrWhiteSpace(hostPath))
|
||||
{
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "launchHost",
|
||||
Code = "host_not_found",
|
||||
Message = "LanMountainDesktop host executable not found."
|
||||
};
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
|
||||
{
|
||||
EnsureExecutable(hostPath);
|
||||
}
|
||||
|
||||
var processStartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = hostPath,
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot()
|
||||
};
|
||||
|
||||
Process.Start(processStartInfo);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "launchHost",
|
||||
Code = "ok",
|
||||
Message = "Host launched."
|
||||
};
|
||||
}
|
||||
|
||||
private static void EnsureExecutable(string path)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var mode = File.GetUnixFileMode(path);
|
||||
mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
|
||||
File.SetUnixFileMode(path, mode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class WelcomeOobeStep : IOobeStep
|
||||
{
|
||||
private readonly OobeStateService _stateService;
|
||||
|
||||
public WelcomeOobeStep(OobeStateService stateService)
|
||||
{
|
||||
_stateService = stateService;
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var window = await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
var oobeWindow = new OobeWindow();
|
||||
oobeWindow.Show();
|
||||
return oobeWindow;
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
using var _ = cancellationToken.Register(() => window.Close());
|
||||
await window.WaitForEnterAsync().ConfigureAwait(false);
|
||||
_stateService.MarkCompleted();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => window.Close());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NullSplashStageReporter : ISplashStageReporter
|
||||
{
|
||||
public void Report(string stage, string message)
|
||||
{
|
||||
_ = stage;
|
||||
_ = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
29
LanMountainDesktop.Launcher/Services/OobeStateService.cs
Normal file
29
LanMountainDesktop.Launcher/Services/OobeStateService.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class OobeStateService
|
||||
{
|
||||
private readonly string _markerPath;
|
||||
|
||||
public OobeStateService(string appRoot)
|
||||
{
|
||||
var stateDir = Path.Combine(appRoot, ".launcher", "state");
|
||||
Directory.CreateDirectory(stateDir);
|
||||
_markerPath = Path.Combine(stateDir, "first_run_completed");
|
||||
}
|
||||
|
||||
public bool IsFirstRun()
|
||||
{
|
||||
return !File.Exists(_markerPath);
|
||||
}
|
||||
|
||||
public void MarkCompleted()
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_markerPath);
|
||||
if (!string.IsNullOrWhiteSpace(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
File.WriteAllText(_markerPath, DateTimeOffset.UtcNow.ToString("O"));
|
||||
}
|
||||
}
|
||||
200
LanMountainDesktop.Launcher/Services/PluginInstallerService.cs
Normal file
200
LanMountainDesktop.Launcher/Services/PluginInstallerService.cs
Normal file
@@ -0,0 +1,200 @@
|
||||
using System.IO.Compression;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class PluginInstallerService
|
||||
{
|
||||
private static readonly TimeSpan[] RetryDelays =
|
||||
[
|
||||
TimeSpan.FromMilliseconds(120),
|
||||
TimeSpan.FromMilliseconds(250),
|
||||
TimeSpan.FromMilliseconds(500)
|
||||
];
|
||||
|
||||
public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory)
|
||||
{
|
||||
var fullSourcePath = Path.GetFullPath(sourcePath);
|
||||
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
|
||||
|
||||
if (!File.Exists(fullSourcePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
||||
}
|
||||
|
||||
var manifest = ReadManifestFromPackage(fullSourcePath);
|
||||
Directory.CreateDirectory(fullPluginsDirectory);
|
||||
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
||||
var stagingPath = destinationPath + ".incoming";
|
||||
DeleteFileWithRetry(stagingPath);
|
||||
CopyWithRetry(fullSourcePath, stagingPath, overwrite: true);
|
||||
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath);
|
||||
MoveWithOverwriteRetry(stagingPath, destinationPath);
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "plugin.install",
|
||||
Code = "ok",
|
||||
Message = "Plugin installed.",
|
||||
InstalledPackagePath = destinationPath,
|
||||
ManifestId = manifest.Id,
|
||||
ManifestName = manifest.Name
|
||||
};
|
||||
}
|
||||
|
||||
public PluginManifest ReadManifestFromPackage(string packagePath)
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(packagePath);
|
||||
var entries = archive.Entries
|
||||
.Where(entry => string.Equals(entry.Name, PluginSdkInfo.ManifestFileName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin package '{packagePath}' does not contain '{PluginSdkInfo.ManifestFileName}'.");
|
||||
}
|
||||
|
||||
if (entries.Length > 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin package '{packagePath}' contains multiple '{PluginSdkInfo.ManifestFileName}' files.");
|
||||
}
|
||||
|
||||
using var stream = entries[0].Open();
|
||||
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
|
||||
}
|
||||
|
||||
private void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
|
||||
{
|
||||
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), PluginSdkInfo.RuntimeDirectoryName));
|
||||
var pendingDeletionDir = Path.Combine(pluginsDirectory, ".pending-deletions");
|
||||
Directory.CreateDirectory(pendingDeletionDir);
|
||||
|
||||
foreach (var existingPackagePath in Directory
|
||||
.EnumerateFiles(pluginsDirectory, "*" + PluginSdkInfo.PackageFileExtension, SearchOption.AllDirectories)
|
||||
.Select(Path.GetFullPath)
|
||||
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.Equals(existingPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(existingPackagePath, Path.GetFullPath(stagingPath), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var existingManifest = ReadManifestFromPackage(existingPackagePath);
|
||||
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TryRemoveExistingPackage(existingPackagePath, pendingDeletionDir);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
CleanupPendingDeletions(pendingDeletionDir);
|
||||
}
|
||||
|
||||
private void TryRemoveExistingPackage(string existingPackagePath, string pendingDeletionDir)
|
||||
{
|
||||
try
|
||||
{
|
||||
DeleteFileWithRetry(existingPackagePath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
var fileName = Path.GetFileName(existingPackagePath);
|
||||
var pendingPath = Path.Combine(pendingDeletionDir, $"{fileName}.{Guid.NewGuid():N}.pending");
|
||||
File.Move(existingPackagePath, pendingPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CleanupPendingDeletions(string pendingDeletionDir)
|
||||
{
|
||||
if (!Directory.Exists(pendingDeletionDir))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var pendingFile in Directory.EnumerateFiles(pendingDeletionDir, "*.pending"))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(pendingFile);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyWithRetry(string sourcePath, string destinationPath, bool overwrite)
|
||||
{
|
||||
Retry(() => File.Copy(sourcePath, destinationPath, overwrite));
|
||||
}
|
||||
|
||||
private static void MoveWithOverwriteRetry(string sourcePath, string destinationPath)
|
||||
{
|
||||
Retry(() => File.Move(sourcePath, destinationPath, overwrite: true));
|
||||
}
|
||||
|
||||
private static void DeleteFileWithRetry(string filePath)
|
||||
{
|
||||
Retry(() =>
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void Retry(Action action)
|
||||
{
|
||||
Exception? lastException = null;
|
||||
for (var attempt = 0; attempt <= RetryDelays.Length; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
lastException = ex;
|
||||
if (attempt >= RetryDelays.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
Thread.Sleep(RetryDelays[attempt]);
|
||||
}
|
||||
}
|
||||
|
||||
if (lastException is not null)
|
||||
{
|
||||
throw lastException;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildInstalledPackageFileName(string pluginId)
|
||||
{
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||
return fileName + PluginSdkInfo.PackageFileExtension;
|
||||
}
|
||||
|
||||
private static string EnsureTrailingSeparator(string path)
|
||||
{
|
||||
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
||||
? path
|
||||
: path + Path.DirectorySeparatorChar;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class PluginUpgradeQueueService
|
||||
{
|
||||
private const string PendingUpgradesFileName = ".pending-plugin-upgrades.json";
|
||||
|
||||
private readonly PluginInstallerService _installerService;
|
||||
|
||||
public PluginUpgradeQueueService(PluginInstallerService installerService)
|
||||
{
|
||||
_installerService = installerService;
|
||||
}
|
||||
|
||||
public LauncherResult ApplyPendingUpgrades(string pluginsDirectory)
|
||||
{
|
||||
var pendingPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName);
|
||||
if (!File.Exists(pendingPath))
|
||||
{
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "plugin.update",
|
||||
Code = "noop",
|
||||
Message = "No pending plugin upgrades."
|
||||
};
|
||||
}
|
||||
|
||||
var text = File.ReadAllText(pendingPath);
|
||||
var pending = JsonSerializer.Deserialize<List<PendingUpgrade>>(text) ?? [];
|
||||
var failures = new List<string>();
|
||||
var succeeded = new List<PendingUpgrade>();
|
||||
|
||||
foreach (var item in pending)
|
||||
{
|
||||
if (!item.IsValid())
|
||||
{
|
||||
failures.Add(item.PluginId);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory);
|
||||
succeeded.Add(item);
|
||||
}
|
||||
catch
|
||||
{
|
||||
failures.Add(item.PluginId);
|
||||
}
|
||||
}
|
||||
|
||||
var remaining = pending
|
||||
.Except(succeeded)
|
||||
.Where(item => failures.Contains(item.PluginId, StringComparer.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (remaining.Count == 0)
|
||||
{
|
||||
File.Delete(pendingPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
}));
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = failures.Count == 0,
|
||||
Stage = "plugin.update",
|
||||
Code = failures.Count == 0 ? "ok" : "partial_failed",
|
||||
Message = failures.Count == 0
|
||||
? $"Applied {succeeded.Count} pending plugin upgrade(s)."
|
||||
: $"Applied {succeeded.Count} upgrades, failed: {string.Join(", ", failures)}."
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record PendingUpgrade(
|
||||
string PluginId,
|
||||
string SourcePackagePath,
|
||||
string TargetVersion,
|
||||
DateTimeOffset CreatedAt)
|
||||
{
|
||||
public bool IsValid()
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(PluginId) &&
|
||||
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
|
||||
!string.IsNullOrWhiteSpace(TargetVersion) &&
|
||||
File.Exists(SourcePackagePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
168
LanMountainDesktop.Launcher/Services/UpdateCheckService.cs
Normal file
168
LanMountainDesktop.Launcher/Services/UpdateCheckService.cs
Normal file
@@ -0,0 +1,168 @@
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 更新检查服务 - 基于 GitHub Release API
|
||||
/// </summary>
|
||||
internal sealed class UpdateCheckService
|
||||
{
|
||||
private const string GitHubApiBase = "https://api.github.com";
|
||||
private readonly string _repoOwner;
|
||||
private readonly string _repoName;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public UpdateCheckService(string repoOwner, string repoName)
|
||||
{
|
||||
_repoOwner = repoOwner;
|
||||
_repoName = repoName;
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "LanMountainDesktop-Launcher");
|
||||
_httpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json");
|
||||
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查更新
|
||||
/// </summary>
|
||||
public async Task<UpdateCheckResult> CheckForUpdateAsync(
|
||||
string currentVersion,
|
||||
UpdateChannel channel,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var releases = await FetchReleasesAsync(cancellationToken);
|
||||
|
||||
// 根据频道过滤版本
|
||||
var filteredReleases = channel == UpdateChannel.Stable
|
||||
? releases.Where(r => !r.Prerelease).ToList()
|
||||
: releases;
|
||||
|
||||
// 找到最新版本
|
||||
var latestRelease = filteredReleases
|
||||
.OrderByDescending(r => ParseVersion(r.TagName))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (latestRelease == null)
|
||||
{
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
HasUpdate = false,
|
||||
CurrentVersion = currentVersion,
|
||||
ErrorMessage = "No releases found"
|
||||
};
|
||||
}
|
||||
|
||||
var latestVersion = ParseVersionString(latestRelease.TagName);
|
||||
var current = ParseVersion(currentVersion);
|
||||
var latest = ParseVersion(latestVersion);
|
||||
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
HasUpdate = latest > current,
|
||||
LatestVersion = latestVersion,
|
||||
CurrentVersion = currentVersion,
|
||||
Release = latestRelease
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
HasUpdate = false,
|
||||
CurrentVersion = currentVersion,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有 Release
|
||||
/// </summary>
|
||||
private async Task<List<ReleaseInfo>> FetchReleasesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var url = $"{GitHubApiBase}/repos/{_repoOwner}/{_repoName}/releases";
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var releases = JsonSerializer.Deserialize<List<GitHubRelease>>(json, _jsonOptions);
|
||||
|
||||
return releases?.Select(r => new ReleaseInfo
|
||||
{
|
||||
TagName = r.TagName ?? "",
|
||||
Name = r.Name ?? "",
|
||||
Prerelease = r.Prerelease,
|
||||
PublishedAt = r.PublishedAt,
|
||||
Body = r.Body,
|
||||
Assets = r.Assets?.Select(a => new ReleaseAsset
|
||||
{
|
||||
Name = a.Name ?? "",
|
||||
BrowserDownloadUrl = a.BrowserDownloadUrl ?? "",
|
||||
Size = a.Size
|
||||
}).ToList() ?? []
|
||||
}).ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从 tag 解析版本号 (例如: v1.0.0 -> 1.0.0)
|
||||
/// </summary>
|
||||
private static string ParseVersionString(string tag)
|
||||
{
|
||||
return tag.TrimStart('v', 'V');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析版本号
|
||||
/// </summary>
|
||||
private static Version ParseVersion(string versionString)
|
||||
{
|
||||
var cleaned = ParseVersionString(versionString);
|
||||
return Version.TryParse(cleaned, out var version) ? version : new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
// GitHub API 响应模型
|
||||
private sealed class GitHubRelease
|
||||
{
|
||||
[JsonPropertyName("tag_name")]
|
||||
public string? TagName { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("prerelease")]
|
||||
public bool Prerelease { get; set; }
|
||||
|
||||
[JsonPropertyName("published_at")]
|
||||
public DateTime PublishedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("body")]
|
||||
public string? Body { get; set; }
|
||||
|
||||
[JsonPropertyName("assets")]
|
||||
public List<GitHubAsset>? Assets { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GitHubAsset
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("browser_download_url")]
|
||||
public string? BrowserDownloadUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; set; }
|
||||
}
|
||||
}
|
||||
512
LanMountainDesktop.Launcher/Services/UpdateEngineService.cs
Normal file
512
LanMountainDesktop.Launcher/Services/UpdateEngineService.cs
Normal file
@@ -0,0 +1,512 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
internal sealed class UpdateEngineService
|
||||
{
|
||||
private const string LauncherDirectoryName = ".launcher";
|
||||
private const string UpdateDirectoryName = "update";
|
||||
private const string IncomingDirectoryName = "incoming";
|
||||
private const string SnapshotsDirectoryName = "snapshots";
|
||||
private const string SignedFileMapName = "files.json";
|
||||
private const string SignatureFileName = "files.json.sig";
|
||||
private const string ArchiveFileName = "update.zip";
|
||||
private const string PublicKeyFileName = "public-key.pem";
|
||||
|
||||
private readonly DeploymentLocator _deploymentLocator;
|
||||
private readonly string _appRoot;
|
||||
private readonly string _launcherRoot;
|
||||
private readonly string _incomingRoot;
|
||||
private readonly string _snapshotsRoot;
|
||||
|
||||
public UpdateEngineService(DeploymentLocator deploymentLocator)
|
||||
{
|
||||
_deploymentLocator = deploymentLocator;
|
||||
_appRoot = deploymentLocator.GetAppRoot();
|
||||
_launcherRoot = Path.Combine(_appRoot, LauncherDirectoryName);
|
||||
_incomingRoot = Path.Combine(_launcherRoot, UpdateDirectoryName, IncomingDirectoryName);
|
||||
_snapshotsRoot = Path.Combine(_launcherRoot, SnapshotsDirectoryName);
|
||||
}
|
||||
|
||||
public LauncherResult CheckPendingUpdate()
|
||||
{
|
||||
var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName);
|
||||
var archivePath = Path.Combine(_incomingRoot, ArchiveFileName);
|
||||
var signaturePath = Path.Combine(_incomingRoot, SignatureFileName);
|
||||
if (!File.Exists(fileMapPath) || !File.Exists(archivePath))
|
||||
{
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.check",
|
||||
Code = "noop",
|
||||
Message = "No pending update."
|
||||
};
|
||||
}
|
||||
|
||||
var fileMapText = File.ReadAllText(fileMapPath);
|
||||
var fileMap = JsonSerializer.Deserialize<SignedFileMap>(fileMapText);
|
||||
if (fileMap is null)
|
||||
{
|
||||
return Failed("update.check", "invalid_manifest", "files.json is invalid.");
|
||||
}
|
||||
|
||||
var verified = VerifySignature(fileMapPath, signaturePath);
|
||||
if (!verified.Success)
|
||||
{
|
||||
return Failed("update.check", "signature_failed", verified.Message);
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.check",
|
||||
Code = "available",
|
||||
Message = "Pending update is available.",
|
||||
CurrentVersion = _deploymentLocator.GetCurrentVersion(),
|
||||
TargetVersion = fileMap.ToVersion
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<LauncherResult> DownloadAsync(string manifestUrl, string signatureUrl, string archiveUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
Directory.CreateDirectory(_incomingRoot);
|
||||
using var client = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromMinutes(2)
|
||||
};
|
||||
|
||||
var manifestPath = Path.Combine(_incomingRoot, SignedFileMapName);
|
||||
var signaturePath = Path.Combine(_incomingRoot, SignatureFileName);
|
||||
var archivePath = Path.Combine(_incomingRoot, ArchiveFileName);
|
||||
|
||||
await using (var stream = await client.GetStreamAsync(manifestUrl, cancellationToken).ConfigureAwait(false))
|
||||
await using (var output = File.Create(manifestPath))
|
||||
{
|
||||
await stream.CopyToAsync(output, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await using (var stream = await client.GetStreamAsync(signatureUrl, cancellationToken).ConfigureAwait(false))
|
||||
await using (var output = File.Create(signaturePath))
|
||||
{
|
||||
await stream.CopyToAsync(output, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await using (var stream = await client.GetStreamAsync(archiveUrl, cancellationToken).ConfigureAwait(false))
|
||||
await using (var output = File.Create(archivePath))
|
||||
{
|
||||
await stream.CopyToAsync(output, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.download",
|
||||
Code = "ok",
|
||||
Message = "Update downloaded."
|
||||
};
|
||||
}
|
||||
|
||||
public LauncherResult ApplyPendingUpdate()
|
||||
{
|
||||
Directory.CreateDirectory(_incomingRoot);
|
||||
Directory.CreateDirectory(_snapshotsRoot);
|
||||
|
||||
var fileMapPath = Path.Combine(_incomingRoot, SignedFileMapName);
|
||||
var signaturePath = Path.Combine(_incomingRoot, SignatureFileName);
|
||||
var archivePath = Path.Combine(_incomingRoot, ArchiveFileName);
|
||||
|
||||
if (!File.Exists(fileMapPath) || !File.Exists(archivePath))
|
||||
{
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.apply",
|
||||
Code = "noop",
|
||||
Message = "No update payload found."
|
||||
};
|
||||
}
|
||||
|
||||
var verifyResult = VerifySignature(fileMapPath, signaturePath);
|
||||
if (!verifyResult.Success)
|
||||
{
|
||||
return Failed("update.apply", "signature_failed", verifyResult.Message);
|
||||
}
|
||||
|
||||
var fileMapText = File.ReadAllText(fileMapPath);
|
||||
var fileMap = JsonSerializer.Deserialize<SignedFileMap>(fileMapText);
|
||||
if (fileMap is null || fileMap.Files.Count == 0)
|
||||
{
|
||||
return Failed("update.apply", "invalid_manifest", "No update file entries were found.");
|
||||
}
|
||||
|
||||
var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory();
|
||||
if (string.IsNullOrWhiteSpace(currentDeployment))
|
||||
{
|
||||
return Failed("update.apply", "no_current_deployment", "Current deployment directory not found.");
|
||||
}
|
||||
|
||||
var currentVersion = _deploymentLocator.GetCurrentVersion();
|
||||
if (!string.IsNullOrWhiteSpace(fileMap.FromVersion) &&
|
||||
!string.Equals(fileMap.FromVersion, currentVersion, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Failed(
|
||||
"update.apply",
|
||||
"version_mismatch",
|
||||
$"Update requires source version {fileMap.FromVersion} but current is {currentVersion}.");
|
||||
}
|
||||
|
||||
var targetVersion = string.IsNullOrWhiteSpace(fileMap.ToVersion) ? currentVersion : fileMap.ToVersion!;
|
||||
var targetDeployment = _deploymentLocator.BuildNextDeploymentDirectory(targetVersion);
|
||||
var partialMarker = Path.Combine(targetDeployment, ".partial");
|
||||
var snapshot = new SnapshotMetadata
|
||||
{
|
||||
SnapshotId = Guid.NewGuid().ToString("N"),
|
||||
SourceVersion = currentVersion,
|
||||
TargetVersion = targetVersion,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
SourceDirectory = currentDeployment,
|
||||
TargetDirectory = targetDeployment,
|
||||
Status = "pending"
|
||||
};
|
||||
var snapshotPath = Path.Combine(_snapshotsRoot, $"{snapshot.SnapshotId}.json");
|
||||
|
||||
var extractRoot = Path.Combine(_incomingRoot, "extracted");
|
||||
try
|
||||
{
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
|
||||
if (Directory.Exists(extractRoot))
|
||||
{
|
||||
Directory.Delete(extractRoot, true);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(extractRoot);
|
||||
ZipFile.ExtractToDirectory(archivePath, extractRoot, overwriteFiles: true);
|
||||
|
||||
Directory.CreateDirectory(targetDeployment);
|
||||
File.WriteAllText(partialMarker, string.Empty);
|
||||
|
||||
foreach (var file in fileMap.Files)
|
||||
{
|
||||
ApplyFileEntry(file, currentDeployment, targetDeployment, extractRoot);
|
||||
}
|
||||
|
||||
foreach (var file in fileMap.Files)
|
||||
{
|
||||
if (!NeedsVerification(file))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(targetDeployment, file.Path);
|
||||
var actualHash = ComputeSha256Hex(fullPath);
|
||||
if (!string.Equals(actualHash, file.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"File hash mismatch for '{file.Path}'.");
|
||||
}
|
||||
}
|
||||
|
||||
ActivateDeployment(currentDeployment, targetDeployment);
|
||||
|
||||
snapshot.Status = "applied";
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
CleanupIncomingArtifacts();
|
||||
CleanupDestroyedDeployments();
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.apply",
|
||||
Code = "ok",
|
||||
Message = $"Updated to {targetVersion}.",
|
||||
CurrentVersion = currentVersion,
|
||||
TargetVersion = targetVersion
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TryRollbackOnFailure(snapshot);
|
||||
snapshot.Status = "rolled_back";
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = "update.apply",
|
||||
Code = "apply_failed",
|
||||
Message = "Failed to apply update. Rolled back to previous version.",
|
||||
ErrorMessage = ex.Message,
|
||||
CurrentVersion = currentVersion,
|
||||
RolledBackTo = currentVersion
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(extractRoot))
|
||||
{
|
||||
Directory.Delete(extractRoot, true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public LauncherResult RollbackLatest()
|
||||
{
|
||||
if (!Directory.Exists(_snapshotsRoot))
|
||||
{
|
||||
return Failed("update.rollback", "no_snapshot", "No snapshot found.");
|
||||
}
|
||||
|
||||
var snapshotPath = Directory
|
||||
.EnumerateFiles(_snapshotsRoot, "*.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderByDescending(File.GetCreationTimeUtc)
|
||||
.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(snapshotPath))
|
||||
{
|
||||
return Failed("update.rollback", "no_snapshot", "No snapshot found.");
|
||||
}
|
||||
|
||||
var snapshot = JsonSerializer.Deserialize<SnapshotMetadata>(File.ReadAllText(snapshotPath));
|
||||
if (snapshot is null || string.IsNullOrWhiteSpace(snapshot.SourceDirectory))
|
||||
{
|
||||
return Failed("update.rollback", "invalid_snapshot", "Invalid snapshot metadata.");
|
||||
}
|
||||
|
||||
var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory();
|
||||
if (string.IsNullOrWhiteSpace(currentDeployment))
|
||||
{
|
||||
return Failed("update.rollback", "no_current_deployment", "Current deployment not found.");
|
||||
}
|
||||
|
||||
ActivateDeployment(currentDeployment, snapshot.SourceDirectory);
|
||||
snapshot.Status = "manual_rollback";
|
||||
SaveSnapshot(snapshotPath, snapshot);
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.rollback",
|
||||
Code = "ok",
|
||||
Message = $"Rolled back to {snapshot.SourceVersion}.",
|
||||
RolledBackTo = snapshot.SourceVersion
|
||||
};
|
||||
}
|
||||
|
||||
public void CleanupDestroyedDeployments()
|
||||
{
|
||||
foreach (var dir in Directory.EnumerateDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
if (!File.Exists(Path.Combine(dir, ".destroy")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFileEntry(UpdateFileEntry file, string currentDeployment, string targetDeployment, string extractRoot)
|
||||
{
|
||||
var normalizedPath = NormalizeRelativePath(file.Path);
|
||||
if (string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var targetPath = Path.Combine(targetDeployment, normalizedPath);
|
||||
EnsurePathWithinRoot(targetPath, targetDeployment);
|
||||
var targetDir = Path.GetDirectoryName(targetPath);
|
||||
if (!string.IsNullOrWhiteSpace(targetDir))
|
||||
{
|
||||
Directory.CreateDirectory(targetDir);
|
||||
}
|
||||
|
||||
if (string.Equals(file.Action, "reuse", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var sourcePath = Path.Combine(currentDeployment, normalizedPath);
|
||||
EnsurePathWithinRoot(sourcePath, currentDeployment);
|
||||
if (!File.Exists(sourcePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because it was not found in current deployment.");
|
||||
}
|
||||
|
||||
File.Copy(sourcePath, targetPath, overwrite: true);
|
||||
return;
|
||||
}
|
||||
|
||||
var archiveRelative = string.IsNullOrWhiteSpace(file.ArchivePath) ? normalizedPath : NormalizeRelativePath(file.ArchivePath);
|
||||
var extractedPath = Path.Combine(extractRoot, archiveRelative);
|
||||
EnsurePathWithinRoot(extractedPath, extractRoot);
|
||||
if (!File.Exists(extractedPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Archive file '{archiveRelative}' not found for '{file.Path}'.");
|
||||
}
|
||||
|
||||
File.Copy(extractedPath, targetPath, overwrite: true);
|
||||
}
|
||||
|
||||
private void ActivateDeployment(string fromDeployment, string toDeployment)
|
||||
{
|
||||
var toCurrent = Path.Combine(toDeployment, ".current");
|
||||
var fromCurrent = Path.Combine(fromDeployment, ".current");
|
||||
var fromDestroy = Path.Combine(fromDeployment, ".destroy");
|
||||
var toPartial = Path.Combine(toDeployment, ".partial");
|
||||
|
||||
File.WriteAllText(toCurrent, string.Empty);
|
||||
if (File.Exists(fromCurrent))
|
||||
{
|
||||
File.Delete(fromCurrent);
|
||||
}
|
||||
|
||||
File.WriteAllText(fromDestroy, string.Empty);
|
||||
if (File.Exists(toPartial))
|
||||
{
|
||||
File.Delete(toPartial);
|
||||
}
|
||||
}
|
||||
|
||||
private void TryRollbackOnFailure(SnapshotMetadata snapshot)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(snapshot.TargetDirectory) && Directory.Exists(snapshot.TargetDirectory))
|
||||
{
|
||||
Directory.Delete(snapshot.TargetDirectory, true);
|
||||
}
|
||||
|
||||
if (File.Exists(Path.Combine(snapshot.SourceDirectory, ".destroy")))
|
||||
{
|
||||
File.Delete(Path.Combine(snapshot.SourceDirectory, ".destroy"));
|
||||
}
|
||||
|
||||
if (!File.Exists(Path.Combine(snapshot.SourceDirectory, ".current")))
|
||||
{
|
||||
File.WriteAllText(Path.Combine(snapshot.SourceDirectory, ".current"), string.Empty);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupIncomingArtifacts()
|
||||
{
|
||||
foreach (var path in new[]
|
||||
{
|
||||
Path.Combine(_incomingRoot, SignedFileMapName),
|
||||
Path.Combine(_incomingRoot, SignatureFileName),
|
||||
Path.Combine(_incomingRoot, ArchiveFileName)
|
||||
})
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private (bool Success, string Message) VerifySignature(string fileMapPath, string signaturePath)
|
||||
{
|
||||
if (!File.Exists(signaturePath))
|
||||
{
|
||||
return (false, "Missing files.json.sig.");
|
||||
}
|
||||
|
||||
var publicKeyPath = Path.Combine(_launcherRoot, UpdateDirectoryName, PublicKeyFileName);
|
||||
if (!File.Exists(publicKeyPath))
|
||||
{
|
||||
return (false, $"Missing public key: {publicKeyPath}");
|
||||
}
|
||||
|
||||
var jsonBytes = File.ReadAllBytes(fileMapPath);
|
||||
var signatureBase64 = File.ReadAllText(signaturePath).Trim();
|
||||
if (string.IsNullOrWhiteSpace(signatureBase64))
|
||||
{
|
||||
return (false, "Signature is empty.");
|
||||
}
|
||||
|
||||
byte[] signature;
|
||||
try
|
||||
{
|
||||
signature = Convert.FromBase64String(signatureBase64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return (false, "Signature is not valid base64.");
|
||||
}
|
||||
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(File.ReadAllText(publicKeyPath));
|
||||
var isValid = rsa.VerifyData(jsonBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
return isValid ? (true, "ok") : (false, "Signature verification failed.");
|
||||
}
|
||||
|
||||
private static string NormalizeRelativePath(string path)
|
||||
{
|
||||
var normalized = path.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar);
|
||||
return normalized.TrimStart(Path.DirectorySeparatorChar);
|
||||
}
|
||||
|
||||
private static void EnsurePathWithinRoot(string targetPath, string rootPath)
|
||||
{
|
||||
var fullTarget = Path.GetFullPath(targetPath);
|
||||
var fullRoot = Path.GetFullPath(rootPath);
|
||||
if (!fullTarget.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Path traversal detected: {targetPath}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool NeedsVerification(UpdateFileEntry file)
|
||||
{
|
||||
return !string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.IsNullOrWhiteSpace(file.Sha256);
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(string filePath)
|
||||
{
|
||||
using var stream = File.OpenRead(filePath);
|
||||
var hash = SHA256.HashData(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static void SaveSnapshot(string path, SnapshotMetadata snapshot)
|
||||
{
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(snapshot, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
}));
|
||||
}
|
||||
|
||||
private static LauncherResult Failed(string stage, string code, string message)
|
||||
{
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = false,
|
||||
Stage = stage,
|
||||
Code = code,
|
||||
Message = message,
|
||||
ErrorMessage = message
|
||||
};
|
||||
}
|
||||
}
|
||||
22
LanMountainDesktop.Launcher/Views/OobeWindow.axaml
Normal file
22
LanMountainDesktop.Launcher/Views/OobeWindow.axaml
Normal file
@@ -0,0 +1,22 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.OobeWindow"
|
||||
Title="阑山桌面"
|
||||
Width="420"
|
||||
Height="260"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
<Grid Margin="24" RowDefinitions="*,Auto">
|
||||
<TextBlock Text="欢迎使用阑山桌面"
|
||||
FontSize="26"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center" />
|
||||
<Button Grid.Row="1"
|
||||
x:Name="EnterButton"
|
||||
HorizontalAlignment="Right"
|
||||
Width="64"
|
||||
Height="40"
|
||||
Content="→"
|
||||
FontSize="18" />
|
||||
</Grid>
|
||||
</Window>
|
||||
27
LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs
Normal file
27
LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
internal partial class OobeWindow : Window
|
||||
{
|
||||
private readonly TaskCompletionSource<bool> _completionSource = new();
|
||||
|
||||
public OobeWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
var enterButton = this.FindControl<Button>("EnterButton");
|
||||
if (enterButton is not null)
|
||||
{
|
||||
enterButton.Click += OnEnterClick;
|
||||
}
|
||||
}
|
||||
|
||||
public Task WaitForEnterAsync() => _completionSource.Task;
|
||||
|
||||
private void OnEnterClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_completionSource.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
17
LanMountainDesktop.Launcher/Views/SplashWindow.axaml
Normal file
17
LanMountainDesktop.Launcher/Views/SplashWindow.axaml
Normal file
@@ -0,0 +1,17 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LanMountainDesktop.Launcher.Views.SplashWindow"
|
||||
Title="阑山桌面"
|
||||
Width="420"
|
||||
Height="220"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
SystemDecorations="None">
|
||||
<Grid Margin="24">
|
||||
<TextBlock x:Name="AppNameText"
|
||||
Text="阑山桌面"
|
||||
FontSize="34"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center" />
|
||||
</Grid>
|
||||
</Window>
|
||||
12
LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs
Normal file
12
LanMountainDesktop.Launcher/Views/SplashWindow.axaml.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
internal partial class SplashWindow : Window
|
||||
{
|
||||
public SplashWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user