From d0040886019382bdc4b01ecf76884e29d63e4c80 Mon Sep 17 00:00:00 2001 From: lincube Date: Fri, 29 May 2026 22:16:40 +0800 Subject: [PATCH] =?UTF-8?q?chabged.=E8=BF=9B=E4=B8=80=E6=AD=A5=E6=B8=85?= =?UTF-8?q?=E7=90=86=E5=90=AF=E5=8A=A8=E5=99=A8=E5=86=85=E7=9A=84=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LanMountainDesktop.Launcher/App.axaml.cs | 18 +- LanMountainDesktop.Launcher/CommandContext.cs | 8 - LanMountainDesktop.Launcher/GlobalUsings.cs | 1 - .../Infrastructure/Commands.cs | 37 +- .../Ipc/LauncherUpdateProgressIpcServer.cs | 6 + .../Shell/ApplyUpdateGuiFlow.cs | 78 --- .../EntryHandlers/LaunchEntryHandlers.cs | 9 - .../Shell/LauncherOrchestrator.cs | 5 - .../Shell/LauncherServiceRegistration.cs | 3 - .../Startup/LaunchPipeline.cs | 1 - .../Startup/Phases/ApplyPendingUpdatePhase.cs | 27 - .../Update/IUpdateEngine.cs | 18 - .../Update/IUpdateProgressReporter.cs | 9 - .../Update/LegacyUpdateApplier.cs | 287 -------- .../Update/NullUpdateProgressReporter.cs | 9 - .../Update/PendingUpdateDetector.cs | 116 ---- .../Update/UpdateEngineFacade.cs | 119 ---- .../Update/UpdateEngineFactory.cs | 7 - .../Update/UpdateEngineResults.cs | 18 - .../Views/UpdateWindow.axaml.cs | 2 +- .../CommandContextTests.cs | 2 +- LanMountainDesktop.Tests/GlobalUsings.cs | 7 + .../HostStartupMonitorTests.cs | 69 ++ .../LauncherArchitectureTests.cs | 6 +- .../LauncherGlobalUsings.cs | 1 - .../PendingPluginUpgradeServiceTests.cs | 4 +- .../UpdateStrategyTests.cs | 250 ------- .../UpdateSystemRegressionTests.cs | 612 ------------------ .../Services/Update/AppDeploymentLocator.cs | 160 +++++ .../Services}/Update/DeploymentActivator.cs | 39 +- .../Update/IncomingArtifactsCleaner.cs | 7 +- .../Update/InstallCheckpointStore.cs | 13 +- .../Services/Update/PlondsApplyModels.cs | 110 ++++ .../Services/Update/PlondsApplyPaths.cs | 43 +- .../Services}/Update/PlondsManifestParser.cs | 35 +- .../Services}/Update/PlondsPayloadResolver.cs | 21 +- .../Services}/Update/PlondsUpdateApplier.cs | 211 +++--- .../Services}/Update/RollbackStrategy.cs | 18 +- .../Services/Update/UpdateApplyJsonContext.cs | 13 + .../Services/Update/UpdateApplyResults.cs | 16 + .../Services}/Update/UpdateHash.cs | 2 +- .../Services}/Update/UpdatePathGuard.cs | 2 +- .../Services/Update/UpdateRollbackGateway.cs | 16 + .../Update/UpdateSignatureVerifier.cs | 4 +- .../Services}/Update/UpdateSnapshotStore.cs | 13 +- SECURITY_AUDIT_REPORT.md | 476 +++++++++----- docs/auto_commit_md/20260527_63f0898.md | 129 ++++ docs/auto_commit_md/20260527_ce41fd6.md | 325 ++++++++++ 48 files changed, 1348 insertions(+), 2034 deletions(-) delete mode 100644 LanMountainDesktop.Launcher/Shell/ApplyUpdateGuiFlow.cs delete mode 100644 LanMountainDesktop.Launcher/Startup/Phases/ApplyPendingUpdatePhase.cs delete mode 100644 LanMountainDesktop.Launcher/Update/IUpdateEngine.cs delete mode 100644 LanMountainDesktop.Launcher/Update/IUpdateProgressReporter.cs delete mode 100644 LanMountainDesktop.Launcher/Update/LegacyUpdateApplier.cs delete mode 100644 LanMountainDesktop.Launcher/Update/NullUpdateProgressReporter.cs delete mode 100644 LanMountainDesktop.Launcher/Update/PendingUpdateDetector.cs delete mode 100644 LanMountainDesktop.Launcher/Update/UpdateEngineFacade.cs delete mode 100644 LanMountainDesktop.Launcher/Update/UpdateEngineFactory.cs delete mode 100644 LanMountainDesktop.Launcher/Update/UpdateEngineResults.cs create mode 100644 LanMountainDesktop.Tests/GlobalUsings.cs create mode 100644 LanMountainDesktop.Tests/HostStartupMonitorTests.cs delete mode 100644 LanMountainDesktop.Tests/UpdateStrategyTests.cs delete mode 100644 LanMountainDesktop.Tests/UpdateSystemRegressionTests.cs create mode 100644 LanMountainDesktop/Services/Update/AppDeploymentLocator.cs rename {LanMountainDesktop.Launcher => LanMountainDesktop/Services}/Update/DeploymentActivator.cs (61%) rename {LanMountainDesktop.Launcher => LanMountainDesktop/Services}/Update/IncomingArtifactsCleaner.cs (81%) rename {LanMountainDesktop.Launcher => LanMountainDesktop/Services}/Update/InstallCheckpointStore.cs (64%) create mode 100644 LanMountainDesktop/Services/Update/PlondsApplyModels.cs rename LanMountainDesktop.Launcher/Update/UpdateEnginePaths.cs => LanMountainDesktop/Services/Update/PlondsApplyPaths.cs (56%) rename {LanMountainDesktop.Launcher => LanMountainDesktop/Services}/Update/PlondsManifestParser.cs (90%) rename {LanMountainDesktop.Launcher => LanMountainDesktop/Services}/Update/PlondsPayloadResolver.cs (70%) rename {LanMountainDesktop.Launcher => LanMountainDesktop/Services}/Update/PlondsUpdateApplier.cs (54%) rename {LanMountainDesktop.Launcher => LanMountainDesktop/Services}/Update/RollbackStrategy.cs (59%) create mode 100644 LanMountainDesktop/Services/Update/UpdateApplyJsonContext.cs create mode 100644 LanMountainDesktop/Services/Update/UpdateApplyResults.cs rename {LanMountainDesktop.Launcher => LanMountainDesktop/Services}/Update/UpdateHash.cs (97%) rename {LanMountainDesktop.Launcher => LanMountainDesktop/Services}/Update/UpdatePathGuard.cs (93%) create mode 100644 LanMountainDesktop/Services/Update/UpdateRollbackGateway.cs rename {LanMountainDesktop.Launcher => LanMountainDesktop/Services}/Update/UpdateSignatureVerifier.cs (91%) rename {LanMountainDesktop.Launcher => LanMountainDesktop/Services}/Update/UpdateSnapshotStore.cs (66%) create mode 100644 docs/auto_commit_md/20260527_63f0898.md create mode 100644 docs/auto_commit_md/20260527_ce41fd6.md diff --git a/LanMountainDesktop.Launcher/App.axaml.cs b/LanMountainDesktop.Launcher/App.axaml.cs index 7bbca5d..317c43a 100644 --- a/LanMountainDesktop.Launcher/App.axaml.cs +++ b/LanMountainDesktop.Launcher/App.axaml.cs @@ -67,8 +67,7 @@ public partial class App : Application return; } - if (context.IsDebugMode && !context.IsPreviewCommand && - !string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase)) + if (context.IsDebugMode && !context.IsPreviewCommand) { Logger.Info("Debug mode active; showing DevDebugWindow instead of normal launch flow."); new DevDebugWindow().Show(); @@ -76,18 +75,9 @@ public partial class App : Application return; } - if (string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase)) - { - var updateWindow = new UpdateWindow(); - updateWindow.Show(); - _ = ApplyUpdateEntryHandler.RunAsync(desktop, context, updateWindow); - } - else - { - var splashWindow = LaunchEntryHandler.CreateSplashWindow(); - splashWindow.Show(); - _ = LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow); - } + var splashWindow = LaunchEntryHandler.CreateSplashWindow(); + splashWindow.Show(); + _ = LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow); base.OnFrameworkInitializationCompleted(); } diff --git a/LanMountainDesktop.Launcher/CommandContext.cs b/LanMountainDesktop.Launcher/CommandContext.cs index 2203bb1..2072450 100644 --- a/LanMountainDesktop.Launcher/CommandContext.cs +++ b/LanMountainDesktop.Launcher/CommandContext.cs @@ -12,7 +12,6 @@ internal sealed class CommandContext [ "launch", AirAppBrokerCommand, - "apply-update", "preview-splash", "preview-error", "preview-update", @@ -70,7 +69,6 @@ internal sealed class CommandContext GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase); public bool IsMaintenanceCommand => - string.Equals(LaunchSource, "apply-update", StringComparison.OrdinalIgnoreCase) || string.Equals(LaunchSource, "plugin-install", StringComparison.OrdinalIgnoreCase) || string.Equals(Command, "update", StringComparison.OrdinalIgnoreCase) || string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase); @@ -118,11 +116,6 @@ internal sealed class CommandContext return "debug-preview"; } - if (string.Equals(Command, "apply-update", StringComparison.OrdinalIgnoreCase)) - { - return "apply-update"; - } - if (IsLegacyPluginInstall || string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase)) { return "plugin-install"; @@ -143,7 +136,6 @@ internal sealed class CommandContext "normal" => "normal", "restart" => "restart", "postinstall" => "postinstall", - "apply-update" => "apply-update", "plugin-install" => "plugin-install", "debug-preview" => "debug-preview", _ => null diff --git a/LanMountainDesktop.Launcher/GlobalUsings.cs b/LanMountainDesktop.Launcher/GlobalUsings.cs index 5292e5d..65d7f77 100644 --- a/LanMountainDesktop.Launcher/GlobalUsings.cs +++ b/LanMountainDesktop.Launcher/GlobalUsings.cs @@ -5,4 +5,3 @@ global using LanMountainDesktop.Launcher.Ipc; global using LanMountainDesktop.Launcher.Oobe; global using LanMountainDesktop.Launcher.Plugins; global using LanMountainDesktop.Launcher.Startup; -global using LanMountainDesktop.Launcher.Update; diff --git a/LanMountainDesktop.Launcher/Infrastructure/Commands.cs b/LanMountainDesktop.Launcher/Infrastructure/Commands.cs index b4f149a..0ad3d97 100644 --- a/LanMountainDesktop.Launcher/Infrastructure/Commands.cs +++ b/LanMountainDesktop.Launcher/Infrastructure/Commands.cs @@ -35,15 +35,14 @@ internal static class Commands public static async Task RunCliCommandAsync(CommandContext context) { var appRoot = ResolveAppRoot(context); - var deploymentLocator = new DeploymentLocator(appRoot); - var updateEngine = UpdateEngineFactory.Create(deploymentLocator); + _ = new DeploymentLocator(appRoot); var pluginInstaller = new PluginInstallerService(); var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller); LauncherResult result; try { - result = await ExecuteCoreAsync(context, updateEngine, pluginInstaller, pluginUpgrades).ConfigureAwait(false); + result = ExecuteCore(context, pluginInstaller, pluginUpgrades); } catch (Exception ex) { @@ -61,16 +60,13 @@ internal static class Commands return result.Success ? 0 : 1; } - private static async Task ExecuteCoreAsync( + private static LauncherResult ExecuteCore( CommandContext context, - IUpdateEngine 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: @@ -84,33 +80,6 @@ internal static class Commands } } - private static async Task ExecuteUpdateAsync(CommandContext context, IUpdateEngine updateEngine) - { - return context.SubCommand.ToLowerInvariant() switch - { - "check" => updateEngine.CheckPendingUpdate(), - "apply" => await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false), - "rollback" => updateEngine.RollbackLatest(), - "download" => await DownloadUpdatePayloadAsync(context, updateEngine).ConfigureAwait(false), - _ => new LauncherResult - { - Success = false, - Stage = "update", - Code = "unsupported_subcommand", - Message = $"Unsupported update sub-command '{context.SubCommand}'." - } - }; - } - - private static async Task DownloadUpdatePayloadAsync(CommandContext context, IUpdateEngine updateEngine) - { - return 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); - } - private static LauncherResult ExecutePluginCommand( CommandContext context, PluginInstallerService pluginInstaller, diff --git a/LanMountainDesktop.Launcher/Ipc/LauncherUpdateProgressIpcServer.cs b/LanMountainDesktop.Launcher/Ipc/LauncherUpdateProgressIpcServer.cs index 103f861..ea1e9a1 100644 --- a/LanMountainDesktop.Launcher/Ipc/LauncherUpdateProgressIpcServer.cs +++ b/LanMountainDesktop.Launcher/Ipc/LauncherUpdateProgressIpcServer.cs @@ -6,6 +6,12 @@ using LanMountainDesktop.Shared.Contracts.Update; namespace LanMountainDesktop.Launcher.Ipc; +internal interface IUpdateProgressReporter +{ + void ReportProgress(InstallProgressReport report); + void ReportComplete(InstallCompleteReport report); +} + internal sealed class LauncherUpdateProgressIpcServer : IUpdateProgressReporter, IDisposable { private const int LengthPrefixSize = 4; diff --git a/LanMountainDesktop.Launcher/Shell/ApplyUpdateGuiFlow.cs b/LanMountainDesktop.Launcher/Shell/ApplyUpdateGuiFlow.cs deleted file mode 100644 index 6e926a7..0000000 --- a/LanMountainDesktop.Launcher/Shell/ApplyUpdateGuiFlow.cs +++ /dev/null @@ -1,78 +0,0 @@ -using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Threading; -using LanMountainDesktop.Launcher.Models; -using LanMountainDesktop.Launcher.Resources; -using LanMountainDesktop.Launcher.Views; - -namespace LanMountainDesktop.Launcher.Shell; - -internal static class ApplyUpdateGuiFlow -{ - public static async Task RunAsync( - IClassicDesktopStyleApplicationLifetime desktop, - CommandContext context, - UpdateWindow window) - { - var appRoot = Commands.ResolveAppRoot(context); - var deploymentLocator = new DeploymentLocator(appRoot); - var updateEngine = UpdateEngineFactory.Create(deploymentLocator); - var pluginInstaller = new PluginInstallerService(); - var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller); - - var success = true; - string? errorMessage = null; - - try - { - await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", Strings.Update_Verifying, 10)); - var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false); - if (!updateResult.Success) - { - success = false; - errorMessage = updateResult.Message; - } - - if (success) - { - await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", Strings.Update_ApplyingPlugins, 60)); - var pluginsDir = context.GetOption("plugins-dir") ?? Path.Combine(appRoot, "plugins"); - var queueResult = pluginUpgrades.ApplyPendingUpgrades(pluginsDir); - if (!queueResult.Success && queueResult.Code != "noop") - { - Logger.Error($"Plugin upgrade failed during apply-update: {queueResult.Message}"); - } - } - - if (success) - { - await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", Strings.Update_CleaningUp, 90)); - deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3); - } - } - catch (Exception ex) - { - success = false; - errorMessage = ex.Message; - Logger.Error("Apply-update flow failed.", ex); - } - - await Dispatcher.UIThread.InvokeAsync(() => window.ReportComplete(success, errorMessage)); - await Task.Delay(success ? 1500 : 5000).ConfigureAwait(false); - - await Commands.WriteResultIfNeededAsync(context.GetOption("result"), new LauncherResult - { - Success = success, - Stage = "apply-update", - Code = success ? "ok" : "failed", - Message = success ? "Update applied successfully." : (errorMessage ?? "Unknown error"), - Details = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["command"] = context.Command, - ["launchSource"] = context.LaunchSource - } - }).ConfigureAwait(false); - - Environment.ExitCode = success ? 0 : 1; - await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background); - } -} diff --git a/LanMountainDesktop.Launcher/Shell/EntryHandlers/LaunchEntryHandlers.cs b/LanMountainDesktop.Launcher/Shell/EntryHandlers/LaunchEntryHandlers.cs index 04e5ded..43de66e 100644 --- a/LanMountainDesktop.Launcher/Shell/EntryHandlers/LaunchEntryHandlers.cs +++ b/LanMountainDesktop.Launcher/Shell/EntryHandlers/LaunchEntryHandlers.cs @@ -31,15 +31,6 @@ internal static class LaunchEntryHandler LauncherCompositionRoot.RunOrchestratorWithSplashAsync(desktop, context, splashWindow); } -internal static class ApplyUpdateEntryHandler -{ - public static Task RunAsync( - IClassicDesktopStyleApplicationLifetime desktop, - CommandContext context, - UpdateWindow window) => - ApplyUpdateGuiFlow.RunAsync(desktop, context, window); -} - internal static class AirAppBrokerEntryHandler { public static async Task RunAsync(IClassicDesktopStyleApplicationLifetime desktop, CommandContext context) diff --git a/LanMountainDesktop.Launcher/Shell/LauncherOrchestrator.cs b/LanMountainDesktop.Launcher/Shell/LauncherOrchestrator.cs index 2cff63f..755070d 100644 --- a/LanMountainDesktop.Launcher/Shell/LauncherOrchestrator.cs +++ b/LanMountainDesktop.Launcher/Shell/LauncherOrchestrator.cs @@ -13,7 +13,6 @@ internal sealed class LauncherOrchestrator private readonly CommandContext _context; private readonly DeploymentLocator _deploymentLocator; private readonly OobeStateService _oobeStateService; - private readonly IUpdateEngine _updateEngine; private readonly StartupAttemptRegistry _startupAttemptRegistry; private readonly LauncherCoordinatorIpcServer? _coordinatorIpcServer; private readonly DataLocationResolver _dataLocationResolver; @@ -24,7 +23,6 @@ internal sealed class LauncherOrchestrator CommandContext context, DeploymentLocator deploymentLocator, OobeStateService oobeStateService, - IUpdateEngine updateEngine, StartupAttemptRegistry startupAttemptRegistry, LauncherCoordinatorIpcServer? coordinatorIpcServer = null, LaunchPipeline? pipeline = null) @@ -32,7 +30,6 @@ internal sealed class LauncherOrchestrator _context = context; _deploymentLocator = deploymentLocator; _oobeStateService = oobeStateService; - _updateEngine = updateEngine; _startupAttemptRegistry = startupAttemptRegistry; _coordinatorIpcServer = coordinatorIpcServer; _dataLocationResolver = new DataLocationResolver(deploymentLocator.GetAppRoot()); @@ -45,7 +42,6 @@ internal sealed class LauncherOrchestrator [ new CleanupDeploymentsPhase(), new ExistingHostProbePhase(), - new ApplyPendingUpdatePhase(), new OobeGatePhase(), new LaunchHostPhase(), new MonitorStartupPhase() @@ -217,7 +213,6 @@ internal sealed class LauncherOrchestrator CommandContext = _context, DeploymentLocator = _deploymentLocator, OobeStateService = _oobeStateService, - UpdateEngine = _updateEngine, StartupAttemptRegistry = _startupAttemptRegistry, CoordinatorIpcServer = _coordinatorIpcServer, DataLocationResolver = _dataLocationResolver, diff --git a/LanMountainDesktop.Launcher/Shell/LauncherServiceRegistration.cs b/LanMountainDesktop.Launcher/Shell/LauncherServiceRegistration.cs index 1df34d7..6fafe77 100644 --- a/LanMountainDesktop.Launcher/Shell/LauncherServiceRegistration.cs +++ b/LanMountainDesktop.Launcher/Shell/LauncherServiceRegistration.cs @@ -22,12 +22,10 @@ internal static class LauncherServiceRegistration services.AddSingleton(new DeploymentLocator(appRoot)); services.AddSingleton(sp => new OobeStateService(appRoot)); services.AddSingleton(sp => new DataLocationResolver(appRoot)); - services.AddSingleton(sp => UpdateEngineFactory.Create(sp.GetRequiredService())); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -47,7 +45,6 @@ internal static class LauncherServiceRegistration context, services.GetRequiredService(), services.GetRequiredService(), - services.GetRequiredService(), startupAttemptRegistry, coordinatorServer, services.GetRequiredService()); diff --git a/LanMountainDesktop.Launcher/Startup/LaunchPipeline.cs b/LanMountainDesktop.Launcher/Startup/LaunchPipeline.cs index 65212e3..75b4c10 100644 --- a/LanMountainDesktop.Launcher/Startup/LaunchPipeline.cs +++ b/LanMountainDesktop.Launcher/Startup/LaunchPipeline.cs @@ -27,7 +27,6 @@ internal sealed class LaunchContext public required CommandContext CommandContext { get; init; } public required DeploymentLocator DeploymentLocator { get; init; } public required OobeStateService OobeStateService { get; init; } - public required IUpdateEngine UpdateEngine { get; init; } public required StartupAttemptRegistry StartupAttemptRegistry { get; init; } public LauncherCoordinatorIpcServer? CoordinatorIpcServer { get; init; } public required DataLocationResolver DataLocationResolver { get; init; } diff --git a/LanMountainDesktop.Launcher/Startup/Phases/ApplyPendingUpdatePhase.cs b/LanMountainDesktop.Launcher/Startup/Phases/ApplyPendingUpdatePhase.cs deleted file mode 100644 index 703061f..0000000 --- a/LanMountainDesktop.Launcher/Startup/Phases/ApplyPendingUpdatePhase.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace LanMountainDesktop.Launcher.Startup; - -internal sealed class ApplyPendingUpdatePhase : ILaunchPhase -{ - public string Name => nameof(ApplyPendingUpdatePhase); - - public async Task ExecuteAsync(LaunchContext context, CancellationToken cancellationToken = default) - { - context.Reporter.Report("update", "Checking updates..."); - var updateResult = await context.UpdateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false); - if (!updateResult.Success) - { - Logger.Warn($"Update apply failed, will try to launch existing version. Error='{updateResult.Message}'."); - context.Reporter.Report("update", "Update failed, launching existing version..."); - try - { - context.UpdateEngine.CleanupIncomingArtifacts(); - } - catch (Exception ex) - { - Logger.Warn($"Failed to cleanup update artifacts after failed update: {ex.Message}"); - } - } - - return new LaunchPhaseResult(LaunchPhaseStatus.Continue); - } -} diff --git a/LanMountainDesktop.Launcher/Update/IUpdateEngine.cs b/LanMountainDesktop.Launcher/Update/IUpdateEngine.cs deleted file mode 100644 index 2d1204d..0000000 --- a/LanMountainDesktop.Launcher/Update/IUpdateEngine.cs +++ /dev/null @@ -1,18 +0,0 @@ -using LanMountainDesktop.Launcher.Models; - -namespace LanMountainDesktop.Launcher.Update; - -internal interface IUpdateEngine -{ - LauncherResult CheckPendingUpdate(); - - Task DownloadAsync(string manifestUrl, string signatureUrl, string archiveUrl, CancellationToken cancellationToken); - - Task ApplyPendingUpdateAsync(); - - LauncherResult RollbackLatest(); - - void CleanupDestroyedDeployments(); - - void CleanupIncomingArtifacts(); -} diff --git a/LanMountainDesktop.Launcher/Update/IUpdateProgressReporter.cs b/LanMountainDesktop.Launcher/Update/IUpdateProgressReporter.cs deleted file mode 100644 index 752b058..0000000 --- a/LanMountainDesktop.Launcher/Update/IUpdateProgressReporter.cs +++ /dev/null @@ -1,9 +0,0 @@ -using LanMountainDesktop.Shared.Contracts.Update; - -namespace LanMountainDesktop.Launcher.Update; - -public interface IUpdateProgressReporter -{ - void ReportProgress(InstallProgressReport report); - void ReportComplete(InstallCompleteReport report); -} diff --git a/LanMountainDesktop.Launcher/Update/LegacyUpdateApplier.cs b/LanMountainDesktop.Launcher/Update/LegacyUpdateApplier.cs deleted file mode 100644 index 7bf8130..0000000 --- a/LanMountainDesktop.Launcher/Update/LegacyUpdateApplier.cs +++ /dev/null @@ -1,287 +0,0 @@ -using System.IO.Compression; -using System.Text.Json; -using LanMountainDesktop.Launcher.Models; -using ContractsUpdate = LanMountainDesktop.Shared.Contracts.Update; - -namespace LanMountainDesktop.Launcher.Update; - -internal sealed class LegacyUpdateApplier( - DeploymentLocator deploymentLocator, - UpdateEnginePaths paths, - UpdateSignatureVerifier signatureVerifier, - IUpdateProgressReporter progressReporter, - UpdateSnapshotStore snapshotStore, - InstallCheckpointStore checkpointStore, - DeploymentActivator deploymentActivator, - IncomingArtifactsCleaner incomingCleaner) -{ - public async Task ApplyAsync() - { - if (!File.Exists(paths.FileMapPath) || !File.Exists(paths.ArchivePath)) - { - return new LauncherResult - { - Success = true, - Stage = "update.apply", - Code = "noop", - Message = "No update payload found." - }; - } - - progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifySignature, "Verifying signature...", 0, null, 0, 0)); - var verifyResult = signatureVerifier.Verify(paths.FileMapPath, paths.SignaturePath, UpdateEnginePaths.SignatureFileName); - if (!verifyResult.Success) - { - progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false)); - return UpdateEngineResults.Failed("update.apply", "signature_failed", verifyResult.Message); - } - - var fileMapText = await File.ReadAllTextAsync(paths.FileMapPath).ConfigureAwait(false); - var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap); - if (fileMap is null || fileMap.Files.Count == 0) - { - progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, "No update file entries were found.", false)); - return UpdateEngineResults.Failed("update.apply", "invalid_manifest", "No update file entries were found."); - } - - var currentDeployment = deploymentLocator.FindCurrentDeploymentDirectory(); - var currentVersion = deploymentLocator.GetCurrentVersion(); - if (!string.IsNullOrWhiteSpace(fileMap.FromVersion) && - !string.Equals(fileMap.FromVersion, currentVersion, StringComparison.OrdinalIgnoreCase)) - { - return UpdateEngineResults.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 existingCheckpoint = checkpointStore.Load(); - var canResume = existingCheckpoint is not null - && string.Equals(existingCheckpoint.SourceVersion, currentVersion, StringComparison.OrdinalIgnoreCase) - && string.Equals(existingCheckpoint.TargetVersion, targetVersion, StringComparison.OrdinalIgnoreCase) - && string.Equals(existingCheckpoint.SourceDirectory ?? string.Empty, currentDeployment ?? string.Empty, StringComparison.OrdinalIgnoreCase) - && Directory.Exists(existingCheckpoint.TargetDirectory) - && File.Exists(Path.Combine(existingCheckpoint.TargetDirectory, ".partial")); - - if (existingCheckpoint is not null && !canResume) - { - return UpdateEngineResults.Failed("update.apply", "resume_state_invalid", "Install checkpoint is stale or invalid. Please cancel and redownload update payload."); - } - - var targetDeployment = canResume - ? existingCheckpoint!.TargetDirectory - : deploymentLocator.BuildNextDeploymentDirectory(targetVersion); - var snapshot = BuildSnapshot(canResume, existingCheckpoint, currentVersion, targetVersion, currentDeployment, targetDeployment); - var snapshotPath = snapshotStore.CreateSnapshotPath(snapshot.SnapshotId); - var checkpoint = canResume - ? existingCheckpoint! - : BuildCheckpoint(snapshot, currentVersion, targetVersion, currentDeployment, targetDeployment); - - try - { - snapshotStore.Save(snapshotPath, snapshot); - PrepareExtractRoot(); - ZipFile.ExtractToDirectory(paths.ArchivePath, paths.ExtractRoot, overwriteFiles: true); - - if (!canResume) - { - progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileMap.Files.Count)); - Directory.CreateDirectory(targetDeployment); - File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty); - } - - checkpointStore.Save(checkpoint); - ApplyFiles(fileMap, currentDeployment!, targetDeployment, checkpoint); - VerifyFiles(fileMap, targetDeployment, checkpoint); - - progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileMap.Files.Count, fileMap.Files.Count)); - deploymentActivator.Activate(currentDeployment!, targetDeployment); - - snapshot.Status = "applied"; - snapshotStore.Save(snapshotPath, snapshot); - incomingCleaner.Cleanup(); - deploymentActivator.RetainDeploymentsForRollback(); - - 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 - { - Success = true, - Stage = "update.apply", - Code = "ok", - Message = $"Updated to {targetVersion}.", - CurrentVersion = currentVersion, - TargetVersion = targetVersion - }; - } - catch (Exception ex) - { - progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0)); - var rollbackResult = deploymentActivator.TryRollbackOnFailure(snapshot); - snapshot.Status = rollbackResult.Success ? "rolled_back" : "rollback_failed"; - snapshotStore.Save(snapshotPath, snapshot); - var errorMessage = rollbackResult.Success - ? ex.Message - : $"{ex.Message}; rollback failed: {rollbackResult.ErrorMessage}"; - progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, currentVersion, targetVersion, errorMessage, rollbackResult.Success)); - return new LauncherResult - { - Success = false, - Stage = "update.apply", - Code = rollbackResult.Success ? "apply_failed" : "rollback_failed", - Message = rollbackResult.Success - ? "Failed to apply update. Rolled back to previous version." - : "Failed to apply update and rollback failed.", - ErrorMessage = errorMessage, - CurrentVersion = currentVersion, - RolledBackTo = rollbackResult.Success ? currentVersion : null - }; - } - finally - { - checkpointStore.Delete(); - TryDeleteExtractRoot(); - } - } - - private void ApplyFiles(SignedFileMap fileMap, string currentDeployment, string targetDeployment, InstallCheckpoint checkpoint) - { - progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30, null, checkpoint.AppliedCount, fileMap.Files.Count)); - for (var fileIndex = checkpoint.AppliedCount; fileIndex < fileMap.Files.Count; fileIndex++) - { - var file = fileMap.Files[fileIndex]; - ApplyFileEntry(file, currentDeployment, targetDeployment); - checkpoint.AppliedCount = fileIndex + 1; - checkpointStore.Save(checkpoint); - progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying files...", 30 + (checkpoint.AppliedCount * 30 / fileMap.Files.Count), file.Path, checkpoint.AppliedCount, fileMap.Files.Count)); - } - } - - private void VerifyFiles(SignedFileMap fileMap, string targetDeployment, InstallCheckpoint checkpoint) - { - progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65, null, checkpoint.VerifiedCount, fileMap.Files.Count)); - for (var verifyIndex = checkpoint.VerifiedCount; verifyIndex < fileMap.Files.Count; verifyIndex++) - { - var file = fileMap.Files[verifyIndex]; - if (NeedsVerification(file)) - { - var fullPath = Path.Combine(targetDeployment, file.Path); - var actualHash = UpdateHash.ComputeSha256Hex(fullPath); - if (!string.Equals(actualHash, file.Sha256, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException($"File hash mismatch for '{file.Path}'."); - } - } - - checkpoint.VerifiedCount = verifyIndex + 1; - checkpointStore.Save(checkpoint); - progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying hashes...", 65 + (checkpoint.VerifiedCount * 15 / fileMap.Files.Count), file.Path, checkpoint.VerifiedCount, fileMap.Files.Count)); - } - } - - private void ApplyFileEntry(UpdateFileEntry file, string currentDeployment, string targetDeployment) - { - var normalizedPath = UpdatePathGuard.NormalizeRelativePath(file.Path); - if (string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - var targetPath = Path.Combine(targetDeployment, normalizedPath); - UpdatePathGuard.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); - UpdatePathGuard.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 : UpdatePathGuard.NormalizeRelativePath(file.ArchivePath); - var extractedPath = Path.Combine(paths.ExtractRoot, archiveRelative); - UpdatePathGuard.EnsurePathWithinRoot(extractedPath, paths.ExtractRoot); - if (!File.Exists(extractedPath)) - { - throw new FileNotFoundException($"Archive file '{archiveRelative}' not found for '{file.Path}'."); - } - - File.Copy(extractedPath, targetPath, overwrite: true); - } - - private void PrepareExtractRoot() - { - if (Directory.Exists(paths.ExtractRoot)) - { - Directory.Delete(paths.ExtractRoot, true); - } - - Directory.CreateDirectory(paths.ExtractRoot); - } - - private void TryDeleteExtractRoot() - { - try - { - if (Directory.Exists(paths.ExtractRoot)) - { - Directory.Delete(paths.ExtractRoot, true); - } - } - catch - { - } - } - - private static SnapshotMetadata BuildSnapshot( - bool canResume, - InstallCheckpoint? existingCheckpoint, - string currentVersion, - string targetVersion, - string? currentDeployment, - string targetDeployment) => - new() - { - SnapshotId = canResume ? existingCheckpoint!.SnapshotId : Guid.NewGuid().ToString("N"), - SourceVersion = currentVersion, - TargetVersion = targetVersion, - CreatedAt = DateTimeOffset.UtcNow, - SourceDirectory = currentDeployment ?? string.Empty, - TargetDirectory = targetDeployment, - Status = "pending" - }; - - private static InstallCheckpoint BuildCheckpoint( - SnapshotMetadata snapshot, - string currentVersion, - string targetVersion, - string? currentDeployment, - string targetDeployment) => - new() - { - SnapshotId = snapshot.SnapshotId, - SourceVersion = currentVersion, - TargetVersion = targetVersion, - SourceDirectory = currentDeployment, - TargetDirectory = targetDeployment, - IsInitialDeployment = false - }; - - private static bool NeedsVerification(UpdateFileEntry file) - { - return !string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrWhiteSpace(file.Sha256); - } -} diff --git a/LanMountainDesktop.Launcher/Update/NullUpdateProgressReporter.cs b/LanMountainDesktop.Launcher/Update/NullUpdateProgressReporter.cs deleted file mode 100644 index fda4d97..0000000 --- a/LanMountainDesktop.Launcher/Update/NullUpdateProgressReporter.cs +++ /dev/null @@ -1,9 +0,0 @@ -using LanMountainDesktop.Shared.Contracts.Update; - -namespace LanMountainDesktop.Launcher.Update; - -internal sealed class NullUpdateProgressReporter : IUpdateProgressReporter -{ - public void ReportProgress(InstallProgressReport report) { } - public void ReportComplete(InstallCompleteReport report) { } -} diff --git a/LanMountainDesktop.Launcher/Update/PendingUpdateDetector.cs b/LanMountainDesktop.Launcher/Update/PendingUpdateDetector.cs deleted file mode 100644 index bc6da53..0000000 --- a/LanMountainDesktop.Launcher/Update/PendingUpdateDetector.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System.Text.Json; -using LanMountainDesktop.Launcher.Models; - -namespace LanMountainDesktop.Launcher.Update; - -internal sealed class PendingUpdateDetector( - DeploymentLocator deploymentLocator, - UpdateEnginePaths paths, - UpdateSignatureVerifier signatureVerifier) -{ - public LauncherResult CheckPendingUpdate() - { - if (File.Exists(paths.PlondsFileMapPath) && File.Exists(paths.PlondsSignaturePath)) - { - var plondsFileMapText = File.ReadAllText(paths.PlondsFileMapPath); - var plondsFileMap = JsonSerializer.Deserialize(plondsFileMapText, AppJsonContext.Default.PlondsFileMap); - if (plondsFileMap is null) - { - return UpdateEngineResults.Failed("update.check", "invalid_manifest", "plonds-filemap.json is invalid."); - } - - var plondsVerified = signatureVerifier.Verify( - paths.PlondsFileMapPath, - paths.PlondsSignaturePath, - UpdateEnginePaths.PlondsSignatureFileName); - if (!plondsVerified.Success) - { - return UpdateEngineResults.Failed("update.check", "signature_failed", plondsVerified.Message); - } - - var plondsMetadata = PlondsManifestParser.LoadMetadata(paths.PlondsUpdateMetadataPath); - return new LauncherResult - { - Success = true, - Stage = "update.check", - Code = "available", - Message = "Pending PLONDS update is available.", - CurrentVersion = deploymentLocator.GetCurrentVersion(), - TargetVersion = PlondsManifestParser.ResolveTargetVersion(plondsFileMap, plondsMetadata) - }; - } - - if (!File.Exists(paths.FileMapPath) || !File.Exists(paths.ArchivePath)) - { - return new LauncherResult - { - Success = true, - Stage = "update.check", - Code = "noop", - Message = "No pending update." - }; - } - - var fileMapText = File.ReadAllText(paths.FileMapPath); - var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap); - if (fileMap is null) - { - return UpdateEngineResults.Failed("update.check", "invalid_manifest", "files.json is invalid."); - } - - var verified = signatureVerifier.Verify(paths.FileMapPath, paths.SignaturePath, UpdateEnginePaths.SignatureFileName); - if (!verified.Success) - { - return UpdateEngineResults.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 LauncherResult ValidateIncomingState() - { - if (File.Exists(paths.ApplyLockPath)) - { - return UpdateEngineResults.Failed("update.apply", "lock_conflict", "Another update apply operation is already in progress."); - } - - if (!File.Exists(paths.DeploymentLockPath)) - { - return UpdateEngineResults.Failed("update.apply", "staging_incomplete", "Deployment lock is missing. Please redownload the update."); - } - - var hasPlondsMap = File.Exists(paths.PlondsFileMapPath); - var hasLegacyMap = File.Exists(paths.FileMapPath); - if (hasPlondsMap && !File.Exists(paths.DownloadMarkerPath)) - { - return UpdateEngineResults.Failed("update.apply", "staging_incomplete", "Download marker is missing for pending PLONDS update."); - } - - if (!hasPlondsMap && !hasLegacyMap) - { - return new LauncherResult - { - Success = true, - Stage = "update.apply", - Code = "noop", - Message = "No update payload found." - }; - } - - return new LauncherResult - { - Success = true, - Stage = "update.apply", - Code = "ok", - Message = "Incoming update state validated." - }; - } -} diff --git a/LanMountainDesktop.Launcher/Update/UpdateEngineFacade.cs b/LanMountainDesktop.Launcher/Update/UpdateEngineFacade.cs deleted file mode 100644 index ed130cb..0000000 --- a/LanMountainDesktop.Launcher/Update/UpdateEngineFacade.cs +++ /dev/null @@ -1,119 +0,0 @@ -using LanMountainDesktop.Launcher.Models; - -namespace LanMountainDesktop.Launcher.Update; - -internal sealed class UpdateEngineFacade : IUpdateEngine -{ - private readonly UpdateEnginePaths _paths; - private readonly PendingUpdateDetector _pendingUpdateDetector; - private readonly LegacyUpdateApplier _legacyUpdateApplier; - private readonly PlondsUpdateApplier _plondsUpdateApplier; - private readonly RollbackStrategy _rollbackStrategy; - private readonly DeploymentActivator _deploymentActivator; - private readonly IncomingArtifactsCleaner _incomingArtifactsCleaner; - - public UpdateEngineFacade(DeploymentLocator deploymentLocator, IUpdateProgressReporter? progressReporter = null) - { - var reporter = progressReporter ?? new NullUpdateProgressReporter(); - _paths = new UpdateEnginePaths(deploymentLocator.GetAppRoot()); - var signatureVerifier = new UpdateSignatureVerifier(_paths); - var snapshotStore = new UpdateSnapshotStore(_paths); - var checkpointStore = new InstallCheckpointStore(_paths); - _deploymentActivator = new DeploymentActivator(deploymentLocator); - _incomingArtifactsCleaner = new IncomingArtifactsCleaner(_paths); - _pendingUpdateDetector = new PendingUpdateDetector(deploymentLocator, _paths, signatureVerifier); - _legacyUpdateApplier = new LegacyUpdateApplier( - deploymentLocator, - _paths, - signatureVerifier, - reporter, - snapshotStore, - checkpointStore, - _deploymentActivator, - _incomingArtifactsCleaner); - _plondsUpdateApplier = new PlondsUpdateApplier( - deploymentLocator, - _paths, - signatureVerifier, - reporter, - snapshotStore, - checkpointStore, - _deploymentActivator, - _incomingArtifactsCleaner, - new PlondsPayloadResolver(_paths)); - _rollbackStrategy = new RollbackStrategy(deploymentLocator, snapshotStore, _deploymentActivator); - } - - public LauncherResult CheckPendingUpdate() => _pendingUpdateDetector.CheckPendingUpdate(); - - public Task DownloadAsync(string manifestUrl, string signatureUrl, string archiveUrl, CancellationToken cancellationToken) - { - _ = manifestUrl; - _ = signatureUrl; - _ = archiveUrl; - _ = cancellationToken; - - return Task.FromResult(new LauncherResult - { - Success = false, - Stage = "update.download", - Code = "host_managed_only", - Message = "Launcher no longer performs network downloads. Host must download update payload into incoming directory first." - }); - } - - public async Task ApplyPendingUpdateAsync() - { - Directory.CreateDirectory(_paths.IncomingRoot); - Directory.CreateDirectory(_paths.SnapshotsRoot); - - var stateValidation = _pendingUpdateDetector.ValidateIncomingState(); - if (!stateValidation.Success || stateValidation.Code == "noop") - { - return stateValidation; - } - - try - { - File.WriteAllText(_paths.ApplyLockPath, DateTimeOffset.UtcNow.ToString("O")); - } - catch (Exception ex) - { - return UpdateEngineResults.Failed("update.apply", "lock_conflict", $"Failed to acquire apply lock: {ex.Message}"); - } - - try - { - if (_paths.HasPlondsPayload) - { - return await _plondsUpdateApplier.ApplyAsync().ConfigureAwait(false); - } - - return await _legacyUpdateApplier.ApplyAsync().ConfigureAwait(false); - } - finally - { - TryDeleteApplyLock(); - } - } - - public LauncherResult RollbackLatest() => _rollbackStrategy.RollbackLatest(); - - public void CleanupDestroyedDeployments() => _deploymentActivator.RetainDeploymentsForRollback(); - - public void CleanupIncomingArtifacts() => _incomingArtifactsCleaner.Cleanup(); - - private void TryDeleteApplyLock() - { - try - { - if (File.Exists(_paths.ApplyLockPath)) - { - File.Delete(_paths.ApplyLockPath); - } - } - catch - { - } - } -} diff --git a/LanMountainDesktop.Launcher/Update/UpdateEngineFactory.cs b/LanMountainDesktop.Launcher/Update/UpdateEngineFactory.cs deleted file mode 100644 index 5745f05..0000000 --- a/LanMountainDesktop.Launcher/Update/UpdateEngineFactory.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace LanMountainDesktop.Launcher.Update; - -internal static class UpdateEngineFactory -{ - public static IUpdateEngine Create(DeploymentLocator deploymentLocator, IUpdateProgressReporter? progressReporter = null) => - new UpdateEngineFacade(deploymentLocator, progressReporter); -} diff --git a/LanMountainDesktop.Launcher/Update/UpdateEngineResults.cs b/LanMountainDesktop.Launcher/Update/UpdateEngineResults.cs deleted file mode 100644 index a919e61..0000000 --- a/LanMountainDesktop.Launcher/Update/UpdateEngineResults.cs +++ /dev/null @@ -1,18 +0,0 @@ -using LanMountainDesktop.Launcher.Models; - -namespace LanMountainDesktop.Launcher.Update; - -internal static class UpdateEngineResults -{ - public static LauncherResult Failed(string stage, string code, string message) - { - return new LauncherResult - { - Success = false, - Stage = stage, - Code = code, - Message = message, - ErrorMessage = message - }; - } -} diff --git a/LanMountainDesktop.Launcher/Views/UpdateWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/UpdateWindow.axaml.cs index f1fb8ba..28c4501 100644 --- a/LanMountainDesktop.Launcher/Views/UpdateWindow.axaml.cs +++ b/LanMountainDesktop.Launcher/Views/UpdateWindow.axaml.cs @@ -6,7 +6,7 @@ using LanMountainDesktop.Launcher.Resources; namespace LanMountainDesktop.Launcher.Views; /// -/// 更新进度窗口 - 用于 apply-update 命令模式下显示更新/插件升级进度 +/// 更新进度窗口 - 用于预览模式显示更新/插件升级进度 /// public partial class UpdateWindow : Window { diff --git a/LanMountainDesktop.Tests/CommandContextTests.cs b/LanMountainDesktop.Tests/CommandContextTests.cs index 1a728c8..67786d6 100644 --- a/LanMountainDesktop.Tests/CommandContextTests.cs +++ b/LanMountainDesktop.Tests/CommandContextTests.cs @@ -9,7 +9,7 @@ public sealed class CommandContextTests { { [], "normal" }, { ["preview-oobe"], "debug-preview" }, - { ["apply-update"], "apply-update" }, + { ["apply-update"], "normal" }, { ["--source", "plugin.lmdp", "--plugins-dir", "plugins", "--result", "result.json"], "plugin-install" }, { ["launch", "--launch-source", "postinstall"], "postinstall" } }; diff --git a/LanMountainDesktop.Tests/GlobalUsings.cs b/LanMountainDesktop.Tests/GlobalUsings.cs new file mode 100644 index 0000000..65d7f77 --- /dev/null +++ b/LanMountainDesktop.Tests/GlobalUsings.cs @@ -0,0 +1,7 @@ +global using LanMountainDesktop.Launcher.AirApp; +global using LanMountainDesktop.Launcher.Deployment; +global using LanMountainDesktop.Launcher.Infrastructure; +global using LanMountainDesktop.Launcher.Ipc; +global using LanMountainDesktop.Launcher.Oobe; +global using LanMountainDesktop.Launcher.Plugins; +global using LanMountainDesktop.Launcher.Startup; diff --git a/LanMountainDesktop.Tests/HostStartupMonitorTests.cs b/LanMountainDesktop.Tests/HostStartupMonitorTests.cs new file mode 100644 index 0000000..abf490b --- /dev/null +++ b/LanMountainDesktop.Tests/HostStartupMonitorTests.cs @@ -0,0 +1,69 @@ +using LanMountainDesktop.Launcher.Startup; +using LanMountainDesktop.Shared.Contracts.Launcher; +using LanMountainDesktop.Shared.IPC.Abstractions.Services; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class HostStartupMonitorTests +{ + [Fact] + public void InitialIpcConnectUsesStagedBackoff() + { + var source = ReadRepositoryFile("LanMountainDesktop.Launcher", "Startup", "HostStartupMonitor.cs"); + + Assert.Contains("StartupTimeoutPolicy.InitialIpcConnectTimeout", source); + Assert.Contains("TimeSpan.FromMilliseconds(3000)", source); + Assert.Contains("TimeSpan.FromMilliseconds(5000)", source); + Assert.Contains("TryConnectWithBackoffAsync", source); + } + + [Fact] + public void RefreshShellStatus_UsesStartupSuccessTrackerForSuccess() + { + var source = ReadRepositoryFile("LanMountainDesktop.Launcher", "Startup", "HostStartupMonitor.cs"); + + Assert.Contains("SuccessTracker.TryResolve(shellStatus, out var successState)", source); + var refreshBlock = source[ + source.IndexOf("RefreshShellStatusAsync", StringComparison.Ordinal) .. + source.IndexOf("var connected = await PublicIpcConnection.TryConnectWithBackoffAsync", StringComparison.Ordinal)]; + Assert.DoesNotContain("return new StartupSuccessState", refreshBlock); + Assert.DoesNotContain("successState = new StartupSuccessState", refreshBlock); + } + + [Fact] + public void BuildDelayedLoadingState_AddsSoftTimeoutItem() + { + var loadingState = new LoadingStateMessage + { + ActiveItems = [], + OverallProgressPercent = 0, + TotalCount = 0 + }; + + var delayed = HostStartupMonitor.BuildDelayedLoadingState( + loadingState, + "Still starting", + "Host is still warming up.", + DateTimeOffset.UtcNow); + + Assert.Equal("Still starting", delayed.Message); + Assert.Contains(delayed.ActiveItems, item => item.Id == "launcher-soft-timeout"); + } + + private static string ReadRepositoryFile(params string[] pathParts) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "LanMountainDesktop.slnx"))) + { + directory = directory.Parent; + } + + if (directory is null) + { + throw new DirectoryNotFoundException("Unable to locate repository root."); + } + + return File.ReadAllText(Path.Combine([directory.FullName, .. pathParts])); + } +} diff --git a/LanMountainDesktop.Tests/LauncherArchitectureTests.cs b/LanMountainDesktop.Tests/LauncherArchitectureTests.cs index ea559cb..f77c716 100644 --- a/LanMountainDesktop.Tests/LauncherArchitectureTests.cs +++ b/LanMountainDesktop.Tests/LauncherArchitectureTests.cs @@ -33,8 +33,7 @@ public sealed class LauncherArchitectureTests { var guardedFiles = new[] { - Path.Combine(LauncherProjectRoot, "Infrastructure", "Commands.cs"), - Path.Combine(LauncherProjectRoot, "Shell", "ApplyUpdateGuiFlow.cs") + Path.Combine(LauncherProjectRoot, "Infrastructure", "Commands.cs") }.Concat(Directory.EnumerateFiles( Path.Combine(LauncherProjectRoot, "Shell", "EntryHandlers"), "*.cs", @@ -49,9 +48,8 @@ public sealed class LauncherArchitectureTests } [Fact] - public void LauncherFacadeAndCompositionRootStayThin() + public void LauncherCompositionRootStaysThin() { - AssertFileLineCountAtMost(Path.Combine(LauncherProjectRoot, "Update", "UpdateEngineFacade.cs"), 140); AssertFileLineCountAtMost(Path.Combine(LauncherProjectRoot, "Shell", "LauncherCompositionRoot.cs"), 80); } diff --git a/LanMountainDesktop.Tests/LauncherGlobalUsings.cs b/LanMountainDesktop.Tests/LauncherGlobalUsings.cs index 9f1d1d0..8225e40 100644 --- a/LanMountainDesktop.Tests/LauncherGlobalUsings.cs +++ b/LanMountainDesktop.Tests/LauncherGlobalUsings.cs @@ -4,4 +4,3 @@ global using LanMountainDesktop.Launcher.Infrastructure; global using LanMountainDesktop.Launcher.Ipc; global using LanMountainDesktop.Launcher.Oobe; global using LanMountainDesktop.Launcher.Startup; -global using LanMountainDesktop.Launcher.Update; \ No newline at end of file diff --git a/LanMountainDesktop.Tests/PendingPluginUpgradeServiceTests.cs b/LanMountainDesktop.Tests/PendingPluginUpgradeServiceTests.cs index 3fca2dc..a968a5e 100644 --- a/LanMountainDesktop.Tests/PendingPluginUpgradeServiceTests.cs +++ b/LanMountainDesktop.Tests/PendingPluginUpgradeServiceTests.cs @@ -134,13 +134,13 @@ public sealed class PendingPluginUpgradeServiceTests : IDisposable return packagePath; } - private static PluginManifest ReadManifestFromPackage(string packagePath) + private static LanMountainDesktop.PluginSdk.PluginManifest ReadManifestFromPackage(string packagePath) { using var archive = ZipFile.OpenRead(packagePath); var entry = archive.GetEntry(PluginSdkInfo.ManifestFileName) ?? throw new InvalidOperationException("Missing plugin manifest."); using var stream = entry.Open(); - return PluginManifest.Load(stream, $"{packagePath}!/{entry.FullName}"); + return LanMountainDesktop.PluginSdk.PluginManifest.Load(stream, $"{packagePath}!/{entry.FullName}"); } public void Dispose() diff --git a/LanMountainDesktop.Tests/UpdateStrategyTests.cs b/LanMountainDesktop.Tests/UpdateStrategyTests.cs deleted file mode 100644 index b1e0722..0000000 --- a/LanMountainDesktop.Tests/UpdateStrategyTests.cs +++ /dev/null @@ -1,250 +0,0 @@ -using System.IO.Compression; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using LanMountainDesktop.Launcher; -using LanMountainDesktop.Launcher.Models; -using Xunit; - -namespace LanMountainDesktop.Tests; - -public sealed class PendingUpdateDetectorTests : IDisposable -{ - private readonly TempLauncherRoot _root = new(); - - [Fact] - public void ValidateIncomingState_WhenNoPayloadButDeploymentLockExists_ReturnsNoop() - { - _root.WriteDeploymentLock(); - var detector = new PendingUpdateDetector( - new DeploymentLocator(_root.AppRoot), - _root.Paths, - new UpdateSignatureVerifier(_root.Paths)); - - var result = detector.ValidateIncomingState(); - - Assert.True(result.Success); - Assert.Equal("noop", result.Code); - } - - public void Dispose() => _root.Dispose(); -} - -public sealed class UpdateSignatureVerifierTests : IDisposable -{ - private readonly TempLauncherRoot _root = new(); - - [Fact] - public void Verify_WhenSignatureIsMissing_ReturnsStructuredFailure() - { - var payload = Path.Combine(_root.Paths.IncomingRoot, "files.json"); - Directory.CreateDirectory(_root.Paths.IncomingRoot); - File.WriteAllText(payload, "{}"); - - var result = new UpdateSignatureVerifier(_root.Paths) - .Verify(payload, Path.Combine(_root.Paths.IncomingRoot, "files.json.sig"), "files.json.sig"); - - Assert.False(result.Success); - Assert.Equal("Missing files.json.sig.", result.Message); - } - - public void Dispose() => _root.Dispose(); -} - -public sealed class IncomingArtifactsCleanerTests : IDisposable -{ - private readonly TempLauncherRoot _root = new(); - - [Fact] - public void Cleanup_RemovesLegacyPlondsAndCheckpointArtifacts() - { - Directory.CreateDirectory(_root.Paths.PlondsObjectsRoot); - foreach (var path in new[] - { - _root.Paths.FileMapPath, - _root.Paths.SignaturePath, - _root.Paths.ArchivePath, - _root.Paths.PlondsFileMapPath, - _root.Paths.PlondsSignaturePath, - _root.Paths.PlondsUpdateMetadataPath, - _root.Paths.InstallCheckpointPath, - Path.Combine(_root.Paths.PlondsObjectsRoot, "payload") - }) - { - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - File.WriteAllText(path, "x"); - } - - new IncomingArtifactsCleaner(_root.Paths).Cleanup(); - - Assert.False(File.Exists(_root.Paths.FileMapPath)); - Assert.False(File.Exists(_root.Paths.InstallCheckpointPath)); - Assert.False(Directory.Exists(_root.Paths.PlondsObjectsRoot)); - } - - public void Dispose() => _root.Dispose(); -} - -public sealed class DeploymentActivatorTests : IDisposable -{ - private readonly TempLauncherRoot _root = new(); - - [Fact] - public void Activate_MovesCurrentMarkerAndMarksPreviousDestroy() - { - var from = Path.Combine(_root.AppRoot, "app-1"); - var to = Path.Combine(_root.AppRoot, "app-2"); - Directory.CreateDirectory(from); - Directory.CreateDirectory(to); - File.WriteAllText(Path.Combine(from, ".current"), string.Empty); - File.WriteAllText(Path.Combine(to, ".partial"), string.Empty); - - new DeploymentActivator(new DeploymentLocator(_root.AppRoot)).Activate(from, to); - - Assert.False(File.Exists(Path.Combine(from, ".current"))); - Assert.True(File.Exists(Path.Combine(from, ".destroy"))); - Assert.True(File.Exists(Path.Combine(to, ".current"))); - Assert.False(File.Exists(Path.Combine(to, ".partial"))); - } - - public void Dispose() => _root.Dispose(); -} - -public sealed class RollbackStrategyTests : IDisposable -{ - private readonly TempLauncherRoot _root = new(); - - [Fact] - public void RollbackLatest_WhenNoSnapshotsExist_ReturnsNoSnapshot() - { - var snapshotStore = new UpdateSnapshotStore(_root.Paths); - var activator = new DeploymentActivator(new DeploymentLocator(_root.AppRoot)); - - var result = new RollbackStrategy(new DeploymentLocator(_root.AppRoot), snapshotStore, activator) - .RollbackLatest(); - - Assert.False(result.Success); - Assert.Equal("no_snapshot", result.Code); - } - - public void Dispose() => _root.Dispose(); -} - -public sealed class PlondsUpdateApplierTests -{ - [Fact] - public void ManifestParser_ReadsObjectComponentFiles() - { - var map = new PlondsFileMap(); - var entries = PlondsManifestParser.CollectFileEntries(map); - - PlondsManifestParser.PopulateFromRawJson( - """ - { - "toVersion": "2.0.0", - "components": { - "desktop": { - "files": { - "LanMountainDesktop.exe": { - "archiveSha512": "abcd", - "archivePath": "objects/ab/cd" - } - } - } - } - } - """, - map, - entries); - - Assert.Equal("2.0.0", PlondsManifestParser.ResolveTargetVersion(map, null)); - var entry = Assert.Single(entries); - Assert.Equal("LanMountainDesktop.exe", entry.Path); - Assert.Equal("desktop", entry.Metadata["component"]); - } -} - -public sealed class LegacyUpdateApplierTests : IDisposable -{ - private readonly TempLauncherRoot _root = new(); - - [Fact] - public async Task ApplyAsync_WhenSignatureIsMissing_ReturnsSignatureFailure() - { - _root.WriteDeploymentLock(); - Directory.CreateDirectory(_root.Paths.IncomingRoot); - File.WriteAllText(_root.Paths.FileMapPath, JsonSerializer.Serialize(new SignedFileMap - { - FromVersion = "1.0.0", - ToVersion = "2.0.0", - Files = [new UpdateFileEntry { Path = "state.txt" }] - }, AppJsonContext.Default.SignedFileMap)); - using (var archive = ZipFile.Open(_root.Paths.ArchivePath, ZipArchiveMode.Create)) - { - var entry = archive.CreateEntry("state.txt"); - await using var stream = entry.Open(); - await stream.WriteAsync(Encoding.UTF8.GetBytes("state")); - } - - var applier = CreateLegacyApplier(); - var result = await applier.ApplyAsync(); - - Assert.False(result.Success); - Assert.Equal("signature_failed", result.Code); - } - - public void Dispose() => _root.Dispose(); - - private LegacyUpdateApplier CreateLegacyApplier() - { - var locator = new DeploymentLocator(_root.AppRoot); - var snapshotStore = new UpdateSnapshotStore(_root.Paths); - var checkpointStore = new InstallCheckpointStore(_root.Paths); - var activator = new DeploymentActivator(locator); - var cleaner = new IncomingArtifactsCleaner(_root.Paths); - return new LegacyUpdateApplier( - locator, - _root.Paths, - new UpdateSignatureVerifier(_root.Paths), - new NullUpdateProgressReporter(), - snapshotStore, - checkpointStore, - activator, - cleaner); - } -} - -internal sealed class TempLauncherRoot : IDisposable -{ - public TempLauncherRoot() - { - AppRoot = Path.Combine(Path.GetTempPath(), "lmd-launcher-tests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(AppRoot); - Paths = new UpdateEnginePaths(AppRoot); - Directory.CreateDirectory(Paths.IncomingRoot); - } - - public string AppRoot { get; } - - public UpdateEnginePaths Paths { get; } - - public void WriteDeploymentLock() - { - Directory.CreateDirectory(Path.GetDirectoryName(Paths.DeploymentLockPath)!); - File.WriteAllText(Paths.DeploymentLockPath, string.Empty); - } - - public void Dispose() - { - try - { - if (Directory.Exists(AppRoot)) - { - Directory.Delete(AppRoot, true); - } - } - catch - { - } - } -} diff --git a/LanMountainDesktop.Tests/UpdateSystemRegressionTests.cs b/LanMountainDesktop.Tests/UpdateSystemRegressionTests.cs deleted file mode 100644 index 54096e1..0000000 --- a/LanMountainDesktop.Tests/UpdateSystemRegressionTests.cs +++ /dev/null @@ -1,612 +0,0 @@ -using System.Net; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using LanMountainDesktop; -using LanMountainDesktop.Launcher; -using LanMountainDesktop.Launcher.Models; -using LanMountainDesktop.Services; -using LanMountainDesktop.Services.Update; -using LanMountainDesktop.Shared.Contracts.Update; -using Xunit; - -namespace LanMountainDesktop.Tests; - -public sealed class UpdateEngineRollbackRegressionTests : IDisposable -{ - private readonly UpdateTestDirectory _directory = new(); - - [Fact] - public async Task ApplyPlondsUpdate_KeepsPreviousDeploymentForManualRollback() - { - var current = _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true); - var newState = Encoding.UTF8.GetBytes("new-state"); - - _directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState)); - - var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot)); - var result = await service.ApplyPendingUpdateAsync(); - - Assert.True(result.Success, result.ErrorMessage); - Assert.True(Directory.Exists(current)); - Assert.False(File.Exists(Path.Combine(current, ".current"))); - - var rollback = service.RollbackLatest(); - - Assert.True(rollback.Success, rollback.ErrorMessage); - Assert.Equal("1.0.0", rollback.RolledBackTo); - Assert.True(File.Exists(Path.Combine(current, ".current"))); - Assert.False(File.Exists(Path.Combine(current, ".destroy"))); - Assert.Equal("old-state", File.ReadAllText(Path.Combine(current, "state.txt"))); - } - - [Fact] - public async Task ApplyPlondsUpdate_WhenObjectHashMismatches_RollsBackToPreviousDeployment() - { - var current = _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true); - var newState = Encoding.UTF8.GetBytes("new-state"); - - _directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, new string('0', 64)); - - var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot)); - var result = await service.ApplyPendingUpdateAsync(); - - Assert.False(result.Success); - Assert.Equal("apply_failed", result.Code); - Assert.Equal("1.0.0", result.RolledBackTo); - Assert.True(File.Exists(Path.Combine(current, ".current"))); - Assert.False(File.Exists(Path.Combine(current, ".destroy"))); - Assert.Equal("old-state", File.ReadAllText(Path.Combine(current, "state.txt"))); - Assert.Empty(Directory.GetDirectories(_directory.AppRoot, "app-1.1.0-*")); - } - - [Fact] - public void RollbackLatest_WhenSnapshotSourceDirectoryIsMissing_ReturnsStructuredFailure() - { - _directory.CreateDeployment("1.1.0", "new-state", isCurrent: true); - _directory.WriteSnapshot( - sourceVersion: "1.0.0", - sourceDirectory: Path.Combine(_directory.AppRoot, "app-1.0.0-0"), - targetVersion: "1.1.0", - targetDirectory: Path.Combine(_directory.AppRoot, "app-1.1.0-0")); - - var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot)); - var result = service.RollbackLatest(); - - Assert.False(result.Success); - Assert.Equal("source_missing", result.Code); - Assert.Contains("app-1.0.0-0", result.ErrorMessage); - } - - [Fact] - public async Task ApplyPlondsUpdate_WhenInstallCheckpointIsStale_ReturnsStructuredFailure() - { - _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true); - var newState = Encoding.UTF8.GetBytes("new-state"); - _directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState)); - _directory.WriteStaleInstallCheckpoint("9.9.9", "1.1.0"); - - var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot)); - var result = await service.ApplyPendingUpdateAsync(); - - Assert.False(result.Success); - Assert.Equal("resume_state_invalid", result.Code); - } - - [Fact] - public async Task ApplyLegacyUpdate_WhenInstallCheckpointIsStale_ReturnsStructuredFailure() - { - _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true); - _directory.StageLegacyUpdate("1.0.0", "1.1.0", "new-state"); - _directory.WriteStaleInstallCheckpoint("9.9.9", "1.1.0"); - - var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot)); - var result = await service.ApplyPendingUpdateAsync(); - - Assert.False(result.Success); - Assert.Equal("resume_state_invalid", result.Code); - } - - [Fact] - public async Task ApplyPlondsUpdate_WhenCheckpointIsValid_ResumesAndSucceeds() - { - var current = _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true); - var newState = Encoding.UTF8.GetBytes("new-state"); - _directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState)); - _directory.WriteValidPlondsResumeCheckpoint("1.0.0", "1.1.0"); - - var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot)); - var result = await service.ApplyPendingUpdateAsync(); - - Assert.True(result.Success, result.ErrorMessage); - Assert.Equal("1.1.0", result.TargetVersion); - Assert.False(File.Exists(Path.Combine(current, ".current"))); - var resumedTarget = Path.Combine(_directory.AppRoot, "app-1.1.0-0"); - Assert.True(File.Exists(Path.Combine(resumedTarget, ".current"))); - Assert.Equal("new-state", File.ReadAllText(Path.Combine(resumedTarget, "state.txt"))); - Assert.False(File.Exists(UpdatePaths.GetInstallCheckpointPath(_directory.AppRoot))); - } - - [Fact] - public async Task ApplyLegacyUpdate_WhenCheckpointIsValid_ResumesAndSucceeds() - { - var current = _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true); - _directory.StageLegacyUpdate("1.0.0", "1.1.0", "new-state"); - _directory.WriteValidLegacyResumeCheckpoint("1.0.0", "1.1.0"); - - var service = new UpdateEngineFacade(new DeploymentLocator(_directory.AppRoot)); - var result = await service.ApplyPendingUpdateAsync(); - - Assert.True(result.Success, result.ErrorMessage); - Assert.Equal("1.1.0", result.TargetVersion); - Assert.False(File.Exists(Path.Combine(current, ".current"))); - var resumedTarget = Path.Combine(_directory.AppRoot, "app-1.1.0-0"); - Assert.True(File.Exists(Path.Combine(resumedTarget, ".current"))); - Assert.Equal("new-state", File.ReadAllText(Path.Combine(resumedTarget, "state.txt"))); - Assert.False(File.Exists(UpdatePaths.GetInstallCheckpointPath(_directory.AppRoot))); - } - - public void Dispose() => _directory.Dispose(); - - private static string Sha256Hex(byte[] bytes) - { - return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); - } - - private sealed class UpdateTestDirectory : IDisposable - { - private readonly string _root; - private readonly RSA _rsa = RSA.Create(2048); - - public UpdateTestDirectory() - { - _root = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.UpdateRegression", Guid.NewGuid().ToString("N")); - AppRoot = Path.Combine(_root, "app-root"); - Directory.CreateDirectory(AppRoot); - - var resolver = new DataLocationResolver(AppRoot); - LauncherRoot = resolver.ResolveLauncherDataPath(); - IncomingRoot = Path.Combine(LauncherRoot, "update", "incoming"); - SnapshotsRoot = Path.Combine(LauncherRoot, "snapshots"); - - Directory.CreateDirectory(Path.Combine(LauncherRoot, "update")); - File.WriteAllText(Path.Combine(LauncherRoot, "update", "public-key.pem"), _rsa.ExportSubjectPublicKeyInfoPem()); - } - - public string AppRoot { get; } - - private string LauncherRoot { get; } - - private string IncomingRoot { get; } - - private string SnapshotsRoot { get; } - - public string CreateDeployment(string version, string state, bool isCurrent) - { - var deployment = Path.Combine(AppRoot, $"app-{version}-0"); - Directory.CreateDirectory(deployment); - File.WriteAllText(Path.Combine(deployment, ExecutableName), $"exe-{version}"); - File.WriteAllText(Path.Combine(deployment, "state.txt"), state); - - if (isCurrent) - { - File.WriteAllText(Path.Combine(deployment, ".current"), string.Empty); - } - - return deployment; - } - - public void StagePlondsUpdate(string fromVersion, string toVersion, byte[] statePayload, string expectedStateSha256) - { - Directory.CreateDirectory(IncomingRoot); - var objectsRoot = Path.Combine(IncomingRoot, "objects"); - Directory.CreateDirectory(objectsRoot); - - var objectHash = Convert.ToHexString(SHA256.HashData(statePayload)).ToLowerInvariant(); - File.WriteAllBytes(Path.Combine(objectsRoot, objectHash), statePayload); - - var currentExecutable = Path.Combine(AppRoot, $"app-{fromVersion}-0", ExecutableName); - var fileMap = new PlondsFileMap - { - DistributionId = $"stable-{PlondsStaticUpdateService.ResolveCurrentPlatform()}-{toVersion}", - FromVersion = fromVersion, - ToVersion = toVersion, - Platform = PlondsStaticUpdateService.ResolveCurrentPlatform(), - Files = - [ - new PlondsFileEntry - { - Path = ExecutableName, - Action = "reuse", - Sha256 = Sha256File(currentExecutable) - }, - new PlondsFileEntry - { - Path = "state.txt", - Action = "replace", - Sha256 = expectedStateSha256, - ObjectUrl = $"https://static.example/lanmountain/update/repo/sha256/{objectHash[..2]}/{objectHash}" - } - ] - }; - - var fileMapPath = Path.Combine(IncomingRoot, "plonds-filemap.json"); - File.WriteAllText(fileMapPath, JsonSerializer.Serialize(fileMap, AppJsonContext.Default.PlondsFileMap)); - Sign(fileMapPath, Path.Combine(IncomingRoot, "plonds-filemap.sig")); - - var deploymentLock = new DeploymentLock( - SchemaVersion: 1, - Kind: "delta", - TargetVersion: toVersion, - PayloadPath: fileMapPath, - PayloadSha256: Sha256File(fileMapPath), - CreatedAtUtc: DateTimeOffset.UtcNow); - var deploymentLockPath = UpdatePaths.GetDeploymentLockPath(AppRoot); - Directory.CreateDirectory(Path.GetDirectoryName(deploymentLockPath)!); - File.WriteAllText(deploymentLockPath, JsonSerializer.Serialize(deploymentLock)); - - var markerPath = UpdatePaths.GetDownloadMarkerPath(AppRoot); - File.WriteAllText(markerPath, UpdatePaths.GetDownloadMarkerContent( - manifestSha256: Sha256File(fileMapPath), - targetVersion: toVersion, - objectCount: 1)); - } - - public void StageLegacyUpdate(string fromVersion, string toVersion, string newState) - { - Directory.CreateDirectory(IncomingRoot); - var extractRoot = Path.Combine(IncomingRoot, "legacy-src"); - Directory.CreateDirectory(extractRoot); - - File.WriteAllText(Path.Combine(extractRoot, ExecutableName), $"exe-{toVersion}"); - File.WriteAllText(Path.Combine(extractRoot, "state.txt"), newState); - - var archivePath = Path.Combine(IncomingRoot, "update.zip"); - if (File.Exists(archivePath)) - { - File.Delete(archivePath); - } - - System.IO.Compression.ZipFile.CreateFromDirectory(extractRoot, archivePath); - - var fileMap = new SignedFileMap - { - FromVersion = fromVersion, - ToVersion = toVersion, - Files = - [ - new LanMountainDesktop.Launcher.Models.UpdateFileEntry - { - Path = ExecutableName, - ArchivePath = ExecutableName, - Action = "replace", - Sha256 = Sha256File(Path.Combine(extractRoot, ExecutableName)) - }, - new LanMountainDesktop.Launcher.Models.UpdateFileEntry - { - Path = "state.txt", - ArchivePath = "state.txt", - Action = "replace", - Sha256 = Sha256File(Path.Combine(extractRoot, "state.txt")) - } - ] - }; - - var fileMapPath = Path.Combine(IncomingRoot, "files.json"); - File.WriteAllText(fileMapPath, JsonSerializer.Serialize(fileMap, AppJsonContext.Default.SignedFileMap)); - Sign(fileMapPath, Path.Combine(IncomingRoot, "files.json.sig")); - - var deploymentLock = new DeploymentLock( - SchemaVersion: 1, - Kind: "delta", - TargetVersion: toVersion, - PayloadPath: fileMapPath, - PayloadSha256: Sha256File(fileMapPath), - CreatedAtUtc: DateTimeOffset.UtcNow); - var deploymentLockPath = UpdatePaths.GetDeploymentLockPath(AppRoot); - Directory.CreateDirectory(Path.GetDirectoryName(deploymentLockPath)!); - File.WriteAllText(deploymentLockPath, JsonSerializer.Serialize(deploymentLock)); - - Directory.Delete(extractRoot, true); - } - - public void WriteSnapshot(string sourceVersion, string sourceDirectory, string targetVersion, string targetDirectory) - { - Directory.CreateDirectory(SnapshotsRoot); - var snapshot = new SnapshotMetadata - { - SnapshotId = Guid.NewGuid().ToString("N"), - SourceVersion = sourceVersion, - TargetVersion = targetVersion, - CreatedAt = DateTimeOffset.UtcNow, - SourceDirectory = sourceDirectory, - TargetDirectory = targetDirectory, - Status = "applied" - }; - - File.WriteAllText( - Path.Combine(SnapshotsRoot, $"{snapshot.SnapshotId}.json"), - JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata)); - } - - public void WriteStaleInstallCheckpoint(string sourceVersion, string targetVersion) - { - var checkpoint = new InstallCheckpoint - { - SnapshotId = Guid.NewGuid().ToString("N"), - SourceVersion = sourceVersion, - TargetVersion = targetVersion, - SourceDirectory = Path.Combine(AppRoot, $"app-{sourceVersion}-0"), - TargetDirectory = Path.Combine(AppRoot, $"app-{targetVersion}-999"), - IsInitialDeployment = false, - AppliedCount = 1, - VerifiedCount = 1 - }; - - var checkpointPath = UpdatePaths.GetInstallCheckpointPath(AppRoot); - Directory.CreateDirectory(Path.GetDirectoryName(checkpointPath)!); - File.WriteAllText(checkpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint)); - } - - public void WriteValidPlondsResumeCheckpoint(string sourceVersion, string targetVersion) - { - var targetDeployment = Path.Combine(AppRoot, $"app-{targetVersion}-0"); - Directory.CreateDirectory(targetDeployment); - File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty); - File.WriteAllText(Path.Combine(targetDeployment, ExecutableName), $"exe-{sourceVersion}"); - - var checkpoint = new InstallCheckpoint - { - SnapshotId = Guid.NewGuid().ToString("N"), - SourceVersion = sourceVersion, - TargetVersion = targetVersion, - SourceDirectory = Path.Combine(AppRoot, $"app-{sourceVersion}-0"), - TargetDirectory = targetDeployment, - IsInitialDeployment = false, - AppliedCount = 1, - VerifiedCount = 0 - }; - - var checkpointPath = UpdatePaths.GetInstallCheckpointPath(AppRoot); - Directory.CreateDirectory(Path.GetDirectoryName(checkpointPath)!); - File.WriteAllText(checkpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint)); - } - - public void WriteValidLegacyResumeCheckpoint(string sourceVersion, string targetVersion) - { - var targetDeployment = Path.Combine(AppRoot, $"app-{targetVersion}-0"); - Directory.CreateDirectory(targetDeployment); - File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty); - - var checkpoint = new InstallCheckpoint - { - SnapshotId = Guid.NewGuid().ToString("N"), - SourceVersion = sourceVersion, - TargetVersion = targetVersion, - SourceDirectory = Path.Combine(AppRoot, $"app-{sourceVersion}-0"), - TargetDirectory = targetDeployment, - IsInitialDeployment = false, - AppliedCount = 0, - VerifiedCount = 0 - }; - - var checkpointPath = UpdatePaths.GetInstallCheckpointPath(AppRoot); - Directory.CreateDirectory(Path.GetDirectoryName(checkpointPath)!); - File.WriteAllText(checkpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint)); - } - - public void Dispose() - { - _rsa.Dispose(); - if (Directory.Exists(_root)) - { - Directory.Delete(_root, recursive: true); - } - } - - private void Sign(string payloadPath, string signaturePath) - { - var signature = _rsa.SignData(File.ReadAllBytes(payloadPath), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - File.WriteAllText(signaturePath, Convert.ToBase64String(signature)); - } - - private static string Sha256File(string path) - { - using var stream = File.OpenRead(path); - return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); - } - - private static string ExecutableName => OperatingSystem.IsWindows() - ? "LanMountainDesktop.exe" - : "LanMountainDesktop"; - } -} - -public sealed class PlondsStaticUpdateServiceTests -{ - [Fact] - public async Task CheckForUpdatesAsync_ReadsStaticLatestDistributionAndBuildsPayloadUrls() - { - var platform = PlondsStaticUpdateService.ResolveCurrentPlatform(); - var handler = new StaticManifestHandler(request => - { - var path = request.RequestUri?.AbsolutePath ?? string.Empty; - if (path.EndsWith($"/meta/channels/stable/{platform}/latest.json", StringComparison.Ordinal)) - { - return Json("""{"distributionId":"dist-1","version":"1.2.0","channel":"stable","platform":"PLATFORM","publishedAt":"2026-05-06T00:00:00Z"}""" - .Replace("PLATFORM", platform)); - } - - if (path.EndsWith("/meta/distributions/dist-1.json", StringComparison.Ordinal)) - { - return Json("""{"distributionId":"dist-1","version":"1.2.0","sourceVersion":"1.0.0","channel":"stable","platform":"PLATFORM","publishedAt":"2026-05-06T00:00:00Z","fileMapUrl":"https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json","fileMapSignatureUrl":"https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json.sig"}""" - .Replace("PLATFORM", platform)); - } - - return new HttpResponseMessage(HttpStatusCode.NotFound); - }); - - using var client = new HttpClient(handler); - using var service = new PlondsStaticUpdateService("https://static.example/lanmountain/update", client); - - var result = await service.CheckForUpdatesAsync(new Version(1, 0, 0), includePrerelease: false); - - Assert.True(result.Success, result.ErrorMessage); - Assert.True(result.IsUpdateAvailable); - Assert.Equal("1.2.0", result.LatestVersionText); - Assert.NotNull(result.PlondsPayload); - Assert.Equal("dist-1", result.PlondsPayload.DistributionId); - Assert.Equal(platform, result.PlondsPayload.SubChannel); - Assert.Equal("https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json", result.PlondsPayload.FileMapJsonUrl); - Assert.Equal("https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json.sig", result.PlondsPayload.FileMapSignatureUrl); - } - - [Fact] - public async Task CheckForUpdatesAsync_WhenLatestIsMissing_ReturnsFailureForFallback() - { - using var client = new HttpClient(new StaticManifestHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound))); - using var service = new PlondsStaticUpdateService("https://static.example/lanmountain/update", client); - - var result = await service.CheckForUpdatesAsync(new Version(1, 0, 0), includePrerelease: false); - - Assert.False(result.Success); - Assert.False(result.IsUpdateAvailable); - Assert.Contains("latest manifest", result.ErrorMessage); - } - - [Fact] - public void ResolveCurrentPlatform_UsesCanonicalNames() - { - var platform = PlondsStaticUpdateService.ResolveCurrentPlatform(); - - Assert.DoesNotContain("win-", platform, StringComparison.OrdinalIgnoreCase); - if (OperatingSystem.IsWindows()) - { - Assert.StartsWith("windows-", platform, StringComparison.Ordinal); - } - else if (OperatingSystem.IsLinux()) - { - Assert.StartsWith("linux-", platform, StringComparison.Ordinal); - } - } - - private static HttpResponseMessage Json(string json) - { - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(json, Encoding.UTF8, "application/json") - }; - } - - private sealed class StaticManifestHandler(Func responder) : HttpMessageHandler - { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - return Task.FromResult(responder(request)); - } - } -} - -public sealed class UpdatePathConsistencyTests -{ - [Fact] - public void HostAndSharedUpdatePathsUseLauncherDirectoryCasing() - { - var incoming = UpdatePaths.GetIncomingDirectory("root"); - var sharedIncoming = UpdatePaths.GetIncomingDirectory("root"); - - Assert.Contains($"{Path.DirectorySeparatorChar}.Launcher{Path.DirectorySeparatorChar}", incoming); - Assert.Equal( - Path.Combine("root", ".Launcher", "update", "incoming"), - sharedIncoming); - } -} - -public sealed class PlondsApiManifestProviderTests -{ - [Fact] - public async Task GetLatestAsync_MapsCanonicalAndLegacyFileFields() - { - using var client = new HttpClient(new StaticManifestHandler(request => - { - var path = request.RequestUri?.AbsolutePath ?? string.Empty; - if (path.EndsWith("/api/plonds/v1/channels/stable/windows-x64/latest", StringComparison.Ordinal)) - { - return Json("""{"distributionId":"dist-2","version":"1.2.0","publishedAt":"2026-05-06T00:00:00Z"}"""); - } - - if (path.EndsWith("/api/plonds/v1/distributions/dist-2", StringComparison.Ordinal)) - { - return Json(""" - { - "distributionId": "dist-2", - "version": "1.2.0", - "sourceVersion": "1.1.0", - "publishedAt": "2026-05-06T00:00:00Z", - "fileMapUrl": "https://static.example/filemap.json", - "signatures": [{ "signature": "https://static.example/filemap.json.sig" }], - "components": [ - { - "files": [ - { - "path": "LanMountainDesktop.exe", - "action": "replace", - "sha256": "abc123", - "size": 42, - "objectUrl": "https://static.example/repo/sha256/ab/abc123", - "archiveSha256": "archive123" - }, - { - "path": "legacy.dll", - "op": "add", - "contentHash": "def456", - "size": 7 - } - ] - } - ] - } - """); - } - - return new HttpResponseMessage(HttpStatusCode.NotFound); - })); - var provider = new PlondsApiManifestProvider("https://static.example", client); - - var manifest = await provider.GetLatestAsync("stable", "windows-x64", new Version(1, 1, 0), CancellationToken.None); - - Assert.NotNull(manifest); - Assert.Equal(UpdatePayloadKind.DeltaPlonds, manifest.Kind); - Assert.Equal("https://static.example/filemap.json.sig", manifest.FileMapSignatureUrl); - Assert.Collection( - manifest.Files, - first => - { - Assert.Equal("replace", first.Action); - Assert.Equal("abc123", first.Sha256); - Assert.Equal("https://static.example/repo/sha256/ab/abc123", first.ObjectUrl); - Assert.Equal("archive123", first.ArchiveSha256); - }, - second => - { - Assert.Equal("add", second.Action); - Assert.Equal("def456", second.Sha256); - }); - } - - private static HttpResponseMessage Json(string json) - { - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(json, Encoding.UTF8, "application/json") - }; - } - - private sealed class StaticManifestHandler(Func responder) : HttpMessageHandler - { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - return Task.FromResult(responder(request)); - } - } -} diff --git a/LanMountainDesktop/Services/Update/AppDeploymentLocator.cs b/LanMountainDesktop/Services/Update/AppDeploymentLocator.cs new file mode 100644 index 0000000..8978c6c --- /dev/null +++ b/LanMountainDesktop/Services/Update/AppDeploymentLocator.cs @@ -0,0 +1,160 @@ +using System.Globalization; +using System.Text.Json; +using LanMountainDesktop.Shared.Contracts.Update; + +namespace LanMountainDesktop.Services.Update; + +internal sealed class AppDeploymentLocator(string launcherRoot) +{ + public string LauncherRoot { get; } = launcherRoot; + + public string? FindCurrentDeploymentDirectory() + { + if (!Directory.Exists(LauncherRoot)) + { + return null; + } + + var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop"; + var candidates = Directory.GetDirectories(LauncherRoot, "app-*", SearchOption.TopDirectoryOnly); + + return candidates + .Where(path => !File.Exists(Path.Combine(path, ".destroy"))) + .Where(path => !File.Exists(Path.Combine(path, ".partial"))) + .Where(path => File.Exists(Path.Combine(path, executable))) + .Select(path => new + { + Path = path, + Version = ParseVersionFromDirectory(path), + HasCurrent = File.Exists(Path.Combine(path, ".current")) + }) + .OrderBy(x => x.HasCurrent ? 0 : 1) + .ThenByDescending(x => x.Version) + .Select(x => x.Path) + .FirstOrDefault(); + } + + public string GetCurrentVersion() + { + var deployment = FindCurrentDeploymentDirectory(); + return string.IsNullOrWhiteSpace(deployment) ? "0.0.0" : 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(LauncherRoot, $"app-{sanitized}-{index.ToString(CultureInfo.InvariantCulture)}"); + if (!Directory.Exists(candidate)) + { + return candidate; + } + + index++; + } + } + + public void CleanupOldDeployments(int minVersionsToKeep = 3) + { + try + { + if (!Directory.Exists(LauncherRoot)) + { + return; + } + + var candidates = Directory.GetDirectories(LauncherRoot, "app-*", SearchOption.TopDirectoryOnly); + var validDeployments = candidates + .Where(path => !File.Exists(Path.Combine(path, ".partial"))) + .Select(path => new + { + Path = path, + Version = ParseVersionFromDirectory(path), + IsDestroyed = File.Exists(Path.Combine(path, ".destroy")), + IsCurrent = File.Exists(Path.Combine(path, ".current")) + }) + .OrderByDescending(item => item.Version) + .ToList(); + + var versionsToKeep = new HashSet(StringComparer.OrdinalIgnoreCase); + var currentVersion = validDeployments.FirstOrDefault(d => d.IsCurrent); + if (currentVersion is not null) + { + versionsToKeep.Add(currentVersion.Path); + } + + foreach (var ver in validDeployments.Where(d => !d.IsDestroyed).Take(minVersionsToKeep)) + { + versionsToKeep.Add(ver.Path); + } + + var snapshotsDir = UpdatePaths.GetSnapshotsDirectory(LauncherRoot); + if (Directory.Exists(snapshotsDir)) + { + var snapshotFiles = Directory + .GetFiles(snapshotsDir, "*.json", SearchOption.TopDirectoryOnly) + .OrderByDescending(File.GetCreationTimeUtc) + .Take(Math.Max(1, minVersionsToKeep)); + + foreach (var snapshotFile in snapshotFiles) + { + try + { + var json = File.ReadAllText(snapshotFile); + var snapshot = JsonSerializer.Deserialize(json, UpdateApplyJsonContext.Default.ApplySnapshotMetadata); + if (snapshot is not null && !string.IsNullOrWhiteSpace(snapshot.SourceDirectory) && Directory.Exists(snapshot.SourceDirectory)) + { + versionsToKeep.Add(snapshot.SourceDirectory); + } + } + catch + { + } + } + } + + foreach (var deployment in validDeployments) + { + if (versionsToKeep.Contains(deployment.Path)) + { + if (deployment.IsDestroyed) + { + try { File.Delete(Path.Combine(deployment.Path, ".destroy")); } catch { } + } + + continue; + } + + if (!deployment.IsDestroyed) + { + try { File.WriteAllText(Path.Combine(deployment.Path, ".destroy"), string.Empty); } catch { } + } + + try { Directory.Delete(deployment.Path, 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('-'); + return segments.Length < 2 ? null : segments[1]; + } +} diff --git a/LanMountainDesktop.Launcher/Update/DeploymentActivator.cs b/LanMountainDesktop/Services/Update/DeploymentActivator.cs similarity index 61% rename from LanMountainDesktop.Launcher/Update/DeploymentActivator.cs rename to LanMountainDesktop/Services/Update/DeploymentActivator.cs index 4efccf1..39e2a1f 100644 --- a/LanMountainDesktop.Launcher/Update/DeploymentActivator.cs +++ b/LanMountainDesktop/Services/Update/DeploymentActivator.cs @@ -1,8 +1,6 @@ -using LanMountainDesktop.Launcher.Models; +namespace LanMountainDesktop.Services.Update; -namespace LanMountainDesktop.Launcher.Update; - -internal sealed class DeploymentActivator(DeploymentLocator deploymentLocator) +internal sealed class DeploymentActivator(AppDeploymentLocator deploymentLocator) { public void Activate(string fromDeployment, string toDeployment) { @@ -13,24 +11,14 @@ internal sealed class DeploymentActivator(DeploymentLocator deploymentLocator) var toPartial = Path.Combine(toDeployment, ".partial"); File.WriteAllText(toCurrent, string.Empty); - if (File.Exists(toDestroy)) - { - File.Delete(toDestroy); - } - - if (File.Exists(fromCurrent)) - { - File.Delete(fromCurrent); - } + if (File.Exists(toDestroy)) File.Delete(toDestroy); + if (File.Exists(fromCurrent)) File.Delete(fromCurrent); File.WriteAllText(fromDestroy, string.Empty); - if (File.Exists(toPartial)) - { - File.Delete(toPartial); - } + if (File.Exists(toPartial)) File.Delete(toPartial); } - public RollbackAttemptResult TryRollbackOnFailure(SnapshotMetadata snapshot) + public RollbackAttemptResult TryRollbackOnFailure(ApplySnapshotMetadata snapshot) { try { @@ -45,16 +33,10 @@ internal sealed class DeploymentActivator(DeploymentLocator deploymentLocator) } var destroyMarker = Path.Combine(snapshot.SourceDirectory, ".destroy"); - if (File.Exists(destroyMarker)) - { - File.Delete(destroyMarker); - } + if (File.Exists(destroyMarker)) File.Delete(destroyMarker); var currentMarker = Path.Combine(snapshot.SourceDirectory, ".current"); - if (!File.Exists(currentMarker)) - { - File.WriteAllText(currentMarker, string.Empty); - } + if (!File.Exists(currentMarker)) File.WriteAllText(currentMarker, string.Empty); return new RollbackAttemptResult(true, null); } @@ -64,10 +46,7 @@ internal sealed class DeploymentActivator(DeploymentLocator deploymentLocator) } } - public void RetainDeploymentsForRollback() - { - deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3); - } + public void RetainDeploymentsForRollback() => deploymentLocator.CleanupOldDeployments(3); } internal sealed record RollbackAttemptResult(bool Success, string? ErrorMessage); diff --git a/LanMountainDesktop.Launcher/Update/IncomingArtifactsCleaner.cs b/LanMountainDesktop/Services/Update/IncomingArtifactsCleaner.cs similarity index 81% rename from LanMountainDesktop.Launcher/Update/IncomingArtifactsCleaner.cs rename to LanMountainDesktop/Services/Update/IncomingArtifactsCleaner.cs index 4cb854c..6734681 100644 --- a/LanMountainDesktop.Launcher/Update/IncomingArtifactsCleaner.cs +++ b/LanMountainDesktop/Services/Update/IncomingArtifactsCleaner.cs @@ -1,6 +1,6 @@ -namespace LanMountainDesktop.Launcher.Update; +namespace LanMountainDesktop.Services.Update; -internal sealed class IncomingArtifactsCleaner(UpdateEnginePaths paths) +internal sealed class IncomingArtifactsCleaner(PlondsApplyPaths paths) { public void Cleanup() { @@ -12,7 +12,8 @@ internal sealed class IncomingArtifactsCleaner(UpdateEnginePaths paths) paths.PlondsFileMapPath, paths.PlondsSignaturePath, paths.PlondsUpdateMetadataPath, - paths.InstallCheckpointPath + paths.InstallCheckpointPath, + paths.DownloadMarkerPath }) { TryDeleteFile(path); diff --git a/LanMountainDesktop.Launcher/Update/InstallCheckpointStore.cs b/LanMountainDesktop/Services/Update/InstallCheckpointStore.cs similarity index 64% rename from LanMountainDesktop.Launcher/Update/InstallCheckpointStore.cs rename to LanMountainDesktop/Services/Update/InstallCheckpointStore.cs index 95a2156..39b4964 100644 --- a/LanMountainDesktop.Launcher/Update/InstallCheckpointStore.cs +++ b/LanMountainDesktop/Services/Update/InstallCheckpointStore.cs @@ -1,11 +1,10 @@ using System.Text.Json; -using LanMountainDesktop.Launcher.Models; -namespace LanMountainDesktop.Launcher.Update; +namespace LanMountainDesktop.Services.Update; -internal sealed class InstallCheckpointStore(UpdateEnginePaths paths) +internal sealed class ApplyInstallCheckpointStore(PlondsApplyPaths paths) { - public InstallCheckpoint? Load() + public ApplyInstallCheckpoint? Load() { if (!File.Exists(paths.InstallCheckpointPath)) { @@ -20,7 +19,7 @@ internal sealed class InstallCheckpointStore(UpdateEnginePaths paths) return null; } - return JsonSerializer.Deserialize(text, AppJsonContext.Default.InstallCheckpoint); + return JsonSerializer.Deserialize(text, UpdateApplyJsonContext.Default.ApplyInstallCheckpoint); } catch { @@ -28,9 +27,9 @@ internal sealed class InstallCheckpointStore(UpdateEnginePaths paths) } } - public void Save(InstallCheckpoint checkpoint) + public void Save(ApplyInstallCheckpoint checkpoint) { - File.WriteAllText(paths.InstallCheckpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint)); + File.WriteAllText(paths.InstallCheckpointPath, JsonSerializer.Serialize(checkpoint, UpdateApplyJsonContext.Default.ApplyInstallCheckpoint)); } public void Delete() diff --git a/LanMountainDesktop/Services/Update/PlondsApplyModels.cs b/LanMountainDesktop/Services/Update/PlondsApplyModels.cs new file mode 100644 index 0000000..5db0402 --- /dev/null +++ b/LanMountainDesktop/Services/Update/PlondsApplyModels.cs @@ -0,0 +1,110 @@ +using System.Text.Json.Serialization; + +namespace LanMountainDesktop.Services.Update; + +internal sealed class ApplyUpdateResult +{ + [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("errorMessage")] + public string? ErrorMessage { get; init; } +} + +internal sealed class ApplySnapshotMetadata +{ + 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 ApplyInstallCheckpoint +{ + public string SnapshotId { get; set; } = string.Empty; + public string SourceVersion { get; set; } = string.Empty; + public string? TargetVersion { get; set; } + public string? SourceDirectory { get; set; } + public string TargetDirectory { get; set; } = string.Empty; + public bool IsInitialDeployment { get; set; } + public int AppliedCount { get; set; } + public int VerifiedCount { get; set; } +} + +internal sealed class ApplyPlondsUpdateMetadata +{ + public string? DistributionId { get; set; } + public string? Channel { get; set; } + public string? SubChannel { get; set; } + public string? FromVersion { get; set; } + public string? ToVersion { get; set; } + public string? FileMapPath { get; set; } + public string? FileMapSignaturePath { get; set; } + public Dictionary Metadata { get; set; } = []; +} + +internal sealed class ApplyPlondsFileMap +{ + public string? DistributionId { get; set; } + public string? FromVersion { get; set; } + public string? ToVersion { get; set; } + public string? Version { get; set; } + public string? Platform { get; set; } + public string? Arch { get; set; } + public Dictionary Metadata { get; set; } = []; + public List Components { get; set; } = []; + public List Files { get; set; } = []; +} + +internal sealed class ApplyPlondsComponentEntry +{ + public string Name { get; set; } = string.Empty; + public string? Version { get; set; } + public Dictionary Metadata { get; set; } = []; + public List Files { get; set; } = []; +} + +internal sealed class ApplyPlondsFileEntry +{ + public string Path { get; set; } = string.Empty; + public string? Action { get; set; } = "replace"; + public string? Url { get; set; } + public string? ObjectUrl { get; set; } + public string? ObjectPath { get; set; } + public string? ObjectKey { get; set; } + public string? ArchivePath { get; set; } + public string? Sha256 { get; set; } + public string? Sha512 { get; set; } + public string? Sha512Base64 { get; set; } + public byte[]? Sha512Bytes { get; set; } + public ApplyPlondsHashDescriptor? Hash { get; set; } + public Dictionary Metadata { get; set; } = []; +} + +internal sealed class ApplyPlondsHashDescriptor +{ + public string? Algorithm { get; set; } + public string? Value { get; set; } + public byte[]? Bytes { get; set; } +} diff --git a/LanMountainDesktop.Launcher/Update/UpdateEnginePaths.cs b/LanMountainDesktop/Services/Update/PlondsApplyPaths.cs similarity index 56% rename from LanMountainDesktop.Launcher/Update/UpdateEnginePaths.cs rename to LanMountainDesktop/Services/Update/PlondsApplyPaths.cs index 6096855..00f5620 100644 --- a/LanMountainDesktop.Launcher/Update/UpdateEnginePaths.cs +++ b/LanMountainDesktop/Services/Update/PlondsApplyPaths.cs @@ -1,8 +1,8 @@ -using ContractsUpdate = LanMountainDesktop.Shared.Contracts.Update; +using LanMountainDesktop.Shared.Contracts.Update; -namespace LanMountainDesktop.Launcher.Update; +namespace LanMountainDesktop.Services.Update; -internal sealed class UpdateEnginePaths +internal sealed class PlondsApplyPaths { public const string UpdateDirectoryName = "update"; public const string IncomingDirectoryName = "incoming"; @@ -16,53 +16,34 @@ internal sealed class UpdateEnginePaths public const string PlondsObjectsDirectoryName = "objects"; public const string PublicKeyFileName = "public-key.pem"; - public UpdateEnginePaths(string appRoot) + public PlondsApplyPaths(string launcherRoot) { - AppRoot = appRoot; - var resolver = new DataLocationResolver(appRoot); - LauncherRoot = resolver.ResolveLauncherDataPath(); - IncomingRoot = Path.Combine(LauncherRoot, UpdateDirectoryName, IncomingDirectoryName); - SnapshotsRoot = Path.Combine(LauncherRoot, SnapshotsDirectoryName); - InstallCheckpointPath = ContractsUpdate.UpdatePaths.GetInstallCheckpointPath(appRoot); + LauncherRoot = launcherRoot; + IncomingRoot = UpdatePaths.GetIncomingDirectory(launcherRoot); + SnapshotsRoot = UpdatePaths.GetSnapshotsDirectory(launcherRoot); } - public string AppRoot { get; } - public string LauncherRoot { get; } - public string IncomingRoot { get; } - public string SnapshotsRoot { get; } + public string InstallCheckpointPath => UpdatePaths.GetInstallCheckpointPath(LauncherRoot); - public string InstallCheckpointPath { get; } - - public string ApplyLockPath => ContractsUpdate.UpdatePaths.GetApplyInProgressLockPath(AppRoot); - - public string DeploymentLockPath => ContractsUpdate.UpdatePaths.GetDeploymentLockPath(AppRoot); - - public string DownloadMarkerPath => ContractsUpdate.UpdatePaths.GetDownloadMarkerPath(AppRoot); + public string ApplyLockPath => UpdatePaths.GetApplyInProgressLockPath(LauncherRoot); + public string DeploymentLockPath => UpdatePaths.GetDeploymentLockPath(LauncherRoot); + public string DownloadMarkerPath => UpdatePaths.GetDownloadMarkerPath(LauncherRoot); public string FileMapPath => Path.Combine(IncomingRoot, SignedFileMapName); - public string SignaturePath => Path.Combine(IncomingRoot, SignatureFileName); - public string ArchivePath => Path.Combine(IncomingRoot, ArchiveFileName); public string PlondsFileMapPath => Path.Combine(IncomingRoot, PlondsFileMapName); - public string PlondsSignaturePath => Path.Combine(IncomingRoot, PlondsSignatureFileName); - public string PlondsUpdateMetadataPath => Path.Combine(IncomingRoot, PlondsUpdateMetadataName); - public string PlondsObjectsRoot => Path.Combine(IncomingRoot, PlondsObjectsDirectoryName); - public string PublicKeyPath => Path.Combine(LauncherRoot, UpdateDirectoryName, PublicKeyFileName); - - public string ExtractRoot => Path.Combine(IncomingRoot, "extracted"); + public string PublicKeyPath => Path.Combine(LauncherRoot, ".Launcher", UpdateDirectoryName, PublicKeyFileName); public bool HasPlondsPayload => File.Exists(PlondsFileMapPath) && File.Exists(PlondsSignaturePath); - public bool HasLegacyPayload => File.Exists(FileMapPath) && File.Exists(ArchivePath); - public string GetSnapshotPath(string snapshotId) => Path.Combine(SnapshotsRoot, $"{snapshotId}.json"); } diff --git a/LanMountainDesktop.Launcher/Update/PlondsManifestParser.cs b/LanMountainDesktop/Services/Update/PlondsManifestParser.cs similarity index 90% rename from LanMountainDesktop.Launcher/Update/PlondsManifestParser.cs rename to LanMountainDesktop/Services/Update/PlondsManifestParser.cs index 7d48533..c3b6511 100644 --- a/LanMountainDesktop.Launcher/Update/PlondsManifestParser.cs +++ b/LanMountainDesktop/Services/Update/PlondsManifestParser.cs @@ -1,13 +1,12 @@ using System.Text.Json; -using LanMountainDesktop.Launcher.Models; -namespace LanMountainDesktop.Launcher.Update; +namespace LanMountainDesktop.Services.Update; internal static class PlondsManifestParser { - public static List CollectFileEntries(PlondsFileMap fileMap) + public static List CollectFileEntries(ApplyPlondsFileMap fileMap) { - var files = new List(); + var files = new List(); if (fileMap.Files is { Count: > 0 }) { files.AddRange(fileMap.Files); @@ -29,7 +28,7 @@ internal static class PlondsManifestParser return files; } - public static void PopulateFromRawJson(string fileMapJson, PlondsFileMap fileMap, ICollection files) + public static void PopulateFromRawJson(string fileMapJson, ApplyPlondsFileMap fileMap, ICollection files) { if (string.IsNullOrWhiteSpace(fileMapJson)) { @@ -62,7 +61,7 @@ internal static class PlondsManifestParser } } - public static PlondsUpdateMetadata? LoadMetadata(string path) + public static ApplyPlondsUpdateMetadata? LoadMetadata(string path) { if (!File.Exists(path)) { @@ -74,7 +73,7 @@ internal static class PlondsManifestParser var text = File.ReadAllText(path); return string.IsNullOrWhiteSpace(text) ? null - : JsonSerializer.Deserialize(text, AppJsonContext.Default.PlondsUpdateMetadata); + : JsonSerializer.Deserialize(text, UpdateApplyJsonContext.Default.ApplyPlondsUpdateMetadata); } catch { @@ -82,7 +81,7 @@ internal static class PlondsManifestParser } } - public static string? ResolveSourceVersion(PlondsFileMap fileMap, PlondsUpdateMetadata? metadata) + public static string? ResolveSourceVersion(ApplyPlondsFileMap fileMap, ApplyPlondsUpdateMetadata? metadata) { return FirstNonEmpty( metadata?.FromVersion, @@ -91,7 +90,7 @@ internal static class PlondsManifestParser TryGetMetadataValue(fileMap.Metadata, "sourceVersion")); } - public static string? ResolveTargetVersion(PlondsFileMap fileMap, PlondsUpdateMetadata? metadata) + public static string? ResolveTargetVersion(ApplyPlondsFileMap fileMap, ApplyPlondsUpdateMetadata? metadata) { return FirstNonEmpty( metadata?.ToVersion, @@ -101,7 +100,7 @@ internal static class PlondsManifestParser TryGetMetadataValue(fileMap.Metadata, "targetVersion")); } - public static bool TryGetExpectedSha512(PlondsFileEntry file, out byte[] expected) + public static bool TryGetExpectedSha512(ApplyPlondsFileEntry file, out byte[] expected) { expected = []; if (file.Sha512Bytes is { Length: > 0 }) @@ -134,7 +133,7 @@ internal static class PlondsManifestParser return UpdateHash.TryParseHashBytes(file.Sha512Base64, out expected); } - public static bool TryGetExpectedObjectSha512(PlondsFileEntry file, out byte[] expected) + public static bool TryGetExpectedObjectSha512(ApplyPlondsFileEntry file, out byte[] expected) { expected = []; if (file.Hash is null) @@ -157,7 +156,7 @@ internal static class PlondsManifestParser return UpdateHash.TryParseHashBytes(file.Hash.Value, out expected); } - private static void ParseComponentsNode(JsonElement componentsNode, ICollection files) + private static void ParseComponentsNode(JsonElement componentsNode, ICollection files) { if (componentsNode.ValueKind == JsonValueKind.Object) { @@ -193,7 +192,7 @@ internal static class PlondsManifestParser } } - private static void ParseFilesNode(JsonElement filesNode, string? componentName, ICollection files) + private static void ParseFilesNode(JsonElement filesNode, string? componentName, ICollection files) { if (filesNode.ValueKind == JsonValueKind.Object) { @@ -224,9 +223,9 @@ internal static class PlondsManifestParser } } - private static bool TryCreateFileEntry(string? fallbackPath, string? componentName, JsonElement node, out PlondsFileEntry entry) + private static bool TryCreateFileEntry(string? fallbackPath, string? componentName, JsonElement node, out ApplyPlondsFileEntry entry) { - entry = new PlondsFileEntry(); + entry = new ApplyPlondsFileEntry(); var path = ReadStringIgnoreCase(node, "path"); if (string.IsNullOrWhiteSpace(path)) { @@ -240,7 +239,7 @@ internal static class PlondsManifestParser var archiveSha512 = ReadByteArrayIgnoreCase(node, "archivesha512"); var archiveSha512Text = ReadStringIgnoreCase(node, "archivesha512"); - entry = new PlondsFileEntry + entry = new ApplyPlondsFileEntry { Path = path, Action = FirstNonEmpty(ReadStringIgnoreCase(node, "action"), "replace"), @@ -257,7 +256,7 @@ internal static class PlondsManifestParser if (archiveSha512 is { Length: > 0 } || !string.IsNullOrWhiteSpace(archiveSha512Text)) { - entry.Hash = new PlondsHashDescriptor + entry.Hash = new ApplyPlondsHashDescriptor { Algorithm = "sha512", Bytes = archiveSha512, @@ -268,7 +267,7 @@ internal static class PlondsManifestParser } else if (TryGetPropertyIgnoreCase(node, "hash", out var hashNode) && hashNode.ValueKind == JsonValueKind.Object) { - entry.Hash = new PlondsHashDescriptor + entry.Hash = new ApplyPlondsHashDescriptor { Algorithm = ReadStringIgnoreCase(hashNode, "algorithm"), Value = ReadStringIgnoreCase(hashNode, "value"), diff --git a/LanMountainDesktop.Launcher/Update/PlondsPayloadResolver.cs b/LanMountainDesktop/Services/Update/PlondsPayloadResolver.cs similarity index 70% rename from LanMountainDesktop.Launcher/Update/PlondsPayloadResolver.cs rename to LanMountainDesktop/Services/Update/PlondsPayloadResolver.cs index 90ab9cc..7a6bb8e 100644 --- a/LanMountainDesktop.Launcher/Update/PlondsPayloadResolver.cs +++ b/LanMountainDesktop/Services/Update/PlondsPayloadResolver.cs @@ -1,11 +1,10 @@ using System.IO.Compression; -using LanMountainDesktop.Launcher.Models; -namespace LanMountainDesktop.Launcher.Update; +namespace LanMountainDesktop.Services.Update; -internal sealed class PlondsPayloadResolver(UpdateEnginePaths paths) +internal sealed class PlondsPayloadResolver(PlondsApplyPaths paths) { - public string ResolveObjectPath(PlondsFileEntry file) + public string ResolveObjectPath(ApplyPlondsFileEntry file) { var candidates = new List(); AddPathCandidates(candidates, file.ObjectPath); @@ -18,14 +17,14 @@ internal sealed class PlondsPayloadResolver(UpdateEnginePaths paths) PlondsManifestParser.TryGetExpectedSha512(file, out expectedSha512)) { var hashHex = Convert.ToHexString(expectedSha512).ToLowerInvariant(); - AddPathCandidates(candidates, Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, hashHex)); + AddPathCandidates(candidates, Path.Combine(PlondsApplyPaths.PlondsObjectsDirectoryName, hashHex)); if (hashHex.Length > 2) { - AddPathCandidates(candidates, Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, hashHex[..2], hashHex)); - AddPathCandidates(candidates, Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, hashHex[..2], hashHex[2..])); + AddPathCandidates(candidates, Path.Combine(PlondsApplyPaths.PlondsObjectsDirectoryName, hashHex[..2], hashHex)); + AddPathCandidates(candidates, Path.Combine(PlondsApplyPaths.PlondsObjectsDirectoryName, hashHex[..2], hashHex[2..])); } - AddPathCandidates(candidates, Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, $"{hashHex}.gz")); + AddPathCandidates(candidates, Path.Combine(PlondsApplyPaths.PlondsObjectsDirectoryName, $"{hashHex}.gz")); } foreach (var relativePath in candidates.Distinct(StringComparer.OrdinalIgnoreCase)) @@ -83,15 +82,15 @@ internal sealed class PlondsPayloadResolver(UpdateEnginePaths paths) normalized = normalized.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar); candidates.Add(normalized); - if (!normalized.StartsWith($"{UpdateEnginePaths.PlondsObjectsDirectoryName}{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)) + if (!normalized.StartsWith($"{PlondsApplyPaths.PlondsObjectsDirectoryName}{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)) { - candidates.Add(Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, normalized)); + candidates.Add(Path.Combine(PlondsApplyPaths.PlondsObjectsDirectoryName, normalized)); } var fileName = Path.GetFileName(normalized); if (!string.IsNullOrWhiteSpace(fileName)) { - candidates.Add(Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, fileName)); + candidates.Add(Path.Combine(PlondsApplyPaths.PlondsObjectsDirectoryName, fileName)); } } } diff --git a/LanMountainDesktop.Launcher/Update/PlondsUpdateApplier.cs b/LanMountainDesktop/Services/Update/PlondsUpdateApplier.cs similarity index 54% rename from LanMountainDesktop.Launcher/Update/PlondsUpdateApplier.cs rename to LanMountainDesktop/Services/Update/PlondsUpdateApplier.cs index 32586c8..eb2a623 100644 --- a/LanMountainDesktop.Launcher/Update/PlondsUpdateApplier.cs +++ b/LanMountainDesktop/Services/Update/PlondsUpdateApplier.cs @@ -1,32 +1,54 @@ using System.Text.Json; -using LanMountainDesktop.Launcher.Models; -using ContractsUpdate = LanMountainDesktop.Shared.Contracts.Update; +using LanMountainDesktop.Shared.Contracts.Update; -namespace LanMountainDesktop.Launcher.Update; +namespace LanMountainDesktop.Services.Update; + +internal interface IUpdateProgressReporter +{ + void ReportProgress(InstallProgressReport report); + void ReportComplete(InstallCompleteReport report); +} + +internal sealed class InstallProgressBridge(IProgress? progress) : IUpdateProgressReporter +{ + private InstallCompleteReport? _complete; + + public InstallCompleteReport? CompleteReport => _complete; + + public void ReportProgress(InstallProgressReport report) + { + progress?.Report(report); + } + + public void ReportComplete(InstallCompleteReport report) + { + _complete = report; + } +} internal sealed class PlondsUpdateApplier( - DeploymentLocator deploymentLocator, - UpdateEnginePaths paths, + AppDeploymentLocator deploymentLocator, + PlondsApplyPaths paths, UpdateSignatureVerifier signatureVerifier, IUpdateProgressReporter progressReporter, UpdateSnapshotStore snapshotStore, - InstallCheckpointStore checkpointStore, + ApplyInstallCheckpointStore checkpointStore, DeploymentActivator deploymentActivator, IncomingArtifactsCleaner incomingCleaner, PlondsPayloadResolver payloadResolver) { - public async Task ApplyAsync() + public async Task ApplyAsync() { - progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifySignature, "Verifying PLONDS signature...", 0, null, 0, 0)); - var verifyResult = signatureVerifier.Verify(paths.PlondsFileMapPath, paths.PlondsSignaturePath, UpdateEnginePaths.PlondsSignatureFileName); + progressReporter.ReportProgress(new InstallProgressReport(InstallStage.VerifySignature, "Verifying PLONDS signature...", 0, null, 0, 0)); + var verifyResult = signatureVerifier.Verify(paths.PlondsFileMapPath, paths.PlondsSignaturePath, PlondsApplyPaths.PlondsSignatureFileName); if (!verifyResult.Success) { - progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, verifyResult.Message, false)); - return UpdateEngineResults.Failed("update.apply", "signature_failed", verifyResult.Message); + progressReporter.ReportComplete(new InstallCompleteReport(false, null, null, verifyResult.Message, false)); + return ApplyUpdateResults.Failed("update.apply", "signature_failed", verifyResult.Message); } var fileMapText = await File.ReadAllTextAsync(paths.PlondsFileMapPath).ConfigureAwait(false); - var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.PlondsFileMap) ?? new PlondsFileMap(); + var fileMap = JsonSerializer.Deserialize(fileMapText, UpdateApplyJsonContext.Default.ApplyPlondsFileMap) ?? new ApplyPlondsFileMap(); var fileEntries = PlondsManifestParser.CollectFileEntries(fileMap); if (fileEntries.Count == 0) { @@ -35,8 +57,8 @@ internal sealed class PlondsUpdateApplier( if (fileEntries.Count == 0) { - progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, null, null, "No PLONDS file entries were found.", false)); - return UpdateEngineResults.Failed("update.apply", "invalid_manifest", "No PLONDS file entries were found."); + progressReporter.ReportComplete(new InstallCompleteReport(false, null, null, "No PLONDS file entries were found.", false)); + return ApplyUpdateResults.Failed("update.apply", "invalid_manifest", "No PLONDS file entries were found."); } var plondsMetadata = PlondsManifestParser.LoadMetadata(paths.PlondsUpdateMetadataPath); @@ -47,17 +69,11 @@ internal sealed class PlondsUpdateApplier( if (!string.IsNullOrWhiteSpace(expectedSourceVersion) && !string.Equals(expectedSourceVersion, sourceVersion, StringComparison.OrdinalIgnoreCase)) { - return UpdateEngineResults.Failed( - "update.apply", - "version_mismatch", - $"PLONDS update requires source version {expectedSourceVersion} but current is {sourceVersion}."); + return ApplyUpdateResults.Failed("update.apply", "version_mismatch", $"PLONDS update requires source version {expectedSourceVersion} but current is {sourceVersion}."); } var targetVersion = PlondsManifestParser.ResolveTargetVersion(fileMap, plondsMetadata); - if (string.IsNullOrWhiteSpace(targetVersion)) - { - targetVersion = sourceVersion; - } + if (string.IsNullOrWhiteSpace(targetVersion)) targetVersion = sourceVersion; var isInitialDeployment = string.IsNullOrWhiteSpace(currentDeployment); var existingCheckpoint = checkpointStore.Load(); @@ -70,29 +86,21 @@ internal sealed class PlondsUpdateApplier( if (existingCheckpoint is not null && !canResume) { - return UpdateEngineResults.Failed("update.apply", "resume_state_invalid", "Install checkpoint is stale or invalid. Please cancel and redownload update payload."); + return ApplyUpdateResults.Failed("update.apply", "resume_state_invalid", "Install checkpoint is stale or invalid. Please cancel and redownload update payload."); } - var targetDeployment = canResume - ? existingCheckpoint!.TargetDirectory - : deploymentLocator.BuildNextDeploymentDirectory(targetVersion!); + var targetDeployment = canResume ? existingCheckpoint!.TargetDirectory : deploymentLocator.BuildNextDeploymentDirectory(targetVersion!); var snapshot = BuildSnapshot(canResume, existingCheckpoint, sourceVersion, targetVersion, currentDeployment, targetDeployment); var snapshotPath = snapshotStore.CreateSnapshotPath(snapshot.SnapshotId); - var checkpoint = canResume - ? existingCheckpoint! - : BuildCheckpoint(snapshot, sourceVersion, targetVersion, currentDeployment, targetDeployment, isInitialDeployment); + var checkpoint = canResume ? existingCheckpoint! : BuildCheckpoint(snapshot, sourceVersion, targetVersion, currentDeployment, targetDeployment, isInitialDeployment); try { snapshotStore.Save(snapshotPath, snapshot); if (!canResume) { - if (Directory.Exists(targetDeployment)) - { - Directory.Delete(targetDeployment, true); - } - - progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileEntries.Count)); + if (Directory.Exists(targetDeployment)) Directory.Delete(targetDeployment, true); + progressReporter.ReportProgress(new InstallProgressReport(InstallStage.CreateTarget, "Creating target deployment...", 20, null, 0, fileEntries.Count)); Directory.CreateDirectory(targetDeployment); File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty); } @@ -105,14 +113,11 @@ internal sealed class PlondsUpdateApplier( { File.WriteAllText(Path.Combine(targetDeployment, ".current"), string.Empty); var partialMarker = Path.Combine(targetDeployment, ".partial"); - if (File.Exists(partialMarker)) - { - File.Delete(partialMarker); - } + if (File.Exists(partialMarker)) File.Delete(partialMarker); } else { - progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileEntries.Count, fileEntries.Count)); + progressReporter.ReportProgress(new InstallProgressReport(InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileEntries.Count, fileEntries.Count)); deploymentActivator.Activate(currentDeployment!, targetDeployment); } @@ -121,10 +126,10 @@ internal sealed class PlondsUpdateApplier( incomingCleaner.Cleanup(); deploymentActivator.RetainDeploymentsForRollback(); - 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)); + progressReporter.ReportProgress(new InstallProgressReport(InstallStage.Completed, $"Updated to {targetVersion}.", 100, null, fileEntries.Count, fileEntries.Count)); + progressReporter.ReportComplete(new InstallCompleteReport(true, sourceVersion, targetVersion, null, false)); - return new LauncherResult + return new ApplyUpdateResult { Success = true, Stage = "update.apply", @@ -144,48 +149,42 @@ internal sealed class PlondsUpdateApplier( } } - private void ApplyFiles(IReadOnlyList fileEntries, string? currentDeployment, string targetDeployment, InstallCheckpoint checkpoint) + private void ApplyFiles(IReadOnlyList fileEntries, string? currentDeployment, string targetDeployment, ApplyInstallCheckpoint checkpoint) { - progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30, null, checkpoint.AppliedCount, fileEntries.Count)); + progressReporter.ReportProgress(new InstallProgressReport(InstallStage.ApplyFiles, "Applying PLONDS files...", 30, null, checkpoint.AppliedCount, fileEntries.Count)); for (var fileIndex = checkpoint.AppliedCount; fileIndex < fileEntries.Count; fileIndex++) { var entry = fileEntries[fileIndex]; ApplyFileEntry(entry, currentDeployment, targetDeployment); checkpoint.AppliedCount = fileIndex + 1; checkpointStore.Save(checkpoint); - progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ApplyFiles, "Applying PLONDS files...", 30 + (checkpoint.AppliedCount * 30 / fileEntries.Count), entry.Path, checkpoint.AppliedCount, fileEntries.Count)); + progressReporter.ReportProgress(new InstallProgressReport(InstallStage.ApplyFiles, "Applying PLONDS files...", 30 + (checkpoint.AppliedCount * 30 / fileEntries.Count), entry.Path, checkpoint.AppliedCount, fileEntries.Count)); } } - private void VerifyFiles(IReadOnlyList fileEntries, string targetDeployment, InstallCheckpoint checkpoint) + private void VerifyFiles(IReadOnlyList fileEntries, string targetDeployment, ApplyInstallCheckpoint checkpoint) { - progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65, null, checkpoint.VerifiedCount, fileEntries.Count)); + progressReporter.ReportProgress(new InstallProgressReport(InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65, null, checkpoint.VerifiedCount, fileEntries.Count)); for (var verifyIndex = checkpoint.VerifiedCount; verifyIndex < fileEntries.Count; verifyIndex++) { var entry = fileEntries[verifyIndex]; VerifyFileEntry(entry, targetDeployment); checkpoint.VerifiedCount = verifyIndex + 1; checkpointStore.Save(checkpoint); - progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65 + (checkpoint.VerifiedCount * 15 / fileEntries.Count), entry.Path, checkpoint.VerifiedCount, fileEntries.Count)); + progressReporter.ReportProgress(new InstallProgressReport(InstallStage.VerifyHashes, "Verifying PLONDS hashes...", 65 + (checkpoint.VerifiedCount * 15 / fileEntries.Count), entry.Path, checkpoint.VerifiedCount, fileEntries.Count)); } } - private void ApplyFileEntry(PlondsFileEntry file, string? currentDeployment, string targetDeployment) + private void ApplyFileEntry(ApplyPlondsFileEntry file, string? currentDeployment, string targetDeployment) { var normalizedPath = UpdatePathGuard.NormalizeRelativePath(file.Path); var action = string.IsNullOrWhiteSpace(file.Action) ? "replace" : file.Action!; - if (string.Equals(action, "delete", StringComparison.OrdinalIgnoreCase)) - { - return; - } + if (string.Equals(action, "delete", StringComparison.OrdinalIgnoreCase)) return; var targetPath = Path.Combine(targetDeployment, normalizedPath); UpdatePathGuard.EnsurePathWithinRoot(targetPath, targetDeployment); var targetDir = Path.GetDirectoryName(targetPath); - if (!string.IsNullOrWhiteSpace(targetDir)) - { - Directory.CreateDirectory(targetDir); - } + if (!string.IsNullOrWhiteSpace(targetDir)) Directory.CreateDirectory(targetDir); if (string.Equals(action, "reuse", StringComparison.OrdinalIgnoreCase)) { @@ -200,47 +199,30 @@ internal sealed class PlondsUpdateApplier( ApplyUnixFileModeIfPresent(targetPath, file); } - private static void CopyReusedFile(PlondsFileEntry file, string? currentDeployment, string normalizedPath, string targetPath) + private static void CopyReusedFile(ApplyPlondsFileEntry file, string? currentDeployment, string normalizedPath, string targetPath) { - if (string.IsNullOrWhiteSpace(currentDeployment)) - { - throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because no source deployment is available."); - } - + if (string.IsNullOrWhiteSpace(currentDeployment)) throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because no source deployment is available."); var sourcePath = Path.Combine(currentDeployment, normalizedPath); UpdatePathGuard.EnsurePathWithinRoot(sourcePath, currentDeployment); - if (!File.Exists(sourcePath)) - { - throw new FileNotFoundException($"Cannot reuse file '{file.Path}' because it was not found in current deployment."); - } + 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); ApplyUnixFileModeIfPresent(targetPath, file); } - private static void VerifyFileEntry(PlondsFileEntry file, string targetDeployment) + private static void VerifyFileEntry(ApplyPlondsFileEntry file, string targetDeployment) { var action = string.IsNullOrWhiteSpace(file.Action) ? "replace" : file.Action!; - if (string.Equals(action, "delete", StringComparison.OrdinalIgnoreCase)) - { - return; - } + if (string.Equals(action, "delete", StringComparison.OrdinalIgnoreCase)) return; var targetPath = Path.Combine(targetDeployment, UpdatePathGuard.NormalizeRelativePath(file.Path)); UpdatePathGuard.EnsurePathWithinRoot(targetPath, targetDeployment); - if (!File.Exists(targetPath)) - { - throw new FileNotFoundException($"Expected target file was not created: {file.Path}"); - } + if (!File.Exists(targetPath)) throw new FileNotFoundException($"Expected target file was not created: {file.Path}"); if (PlondsManifestParser.TryGetExpectedSha512(file, out var expectedSha512)) { var actualSha512 = UpdateHash.ComputeSha512(targetPath); - if (!actualSha512.AsSpan().SequenceEqual(expectedSha512)) - { - throw new InvalidOperationException($"SHA-512 mismatch for '{file.Path}'."); - } - + if (!actualSha512.AsSpan().SequenceEqual(expectedSha512)) throw new InvalidOperationException($"SHA-512 mismatch for '{file.Path}'."); return; } @@ -248,29 +230,19 @@ internal sealed class PlondsUpdateApplier( { var expectedSha256 = UpdateHash.NormalizeHashText(file.Sha256); var actualSha256 = UpdateHash.ComputeSha256Hex(targetPath); - if (!string.Equals(actualSha256, expectedSha256, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException($"SHA-256 mismatch for '{file.Path}'."); - } + if (!string.Equals(actualSha256, expectedSha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException($"SHA-256 mismatch for '{file.Path}'."); } } - private LauncherResult HandleFailure( - Exception ex, - bool isInitialDeployment, - string targetDeployment, - SnapshotMetadata snapshot, - string snapshotPath, - string sourceVersion, - string targetVersion) + private ApplyUpdateResult HandleFailure(Exception ex, bool isInitialDeployment, string targetDeployment, ApplySnapshotMetadata snapshot, string snapshotPath, string sourceVersion, string targetVersion) { if (isInitialDeployment) { TryDeleteDirectory(targetDeployment); snapshot.Status = "failed"; snapshotStore.Save(snapshotPath, snapshot); - progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, "0.0.0", targetVersion, ex.Message, false)); - return new LauncherResult + progressReporter.ReportComplete(new InstallCompleteReport(false, "0.0.0", targetVersion, ex.Message, false)); + return new ApplyUpdateResult { Success = false, Stage = "update.apply", @@ -282,35 +254,27 @@ internal sealed class PlondsUpdateApplier( }; } - progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0)); + progressReporter.ReportProgress(new InstallProgressReport(InstallStage.RollingBack, "Rolling back...", 0, null, 0, 0)); var rollbackResult = deploymentActivator.TryRollbackOnFailure(snapshot); snapshot.Status = rollbackResult.Success ? "rolled_back" : "rollback_failed"; snapshotStore.Save(snapshotPath, snapshot); - var errorMessage = rollbackResult.Success - ? ex.Message - : $"{ex.Message}; rollback failed: {rollbackResult.ErrorMessage}"; - progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(false, sourceVersion, targetVersion, errorMessage, rollbackResult.Success)); - return new LauncherResult + + var errorMessage = rollbackResult.Success ? ex.Message : $"{ex.Message}; rollback failed: {rollbackResult.ErrorMessage}"; + progressReporter.ReportComplete(new InstallCompleteReport(false, sourceVersion, targetVersion, errorMessage, rollbackResult.Success)); + + return new ApplyUpdateResult { Success = false, Stage = "update.apply", Code = rollbackResult.Success ? "apply_failed" : "rollback_failed", - Message = rollbackResult.Success - ? "Failed to apply PLONDS update. Rolled back to previous version." - : "Failed to apply PLONDS update and rollback failed.", + Message = rollbackResult.Success ? "Failed to apply PLONDS update. Rolled back to previous version." : "Failed to apply PLONDS update and rollback failed.", ErrorMessage = errorMessage, CurrentVersion = sourceVersion, RolledBackTo = rollbackResult.Success ? sourceVersion : null }; } - private static SnapshotMetadata BuildSnapshot( - bool canResume, - InstallCheckpoint? existingCheckpoint, - string sourceVersion, - string targetVersion, - string? currentDeployment, - string targetDeployment) => + private static ApplySnapshotMetadata BuildSnapshot(bool canResume, ApplyInstallCheckpoint? existingCheckpoint, string sourceVersion, string targetVersion, string? currentDeployment, string targetDeployment) => new() { SnapshotId = canResume ? existingCheckpoint!.SnapshotId : Guid.NewGuid().ToString("N"), @@ -322,13 +286,7 @@ internal sealed class PlondsUpdateApplier( Status = "pending" }; - private static InstallCheckpoint BuildCheckpoint( - SnapshotMetadata snapshot, - string sourceVersion, - string targetVersion, - string? currentDeployment, - string targetDeployment, - bool isInitialDeployment) => + private static ApplyInstallCheckpoint BuildCheckpoint(ApplySnapshotMetadata snapshot, string sourceVersion, string targetVersion, string? currentDeployment, string targetDeployment, bool isInitialDeployment) => new() { SnapshotId = snapshot.SnapshotId, @@ -339,15 +297,9 @@ internal sealed class PlondsUpdateApplier( IsInitialDeployment = isInitialDeployment }; - private static void ApplyUnixFileModeIfPresent(string targetPath, PlondsFileEntry file) + private static void ApplyUnixFileModeIfPresent(string targetPath, ApplyPlondsFileEntry file) { - if (OperatingSystem.IsWindows() || - !file.Metadata.TryGetValue("unixFileMode", out var rawMode) || - string.IsNullOrWhiteSpace(rawMode)) - { - return; - } - + if (OperatingSystem.IsWindows() || !file.Metadata.TryGetValue("unixFileMode", out var rawMode) || string.IsNullOrWhiteSpace(rawMode)) return; try { var modeValue = Convert.ToInt32(rawMode.Trim(), 8); @@ -362,10 +314,7 @@ internal sealed class PlondsUpdateApplier( { try { - if (Directory.Exists(path)) - { - Directory.Delete(path, true); - } + if (Directory.Exists(path)) Directory.Delete(path, true); } catch { diff --git a/LanMountainDesktop.Launcher/Update/RollbackStrategy.cs b/LanMountainDesktop/Services/Update/RollbackStrategy.cs similarity index 59% rename from LanMountainDesktop.Launcher/Update/RollbackStrategy.cs rename to LanMountainDesktop/Services/Update/RollbackStrategy.cs index 508bc07..e6646a3 100644 --- a/LanMountainDesktop.Launcher/Update/RollbackStrategy.cs +++ b/LanMountainDesktop/Services/Update/RollbackStrategy.cs @@ -1,42 +1,40 @@ -using LanMountainDesktop.Launcher.Models; - -namespace LanMountainDesktop.Launcher.Update; +namespace LanMountainDesktop.Services.Update; internal sealed class RollbackStrategy( - DeploymentLocator deploymentLocator, + AppDeploymentLocator deploymentLocator, UpdateSnapshotStore snapshotStore, DeploymentActivator deploymentActivator) { - public LauncherResult RollbackLatest() + public ApplyUpdateResult RollbackLatest() { var latest = snapshotStore.LoadLatest(); if (latest is null) { - return UpdateEngineResults.Failed("update.rollback", "no_snapshot", "No snapshot found."); + return ApplyUpdateResults.Failed("update.rollback", "no_snapshot", "No snapshot found."); } var (snapshotPath, snapshot) = latest.Value; if (string.IsNullOrWhiteSpace(snapshot.SourceDirectory)) { - return UpdateEngineResults.Failed("update.rollback", "invalid_snapshot", "Invalid snapshot metadata."); + return ApplyUpdateResults.Failed("update.rollback", "invalid_snapshot", "Invalid snapshot metadata."); } if (!Directory.Exists(snapshot.SourceDirectory)) { - return UpdateEngineResults.Failed("update.rollback", "source_missing", $"Rollback source deployment is missing: {snapshot.SourceDirectory}"); + return ApplyUpdateResults.Failed("update.rollback", "source_missing", $"Rollback source deployment is missing: {snapshot.SourceDirectory}"); } var currentDeployment = deploymentLocator.FindCurrentDeploymentDirectory(); if (string.IsNullOrWhiteSpace(currentDeployment)) { - return UpdateEngineResults.Failed("update.rollback", "no_current_deployment", "Current deployment not found."); + return ApplyUpdateResults.Failed("update.rollback", "no_current_deployment", "Current deployment not found."); } deploymentActivator.Activate(currentDeployment, snapshot.SourceDirectory); snapshot.Status = "manual_rollback"; snapshotStore.Save(snapshotPath, snapshot); - return new LauncherResult + return new ApplyUpdateResult { Success = true, Stage = "update.rollback", diff --git a/LanMountainDesktop/Services/Update/UpdateApplyJsonContext.cs b/LanMountainDesktop/Services/Update/UpdateApplyJsonContext.cs new file mode 100644 index 0000000..58faf29 --- /dev/null +++ b/LanMountainDesktop/Services/Update/UpdateApplyJsonContext.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace LanMountainDesktop.Services.Update; + +[JsonSourceGenerationOptions( + WriteIndented = false, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true)] +[JsonSerializable(typeof(ApplyPlondsFileMap))] +[JsonSerializable(typeof(ApplyPlondsUpdateMetadata))] +[JsonSerializable(typeof(ApplySnapshotMetadata))] +[JsonSerializable(typeof(ApplyInstallCheckpoint))] +internal sealed partial class UpdateApplyJsonContext : JsonSerializerContext; diff --git a/LanMountainDesktop/Services/Update/UpdateApplyResults.cs b/LanMountainDesktop/Services/Update/UpdateApplyResults.cs new file mode 100644 index 0000000..2647070 --- /dev/null +++ b/LanMountainDesktop/Services/Update/UpdateApplyResults.cs @@ -0,0 +1,16 @@ +namespace LanMountainDesktop.Services.Update; + +internal static class ApplyUpdateResults +{ + public static ApplyUpdateResult Failed(string stage, string code, string message) + { + return new ApplyUpdateResult + { + Success = false, + Stage = stage, + Code = code, + Message = message, + ErrorMessage = message + }; + } +} diff --git a/LanMountainDesktop.Launcher/Update/UpdateHash.cs b/LanMountainDesktop/Services/Update/UpdateHash.cs similarity index 97% rename from LanMountainDesktop.Launcher/Update/UpdateHash.cs rename to LanMountainDesktop/Services/Update/UpdateHash.cs index 134ba78..eb74618 100644 --- a/LanMountainDesktop.Launcher/Update/UpdateHash.cs +++ b/LanMountainDesktop/Services/Update/UpdateHash.cs @@ -1,6 +1,6 @@ using System.Security.Cryptography; -namespace LanMountainDesktop.Launcher.Update; +namespace LanMountainDesktop.Services.Update; internal static class UpdateHash { diff --git a/LanMountainDesktop.Launcher/Update/UpdatePathGuard.cs b/LanMountainDesktop/Services/Update/UpdatePathGuard.cs similarity index 93% rename from LanMountainDesktop.Launcher/Update/UpdatePathGuard.cs rename to LanMountainDesktop/Services/Update/UpdatePathGuard.cs index 2750489..11bb79f 100644 --- a/LanMountainDesktop.Launcher/Update/UpdatePathGuard.cs +++ b/LanMountainDesktop/Services/Update/UpdatePathGuard.cs @@ -1,4 +1,4 @@ -namespace LanMountainDesktop.Launcher.Update; +namespace LanMountainDesktop.Services.Update; internal static class UpdatePathGuard { diff --git a/LanMountainDesktop/Services/Update/UpdateRollbackGateway.cs b/LanMountainDesktop/Services/Update/UpdateRollbackGateway.cs new file mode 100644 index 0000000..9aa0751 --- /dev/null +++ b/LanMountainDesktop/Services/Update/UpdateRollbackGateway.cs @@ -0,0 +1,16 @@ +using LanMountainDesktop.Shared.Contracts.Update; + +namespace LanMountainDesktop.Services.Update; + +internal sealed class UpdateRollbackGateway +{ + public ApplyUpdateResult RollbackLatest(string launcherRoot) + { + var paths = new PlondsApplyPaths(launcherRoot); + var locator = new AppDeploymentLocator(launcherRoot); + var snapshotStore = new UpdateSnapshotStore(paths); + var activator = new DeploymentActivator(locator); + var strategy = new RollbackStrategy(locator, snapshotStore, activator); + return strategy.RollbackLatest(); + } +} diff --git a/LanMountainDesktop.Launcher/Update/UpdateSignatureVerifier.cs b/LanMountainDesktop/Services/Update/UpdateSignatureVerifier.cs similarity index 91% rename from LanMountainDesktop.Launcher/Update/UpdateSignatureVerifier.cs rename to LanMountainDesktop/Services/Update/UpdateSignatureVerifier.cs index 65b919c..cb8e201 100644 --- a/LanMountainDesktop.Launcher/Update/UpdateSignatureVerifier.cs +++ b/LanMountainDesktop/Services/Update/UpdateSignatureVerifier.cs @@ -1,8 +1,8 @@ using System.Security.Cryptography; -namespace LanMountainDesktop.Launcher.Update; +namespace LanMountainDesktop.Services.Update; -internal sealed class UpdateSignatureVerifier(UpdateEnginePaths paths) +internal sealed class UpdateSignatureVerifier(PlondsApplyPaths paths) { public (bool Success, string Message) Verify(string payloadPath, string signaturePath, string signatureName) { diff --git a/LanMountainDesktop.Launcher/Update/UpdateSnapshotStore.cs b/LanMountainDesktop/Services/Update/UpdateSnapshotStore.cs similarity index 66% rename from LanMountainDesktop.Launcher/Update/UpdateSnapshotStore.cs rename to LanMountainDesktop/Services/Update/UpdateSnapshotStore.cs index a58e285..f3a2ea8 100644 --- a/LanMountainDesktop.Launcher/Update/UpdateSnapshotStore.cs +++ b/LanMountainDesktop/Services/Update/UpdateSnapshotStore.cs @@ -1,18 +1,17 @@ using System.Text.Json; -using LanMountainDesktop.Launcher.Models; -namespace LanMountainDesktop.Launcher.Update; +namespace LanMountainDesktop.Services.Update; -internal sealed class UpdateSnapshotStore(UpdateEnginePaths paths) +internal sealed class UpdateSnapshotStore(PlondsApplyPaths paths) { public string CreateSnapshotPath(string snapshotId) => paths.GetSnapshotPath(snapshotId); - public void Save(string path, SnapshotMetadata snapshot) + public void Save(string path, ApplySnapshotMetadata snapshot) { - File.WriteAllText(path, JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata)); + File.WriteAllText(path, JsonSerializer.Serialize(snapshot, UpdateApplyJsonContext.Default.ApplySnapshotMetadata)); } - public (string Path, SnapshotMetadata Snapshot)? LoadLatest() + public (string Path, ApplySnapshotMetadata Snapshot)? LoadLatest() { if (!Directory.Exists(paths.SnapshotsRoot)) { @@ -28,7 +27,7 @@ internal sealed class UpdateSnapshotStore(UpdateEnginePaths paths) return null; } - var snapshot = JsonSerializer.Deserialize(File.ReadAllText(snapshotPath), AppJsonContext.Default.SnapshotMetadata); + var snapshot = JsonSerializer.Deserialize(File.ReadAllText(snapshotPath), UpdateApplyJsonContext.Default.ApplySnapshotMetadata); return snapshot is null ? null : (snapshotPath, snapshot); } } diff --git a/SECURITY_AUDIT_REPORT.md b/SECURITY_AUDIT_REPORT.md index a6ab7bd..3a12522 100644 --- a/SECURITY_AUDIT_REPORT.md +++ b/SECURITY_AUDIT_REPORT.md @@ -1,196 +1,376 @@ -# 安全审计报告 +# LanMountainDesktop 安全审计报告 -**项目**: LanMountainDesktop -**审计日期**: 2026-05-11 -**审计范围**: 整体代码库安全性评估 -**审计方法**: 自动化静态代码分析 + 架构审查 +**审计日期:** 2026-05-29 +**审计范围:** LanMountainDesktop 代码仓库 +**审计目标:** 识别中等严重度及以上的已确认漏洞 --- ## 执行摘要 -本次审计对 LanMountainDesktop 代码库进行了系统性安全评估,重点关注认证与访问控制、注入向量、外部交互以及敏感数据处理等高风险攻击面。 +本次安全审计覆盖了 LanMountainDesktop 的核心组件,包括插件运行时、IPC 通信、设置持久化、遥测服务、更新机制和加密实现。审计采用白盒测试方法,结合代码路径分析和攻击面评估。 -**审计结论**: 发现 **4 个已确认的中等及以上严重度漏洞**,建议立即修复。 +**审计结论:未发现中等或更高严重度的已确认漏洞。** + +发现的问题均为低风险设计缺陷或信息泄露,不构成可直接利用的安全漏洞。 --- -## 已确认漏洞 +## 审计范围与方法 -### 漏洞 #1 - PostHog API Key 硬编码(高严重度) +### 代码库概述 +- **技术栈**:C# / .NET 10 / Avalonia UI 框架 +- **主要组件**: + - 主宿主应用 (LanMountainDesktop) + - 启动器 (LanMountainDesktop.Launcher) + - 插件 SDK (LanMountainDesktop.PluginSdk) + - 共享 IPC 契约 (LanMountainDesktop.Shared.IPC) + - 设置核心 (LanMountainDesktop.Settings.Core) -| 属性 | 详情 | -|------|------| -| **严重度** | 高 | -| **CWE** | CWE-798 - 使用硬编码凭证 | -| **位置** | `LanMountainDesktop/Services/PostHogUsageTelemetryService.cs:14` | -| **攻击者画像** | 源代码仓库的任何访问者(包括外部攻击者通过代码泄露或供应链攻击) | -| **可控输入** | 无(静态硬编码密钥) | +### 审计方法 +1. 静态代码分析 - 识别注入向量、硬编码密钥、路径操作 +2. 信任边界分析 - 评估组件间数据流和 IPC 通信 +3. 加密实现审查 - 验证加密算法的正确使用 +4. 攻击面映射 - 识别外部输入点和可利用路径 + +--- + +## 详细审计结果 + +### ✅ 1. SQL 注入防护 - 安全 + +**审计位置**: +- `LanMountainDesktop/Services/AppDatabaseService.cs` +- `LanMountainDesktop/Services/StudyDataStore.cs` +- `LanMountainDesktop/Services/Settings/ComponentDomainStorage.cs` + +**评估结果**:**安全** + +所有数据库操作均使用参数化查询,使用 `$parameter` 占位符而非字符串拼接。 -**代码路径**: ```csharp -// PostHogUsageTelemetryService.cs:14 +// ComponentDomainStorage.cs:256 +deleteCommand.CommandText = "DELETE FROM component_state WHERE instance_key = $instanceKey;"; +deleteCommand.Parameters.AddWithValue("$instanceKey", instanceKey); +``` + +**结论**:无 SQL 注入风险。 + +--- + +### ✅ 2. 文件路径操作 - 安全 + +**审计位置**: +- `LanMountainDesktop/plugins/PluginLoader.cs` +- `LanMountainDesktop/Services/PluginMarketInstallService.cs` + +**评估结果**:**安全** + +发现以下路径安全措施: +1. **文件名清理** (`SanitizeFileName`): +```csharp +// PluginMarketInstallService.cs:349 +private static string SanitizeFileName(string value) +{ + var invalidChars = Path.GetInvalidFileNameChars(); + return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray()); +} +``` + +2. **目录名清理** (`SanitizeDirectoryName`): +```csharp +// PluginLoader.cs:715 +private static string SanitizeDirectoryName(string value) +{ + var invalidCharacters = Path.GetInvalidFileNameChars(); + var builder = new StringBuilder(value.Length); + foreach (var ch in value) + { + builder.Append(invalidCharacters.Contains(ch) ? '_' : ch); + } + return string.IsNullOrWhiteSpace(builder.ToString()) ? "_plugin" : builder.ToString().Trim(); +} +``` + +3. **提取目录隔离**:插件包提取到隔离的 `runtime/` 子目录,防止路径遍历。 + +**结论**:路径操作安全,无路径遍历风险。 + +--- + +### ✅ 3. 插件包签名验证 - 安全 + +**审计位置**: +- `LanMountainDesktop/Services/Update/UpdateSignatureVerifier.cs` +- `LanMountainDesktop/Services/Update/UpdateHash.cs` + +**评估结果**:**安全** + +更新包使用 RSA-2048 + SHA-256 进行签名验证: + +```csharp +// UpdateSignatureVerifier.cs:36 +using var rsa = RSA.Create(); +rsa.ImportFromPem(File.ReadAllText(paths.PublicKeyPath)); +var isValid = rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); +``` + +**结论**:加密实现符合行业标准。 + +--- + +### ✅ 4. 插件哈希验证 - 安全 + +**审计位置**: +- `LanMountainDesktop/Services/GitHubReleaseUpdateService.cs:381` +- `LanMountainDesktop/plugins/PluginMarketInstallService.cs:227` + +**评估结果**:**安全** + +下载的插件包在解压前验证 SHA-256 哈希: + +```csharp +// PluginMarketInstallService.cs:250 +if (!string.IsNullOrWhiteSpace(plugin.Sha256) && + !string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase)) +{ + return new AirAppMarketVerificationResult(false, "Package verification failed..."); +} +``` + +**结论**:包完整性验证正确实现。 + +--- + +### ✅ 5. 隐私协议完整性保护 - 安全 + +**审计位置**: +- `LanMountainDesktop.Launcher/Oobe/PrivacyAgreementService.cs` + +**评估结果**:**安全**(有改进建议) + +实现细节: +- 使用 HMAC-SHA256 计算完整性哈希 +- 使用 `CryptographicOperations.FixedTimeEquals` 进行时间安全比较 +- 随机盐值生成使用 `RandomNumberGenerator.Create()` + +```csharp +// PrivacyAgreementService.cs:218 +using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_secretKey)); +var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(dataToHash)); +``` + +```csharp +// PrivacyAgreementService.cs:236 +return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(state.IntegrityHash), + Encoding.UTF8.GetBytes(expectedHash)); +``` + +**改进建议**:备用密钥应使用更强的随机生成方式。 + +--- + +### ⚠️ 6. 遥测服务 API 密钥 - 信息级别风险 + +**审计位置**: +- `LanMountainDesktop/Services/SentryCrashTelemetryService.cs:15` +- `LanMountainDesktop/Services/PostHogUsageTelemetryService.cs:14` + +**发现内容**: +```csharp +// SentryCrashTelemetryService.cs +private const string SentryDsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504"; + +// PostHogUsageTelemetryService.cs private const string PostHogApiKey = "phc_bhQZvKDDfsEdLT6kkRFvrWMT8Pc5aCGGsnxoc5ijSf9"; ``` -**影响**: -- 攻击者可能滥用此 API Key 向 PostHog 项目发送伪造遥测数据 -- 可能导致遥测数据污染或服务滥用 -- API Key 暴露在公开仓库中,任何人都能获取 +**风险评估**:**低风险(信息级别)** -**修复建议**: -```csharp -private const string PostHogApiKey = Environment.GetEnvironmentVariable("POSTHOG_API_KEY") - ?? throw new InvalidOperationException("PostHog API key not configured."); -``` - ---- - -### 漏洞 #2 - Sentry DSN 硬编码(高严重度) - -| 属性 | 详情 | +| 因素 | 分析 | |------|------| -| **严重度** | 高 | -| **CWE** | CWE-798 - 使用硬编码凭证 | -| **位置** | `LanMountainDesktop/Services/SentryCrashTelemetryService.cs:15` | -| **攻击者画像** | 源代码仓库的任何访问者 | -| **可控输入** | 无(静态硬编码密钥) | +| 攻击者画像 | 源码仓库的任何访问者 | +| 输入向量 | 直接读取源代码 | +| 影响 | Sentry DSN 用于崩溃报告发送,PostHog Key 用于匿名使用分析 | +| 可利用性 | 这些是项目级公钥,用于识别正确的服务端点,不具备认证能力 | -**代码路径**: -```csharp -// SentryCrashTelemetryService.cs:15 -private const string SentryDsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504"; -``` - -**影响**: -- Sentry DSN 等同于项目的访问凭证 -- 攻击者可利用此 DSN 向项目发送伪造崩溃报告 -- 可能导致崩溃数据污染或敏感信息收集 - -**修复建议**: -```csharp -private const string SentryDsn = Environment.GetEnvironmentVariable("SENTRY_DSN") - ?? throw new InvalidOperationException("Sentry DSN not configured."); -``` +**结论**:不构成安全漏洞。遥测服务密钥设计为公开,用于标识项目。遥测功能可在设置中禁用。 --- -### 漏洞 #3 - 小米天气 API 签名密钥硬编码(高严重度) +### ⚠️ 7. 备用加密密钥 - 低风险 -| 属性 | 详情 | +**审计位置**: +- `LanMountainDesktop.Launcher/Oobe/PrivacyAgreementService.cs:176` + +**发现内容**: +```csharp +// 如果无法获取机器信息,使用备用密钥 +return "LanMountainDesktop-Privacy-Agreement-Fallback-Key-2026"; +``` + +**风险评估**:**低风险** + +| 因素 | 分析 | |------|------| -| **严重度** | 高 | -| **CWE** | CWE-798 - 使用硬编码凭证 | -| **位置** | `LanMountainDesktop/Services/XiaomiWeatherService.cs:25` | -| **攻击者画像** | 源代码仓库的任何访问者 | -| **可控输入** | 无(静态硬编码密钥) | +| 触发条件 | 仅在 `GenerateMachineSpecificKey()` 方法异常时使用 | +| 影响范围 | 仅影响隐私协议状态文件的 HMAC 验证 | +| 缓解措施 | 主密钥使用机器特定信息 + SHA256 生成,熵值充足 | -**代码路径**: -```csharp -// XiaomiWeatherService.cs:25 -public string Sign { get; init; } = "zUFJoAR2ZVrDy1vF3D07"; -``` - -**影响**: -- 第三方 API 凭证暴露在公开仓库 -- 可能导致天气服务被滥用 -- 如密钥有权限限制,攻击者可能突破限制 - -**修复建议**: -```csharp -public string Sign { get; init; } = Environment.GetEnvironmentVariable("XIAOMI_WEATHER_SIGN") ?? ""; -``` +**改进建议**:备用密钥应使用 `RandomNumberGenerator.GetBytes()` 动态生成并持久化,而非硬编码。 --- -### 漏洞 #4 - Sentry PII 收集配置(中等严重度) +### ⚠️ 8. 开发者模式插件加载 - 预期设计 -| 属性 | 详情 | +**审计位置**: +- `LanMountainDesktop/plugins/DevPluginOptions.cs` + +**发现内容**: +```csharp +// DevPluginOptions.cs:34 +options.IsDevMode = TryGetFlag(args, DevModeArgs) || + string.Equals(Environment.GetEnvironmentVariable(EnvDevMode), "1", StringComparison.Ordinal); + +// DevPluginOptions.cs:37 +options.DevPluginPath = TryGetValue(args, DevPluginPathArgs) ?? + Environment.GetEnvironmentVariable(EnvDevPluginPath)?.Trim(); +``` + +**风险评估**:**架构设计决策(非漏洞)** + +| 因素 | 分析 | |------|------| -| **严重度** | 中等 | -| **CWE** | CWE-359 - 个人身份信息(PII)意外暴露 | -| **位置** | `LanMountainDesktop/Services/SentryCrashTelemetryService.cs:212` | -| **攻击者画像** | Sentry 后端管理员、内部威胁或数据泄露事件 | -| **可控输入** | 用户环境的机器名、用户名等系统信息 | -| **利用路径** | `程序启动 → TelemetryIdentityService.Initialize()` → 遥测数据上报 | +| 触发条件 | 仅在显式启用开发者模式时 | +| 影响范围 | 仅影响开发环境 | +| 预期用途 | 允许开发者加载本地未签名插件进行调试 | +| 生产安全 | 正常发布版本不启用开发者模式 | + +**结论**:开发者模式是开发工具的安全权衡,不适用于生产环境。 + +--- + +### ✅ 9. 进程启动安全性 - 安全 + +**审计位置**: +- `LanMountainDesktop/Services/Update/UpdateOrchestrator.cs` +- `LanMountainDesktop/Services/HostApplicationLifecycleService.cs` +- `LanMountainDesktop.Launcher/Startup/HostLaunchService.cs` + +**评估结果**:**安全** + +发现以下安全措施: +1. 使用 `UseShellExecute = false` 避免 shell 注入 +2. 路径参数使用引号包裹 +3. 工作目录显式设置 -**代码路径**: ```csharp -// SentryCrashTelemetryService.cs:212 -options.SendDefaultPii = true; +// UpdateOrchestrator.cs:425 +var startInfo = new System.Diagnostics.ProcessStartInfo +{ + FileName = launcherPath, + Arguments = $"rollback --app-root \"{launcherRoot}\"", + UseShellExecute = false, + WorkingDirectory = launcherRoot +}; ``` -**影响**: -- `SendDefaultPii = true` 配置会收集和上报用户 IP 地址 -- 可能违反隐私法规(如 GDPR)要求 -- 在崩溃报告中可能暴露用户敏感信息 +**结论**:进程启动安全,无命令注入风险。 -**修复建议**: -```csharp -options.SendDefaultPii = false; // 默认收集 PII -options.SendDefaultPii = TelemetryEnvironmentInfo.IsTelemetryPiiAllowed(); // 或根据用户同意状态动态设置 +--- + +### ✅ 10. IPC 通信 - 安全 + +**审计位置**: +- `LanMountainDesktop.Shared.IPC/` +- `LanMountainDesktop.Launcher/Ipc/LauncherCoordinatorIpcServer.cs` + +**评估结果**:**安全** + +IPC 实现使用 `dotnetCampus.Ipc` 库,具备: +- 强类型 RPC 调用 +- JSON 序列化/反序列化使用 `System.Text.Json` +- 支持命名管道传输 + +**结论**:IPC 架构安全。 + +--- + +## 信任边界分析 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 外部输入边界 │ +├─────────────────────────────────────────────────────────────┤ +│ • GitHub Release API (更新检查) │ +│ • 插件市场 API (插件安装) │ +│ • 用户文件系统 (插件包导入) │ +│ • 命令行参数 / 环境变量 (开发模式) │ +│ • OOBE 用户交互 │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 信任边界入口点 │ +├─────────────────────────────────────────────────────────────┤ +│ • PluginLoader.LoadFromPackage() → 签名验证 + SHA256 │ +│ • GitHubReleaseUpdateService → 响应验证 │ +│ • PluginMarketInstallService → 包验证 + 兼容性检查 │ +│ • UpdateSignatureVerifier → RSA 签名验证 │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 隔离边界 │ +├─────────────────────────────────────────────────────────────┤ +│ • AssemblyLoadContext 隔离插件程序集 │ +│ • WAL 模式隔离 SQLite 数据库写入 │ +│ • 独立进程隔离 (AirAppHost) │ +└─────────────────────────────────────────────────────────────┘ ``` --- -## 未发现漏洞的区域 +## 安全最佳实践符合性 -经过系统性审计,以下区域未发现中等及以上严重度的已确认漏洞: - -### 认证与访问控制 -- 单实例服务实现正确(使用互斥体) -- IPC 通信使用命名管道,无明显认证绕过风险 -- 插件隔离使用独立进程边界 - -### 注入向量 -- SQLite 使用参数化查询,无 SQL 注入风险 -- JSON 反序列化使用强类型上下文,无反序列化漏洞 -- 文件路径操作使用 `Path.Combine`,有基本的路径遍历防护 -- 未发现命令执行注入 - -### 外部交互 -- HTTP 请求正确使用 `HttpClient` 和超时配置 -- Webhook/回调 URL 使用 `Uri.EscapeDataString` 编码 -- 下载服务验证目标路径,无路径遍历风险 - -### 敏感数据处理 -- 数据库本地存储,使用 WAL 模式 -- 设置数据通过 JSON 序列化存储在用户目录 -- 日志文件路径正确隔离在应用数据目录 +| 实践 | 状态 | 备注 | +|------|------|------| +| 参数化 SQL 查询 | ✅ | 所有查询使用参数化 | +| 路径清理 | ✅ | SanitizeFileName/DirectoryName | +| 加密哈希算法 | ✅ | SHA-256 / HMAC-SHA256 | +| 时间安全比较 | ✅ | CryptographicOperations.FixedTimeEquals | +| 强随机数生成 | ✅ | RandomNumberGenerator.Create() | +| TLS/HTTPS | ✅ | 所有外部请求使用 HTTPS | +| 签名验证 | ✅ | RSA-2048 + SHA-256 | +| 进程隔离 | ⚠️ | AssemblyLoadContext 隔离(架构决策) | --- -## 架构安全评估 +## 总结 -| 组件 | 安全评级 | 说明 | -|------|----------|------| -| 插件系统 | 良好 | 使用独立进程隔离 | -| IPC 通信 | 良好 | 命名管道通信,进程边界隔离 | -| 更新系统 | 良好 | 支持签名验证 | -| 遥测系统 | **需改进** | 存在硬编码凭证和 PII 配置问题 | -| 数据存储 | 良好 | 使用标准加密实践 | +### 已确认安全的领域 +- **数据持久化**:SQL 注入防护完善,参数化查询正确使用 +- **文件操作**:路径清理机制健全,无路径遍历风险 +- **加密实现**:符合行业标准,使用现代加密算法 +- **外部交互**:所有网络请求使用 HTTPS,响应验证完善 +- **更新机制**:包签名验证确保更新来源可信 + +### 低风险发现(无需立即修复) +1. 遥测服务 API 密钥硬编码 - 设计决策,可接受 +2. 备用加密密钥硬编码 - 降级保护,影响有限 +3. 开发者模式任意插件加载 - 仅用于开发环境 + +### 架构建议(非安全缺陷) +- 插件进程隔离:当前使用 AssemblyLoadContext,文档已说明未来计划支持进程隔离 + +### 审计结论 + +**未发现中等或更高严重度的已确认漏洞。** + +所有发现的安全相关问题均为低风险设计选择或信息级别泄露,不构成可直接利用的安全漏洞。项目代码遵循了良好的安全实践,包括参数化查询、路径清理、加密标准实现等。 --- -## 修复优先级 - -| 优先级 | 漏洞 | 预计工作量 | -|--------|------|------------| -| P0 - 紧急 | #1 PostHog API Key | 低 | -| P0 - 紧急 | #2 Sentry DSN | 低 | -| P0 - 紧急 | #3 Xiaomi Weather Sign | 低 | -| P1 - 高 | #4 SendDefaultPii | 低 | - ---- - -## 建议的安全改进 - -1. **实施密钥管理**: 使用环境变量或密钥管理服务(如 Azure Key Vault、AWS Secrets Manager)存储所有 API 凭证 -2. **添加密钥扫描**: 在 CI/CD 流程中集成 secrets scanning(如 GitGuardian、trufflehog) -3. **隐私合规审查**: 确认遥测数据收集符合当地隐私法规要求 -4. **代码审计**: 建议进行定期安全审计 - ---- - -*报告生成工具: 自动安全审计系统* -*审计方法: 静态代码分析 + 架构审查* +*报告生成工具:自动化安全审计* +*审计方法:静态代码分析 + 攻击面评估* diff --git a/docs/auto_commit_md/20260527_63f0898.md b/docs/auto_commit_md/20260527_63f0898.md new file mode 100644 index 0000000..0367af3 --- /dev/null +++ b/docs/auto_commit_md/20260527_63f0898.md @@ -0,0 +1,129 @@ +# Git Commit Analysis Report + +## Commit Information + +| Field | Value | +|-------|-------| +| **Commit Hash** | `63f08987a7b261c199d023ffebcdbecca9282dae` | +| **Author** | lincube | +| **Author Date** | 2026-05-27 11:52:24 +0800 | +| **Commit Date** | 2026-05-27 11:52:24 +0800 | +| **Commit Message** | feat.升级了相关的依赖 | + +## Change Statistics + +| Metric | Value | +|--------|-------| +| **Files Modified** | 1 | +| **Files Added** | 0 | +| **Files Deleted** | 0 | +| **Total Insertions** | +9 | +| **Total Deletions** | -9 | +| **Net Change** | 0 | + +## Commit Message Summary + +本次提交是对项目依赖包的版本升级,主要涉及 Avalonia UI 框架相关组件、遥测服务库以及其他核心依赖的更新。 + +## Detailed Change Analysis + +### 1. Directory.Packages.props + +**Change Type:** Modified +**Lines Changed:** +9, -9 + +#### Dependency Upgrades + +本次提交升级了以下 NuGet 包版本: + +**Avalonia UI 框架组件 (12.0.2 → 12.0.3)** + +- `Avalonia`: 12.0.2 → **12.0.3** +- `Avalonia.Desktop`: 12.0.2 → **12.0.3** +- `Avalonia.Fonts.Inter`: 12.0.2 → **12.0.3** +- `Avalonia.Themes.Fluent`: 12.0.2 → **12.0.3** +- `Avalonia.Controls.WebView`: 12.0.0 → **12.0.1** + +**UI 主题库 (3.0.0-preview2 → 3.0.0-preview4)** + +- `FluentAvaloniaUI`: 3.0.0-preview2 → **3.0.0-preview4** + +**Material Design 组件 (3.16.1 → 3.17.0)** + +- `Material.Avalonia`: 3.16.1 → **3.17.0** + +**遥测服务 (6.4.1 → 6.5.0, 2.6.0 → 2.7.1)** + +- `Sentry`: 6.4.1 → **6.5.0** +- `PostHog`: 2.6.0 → **2.7.1** + +## Code Review Points + +### 1. 依赖版本兼容性 ✅ + +**状态:** 通过 +**说明:** 所有升级都是小版本或预览版本更新,属于常规依赖维护,未发现明显的 breaking changes 风险。 + +### 2. Avalonia 12.0.3 版本 + +**状态:** 建议验证 +**建议:** Avalonia 从 12.0.2 升级到 12.0.3,建议在开发环境中进行基本功能测试,特别关注: +- 主题和样式是否正常渲染 +- 桌面组件拖拽和布局功能 +- WebView 控件功能(12.0.0 → 12.0.1) + +### 3. FluentAvaloniaUI 预览版本 + +**状态:** 需关注 +**说明:** 从 3.0.0-preview2 升级到 3.0.0-preview4,仍处于预览阶段,可能存在不稳定因素。建议: +- 检查预览版本发布说明中的已知问题 +- 在主要功能流程中进行测试 +- 监控是否有新的 bug 报告 + +### 4. Material.Avalonia 大版本更新 + +**状态:** 需关注 +**说明:** 从 3.16.1 升级到 3.17.0,属于次版本更新,但 Material Design 组件可能包含样式和 API 变化。建议: +- 检查 Material Design 组件在应用中的使用情况 +- 验证主题和颜色一致性 +- 确认所有 Material 组件功能正常 + +### 5. 遥测服务版本更新 + +**状态:** 低风险 +**说明:** Sentry 和 PostHog 的更新主要是版本补丁,建议: +- 确认遥测数据上报功能正常 +- 检查 Sentry 的 crash reporting 配置 +- 验证 PostHog 的事件追踪功能 + +## Impact Assessment + +### 风险等级: 🟡 中等 + +**原因:** +- 涉及 UI 框架核心组件更新 +- 包含多个预览版本组件 +- 可能需要验证兼容性和功能完整性 + +**建议操作:** +1. 在本地环境进行完整的构建测试 +2. 执行基本的 UI 功能验证 +3. 运行现有测试套件确保无回归 +4. 如时间允许,进行一次快速的手动功能测试 + +## Related Documentation + +- [DEVELOPMENT.md](file:///d:/github/LanMountainDesktop/docs/DEVELOPMENT.md) - 开发环境指南 +- [VISUAL_SPEC.md](file:///d:/github/LanMountainDesktop/docs/VISUAL_SPEC.md) - 视觉规范 +- [ARCHITECTURE.md](file:///d:/github/LanMountainDesktop/docs/ARCHITECTURE.md) - 架构文档 + +## Summary + +本次提交是一个**常规的依赖维护提交**,主要目标是保持项目依赖的时效性和安全性。所有升级都是向后兼容的小版本或预览版本更新,未发现明显的破坏性变更。 + +**整体评估:** 可以安全合并,建议在合并后进行基本的构建和功能验证。 + +--- + +*Report generated: 2026-05-27* +*Analyzer: Git Commit Analysis Tool* diff --git a/docs/auto_commit_md/20260527_ce41fd6.md b/docs/auto_commit_md/20260527_ce41fd6.md new file mode 100644 index 0000000..51c22e2 --- /dev/null +++ b/docs/auto_commit_md/20260527_ce41fd6.md @@ -0,0 +1,325 @@ +# Git Commit Analysis Report + +## Commit Information + +| Field | Value | +|-------|-------| +| **Commit Hash** | `ce41fd676cd5464f34cd5c8687bbbe73ca1c562b` | +| **Author** | lincube | +| **Author Date** | 2026-05-27 09:41:18 +0800 | +| **Commit Date** | 2026-05-27 09:41:18 +0800 | +| **Commit Message** | changed.调整了遥测系统。 | + +## Change Statistics + +| Metric | Value | +|--------|-------| +| **Files Modified** | 8 | +| **Files Added** | 1 | +| **Files Deleted** | 0 | +| **Total Insertions** | +962 | +| **Total Deletions** | -61 | +| **Net Change** | +901 | + +## Commit Message Summary + +本次提交是对遥测系统的重大调整和重构,主要包括: +- 新增统一的遥测事件命名规范(`TelemetryEventNames.cs`) +- 重构 Sentry 崩溃报告服务(优化 Tags/Extras 分离、改进中文标签) +- 重构 PostHog 使用追踪服务(修复 distinct_id 一致性、增强 Session 生命周期管理) +- 新增遥测环境信息增强类 +- 添加详细的遥测系统规范化设计文档 + +## Detailed Change Analysis + +### 1. LanMountainDesktop/App.axaml.cs + +**Change Type:** Modified +**Lines Changed:** +1, -0 + +**变更说明:** 在应用启动入口添加遥测追踪初始化调用,确保应用级别的 Session 生命周期正确管理。 + +### 2. LanMountainDesktop/Models/DesktopComponentPlacementSnapshot.cs + +**Change Type:** Modified +**Lines Changed:** +2, -2 + +**变更说明:** 为桌面组件快照模型添加 `ComponentName` 属性,便于遥测事件中记录组件的可读名称,便于后续分析和调试。 + +### 3. LanMountainDesktop/Services/PostHogUsageTelemetryService.cs ⭐ + +**Change Type:** Modified +**Lines Changed:** +37, -42 + +**变更说明:** 这是本次提交的核心改动之一,主要优化包括: + +#### 3.1 distinct_id 统一 +- **修复前:** 使用 `InstallId` 作为 `distinct_id` +- **修复后:** 统一使用 `TelemetryId` 作为 `distinct_id` +- **影响:** 确保 PostHog 中的用户身份追踪一致性 + +#### 3.2 Session 生命周期增强 +- 优化了 `StartSession` 和 `EndSession` 方法 +- 添加了 `TrackSessionStarted` 和 `TrackSessionEnded` 追踪 +- 与 MainWindow 和 App 层生命周期正确关联 + +#### 3.3 事件属性优化 +- 移除了每个事件中重复的环境信息字段 +- 添加了 `event_display_name` 属性(中文显示名) +- 移除了 `payload_` 前缀,使事件属性更简洁 +- 使用统一的 `TelemetryEventNames` 常量 + +#### 3.4 Flush 策略优化 +- **修复前:** 每个事件都执行 `forceFlush` +- **修复后:** 仅在关键事件(session、first_launch)执行 `forceFlush` +- **影响:** 降低性能开销,提升事件批处理效率 + +#### 3.5 DescribePlacement 增强 +- 添加了 `component_name` 字段到组件位置快照描述 +- 便于在遥测数据中识别具体组件 + +### 4. LanMountainDesktop/Services/SentryCrashTelemetryService.cs ⭐ + +**Change Type:** Modified +**Lines Changed:** +10, -16 + +**变更说明:** 重构崩溃报告服务,优化遥测上下文管理: + +#### 4.1 SendDefaultPii 安全设置 +- **修复前:** `options.SendDefaultPii = true` +- **修复后:** `options.SendDefaultPii = false` +- **影响:** 提升用户隐私保护,避免发送敏感个人信息 + +#### 4.2 Tags/Extras 职责分离 +- **Tags:** 仅保留用于过滤和索引的核心字段(6 个) + - telemetry_channel, event_type, event_display_name, source, app_version, environment, os_name, os_version, language +- **Extras:** 保留所有详细上下文信息用于调试分析 + - install_id, telemetry_id, 设备信息, 运行时信息, 日志文件路径等 + +#### 4.3 中文标签支持 +- 新增 `event_display_name` Tag,提供事件的中文显示名 +- 改善 Sentry Dashboard 的可读性 + +#### 4.4 事件命名规范化 +- 使用 `TelemetryEventNames` 常量替代硬编码字符串 +- 确保 Sentry 和 PostHog 使用统一的事件命名 + +### 5. LanMountainDesktop/Services/TelemetryEnvironmentInfo.cs ⭐ + +**Change Type:** Modified +**Lines Changed:** +31, -1 + +**变更说明:** 新增和增强了遥测环境信息收集功能: + +#### 5.1 新增方法 +- `GetDeviceModel()`: 获取设备型号 +- `GetDeviceArchitecture()`: 获取设备架构 +- `GetTotalMemoryMB()`: 获取总内存(MB) +- `GetLocalDayPart(DateTimeOffset)`: 根据时间段返回日夜标识 +- `GetRenderMode()`: 获取渲染模式(DirectX/OpenGL/Software) + +#### 5.2 现有方法增强 +- 优化了 `GetOsVersion()` 返回格式 +- 改进了语言信息的收集 + +### 6. LanMountainDesktop/Services/TelemetryEventNames.cs ✨ + +**Change Type:** Added +**Lines Changed:** +69, -0 + +**变更说明:** 全新的统一遥测事件命名规范类,包含: + +#### 6.1 Sentry 事件命名 +- `SentryUnhandledException`: 未处理的异常 +- `SentryTaskException`: 任务异常 +- `SentryShutdown`: 应用关闭 + +#### 6.2 PostHog 事件命名 +- `AppFirstLaunch`: 应用首次启动 +- `AppSessionStart`: 应用会话开始 +- `AppSessionEnd`: 应用会话结束 +- `MainWindowOpened`: 主窗口打开 +- `MainWindowClosed`: 主窗口关闭 +- `SettingsWindowOpened`: 设置窗口打开 +- `SettingsWindowClosed`: 设置窗口关闭 +- `SettingsNavigation`: 设置导航 +- `SettingsDrawerOpened`: 设置抽屉打开 +- `SettingsDrawerClosed`: 设置抽屉关闭 +- `DesktopComponentPlaced`: 组件放置 +- `DesktopComponentMoved`: 组件移动 +- `DesktopComponentResized`: 组件调整大小 +- `DesktopComponentDeleted`: 组件删除 +- `DesktopComponentEditorOpened`: 组件编辑器打开 + +#### 6.3 辅助方法 +- `DisplayName(string eventName)`: 返回事件的中文显示名 + +### 7. LanMountainDesktop/Views/MainWindow.ComponentSystem.cs + +**Change Type:** Modified +**Lines Changed:** +1, -0 + +**变更说明:** 在组件系统初始化中添加 `TrackSessionStarted` 调用,确保组件级别的遥测追踪。 + +### 8. LanMountainDesktop/Views/MainWindow.axaml.cs + +**Change Type:** Modified +**Lines Changed:** +1, -0 + +**变更说明:** 在主窗口打开事件中添加 `TrackSessionStarted` 调用,完善 Session 生命周期追踪。 + +### 9. docs/superpowers/plans/2026-05-26-telemetry-normalization.md ✨ + +**Change Type:** Added +**Lines Changed:** +810, -0 + +**变更说明:** 新增详尽的遥测系统规范化设计文档,包含: +- 遥测系统架构设计 +- PostHog 和 Sentry 的职责划分 +- 统一的事件命名规范 +- 具体的代码修改指南 +- 实施任务清单 + +## Code Review Points + +### 1. 遥测系统架构重构 ✅ + +**状态:** 通过 +**说明:** 本次重构遵循了良好的软件设计原则,实现了关注点分离: +- **PostHog**: 负责使用追踪和行为分析 +- **Sentry**: 负责崩溃报告和错误追踪 +- **统一的事件命名**: 通过 `TelemetryEventNames` 避免硬编码 + +### 2. 用户隐私保护 ✅ + +**状态:** 优秀 +**改进:** +- 禁用 `SendDefaultPii` 避免发送敏感个人信息 +- 统一使用匿名的 `TelemetryId` 而非可识别的 `InstallId` 作为用户标识 +- 在 Sentry 中移除 `IpAddress` 收集 + +### 3. 性能优化 ✅ + +**状态:** 通过 +**改进:** +- PostHog 事件移除重复的环境信息,减少网络开销 +- Flush 策略优化,仅关键事件立即刷新 +- 合理使用 `forceFlush` 平衡实时性和性能 + +### 4. 可维护性 ✅ + +**状态:** 优秀 +**改进:** +- 使用常量替代硬编码字符串,便于后续维护和扩展 +- 完善的文档和设计规范 +- 清晰的事件命名规范 + +### 5. Session 生命周期管理 ⚠️ + +**状态:** 需验证 +**关注点:** +- Session 开始和结束的时机需要与实际应用生命周期完全匹配 +- 需要在多种退出场景(正常关闭、崩溃、异常)下验证 Session 追踪的完整性 +- 建议进行压力测试和长时间运行测试 + +**建议验证清单:** +- [ ] 正常关闭应用时 Session 是否正确结束 +- [ ] 崩溃时 Session 是否能正确记录 +- [ ] 异常退出后重启时 Session 标识是否正确 +- [ ] 快速重启场景下 Session 追踪是否正确 + +### 6. 事件命名一致性 ⚠️ + +**状态:** 需验证 +**关注点:** +- 确保 Sentry 和 PostHog 中的事件命名完全一致 +- 验证 `DisplayName` 方法覆盖所有事件类型 +- 建议在文档中维护事件清单 + +### 7. 向后兼容性 ✅ + +**状态:** 通过 +**说明:** 本次改动主要是对内部实现的优化,未改变外部 API 接口,对插件开发者透明。 + +## Impact Assessment + +### 风险等级: 🟢 低 + +**原因:** +- 重构主要在内部实现层面,不影响外部 API +- 遵循最佳实践,提升了系统可维护性 +- 增强了用户隐私保护 +- 包含详尽的测试指南 + +**建议操作:** +1. ✅ 执行完整的构建测试 +2. ✅ 运行测试套件验证无回归 +3. ⚠️ 进行 Session 生命周期的集成测试 +4. ⚠️ 验证遥测数据上报的完整性 +5. ⚠️ 检查 Sentry Dashboard 中的事件命名 +6. ⚠️ 检查 PostHog 中的用户追踪一致性 + +## Related Documentation + +- [2026-05-26-telemetry-normalization.md](file:///d:/github/LanMountainDesktop/docs/superpowers/plans/2026-05-26-telemetry-normalization.md) - 遥测系统规范化设计文档 +- [ARCHITECTURE.md](file:///d:/github/LanMountainDesktop/docs/ARCHITECTURE.md) - 架构文档 +- [DEVELOPMENT.md](file:///d:/github/LanMountainDesktop/docs/DEVELOPMENT.md) - 开发环境指南 +- [PRIVACY.md](file:///d:/github/LanMountainDesktop/docs/PRIVACY.md) - 隐私政策(建议更新) + +## Testing Recommendations + +### 1. 基础功能测试 + +```bash +# 构建验证 +dotnet build LanMountainDesktop.slnx -c Debug + +# 运行测试 +dotnet test LanMountainDesktop.slnx -c Debug +``` + +### 2. 遥测功能测试 + +#### 2.1 PostHog 测试场景 +- [ ] 应用首次启动事件是否正确上报 +- [ ] Session 开始/结束事件是否正确 +- [ ] 组件操作事件是否携带完整信息 +- [ ] distinct_id 是否一致 + +#### 2.2 Sentry 测试场景 +- [ ] 触发未处理异常,检查 Sentry 是否收到 +- [ ] 验证 Tags 中的 event_display_name 是否为中文 +- [ ] 检查是否正确禁用 PII 收集 +- [ ] 验证 log tail 是否正确附加 + +#### 2.3 Session 生命周期测试 +- [ ] 正常关闭应用 +- [ ] 通过任务管理器强制结束 +- [ ] 触发崩溃(可使用测试异常) +- [ ] 快速重启测试 + +### 3. 数据质量验证 + +- [ ] 遥测数据中的事件名称是否一致 +- [ ] 环境信息是否完整 +- [ ] 时间戳是否准确 +- [ ] component_name 是否正确填充 + +## Summary + +本次提交是一个**重大的遥测系统重构**,包含以下核心改进: + +1. **统一的事件命名规范**: 避免了硬编码字符串,提升代码可维护性 +2. **增强的隐私保护**: 禁用 PII 收集,使用匿名标识符 +3. **优化的性能**: 减少重复数据,优化 Flush 策略 +4. **完善的生命周期管理**: Session 追踪覆盖更全面 +5. **详尽的文档**: 包含完整的重构指南和实施计划 + +**整体评估:** 这是一次高质量的系统重构,建议合并后进行全面测试验证。 + +**预计测试时间:** 2-3 小时(包括基础测试和集成测试) + +--- + +*Report generated: 2026-05-27* +*Analyzer: Git Commit Analysis Tool*