mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
chabged.进一步清理启动器内的更新逻辑
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -35,15 +35,14 @@ internal static class Commands
|
||||
public static async Task<int> 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<LauncherResult> 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<LauncherResult> 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<LauncherResult> 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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string>(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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<DeploymentLocator>()));
|
||||
services.AddSingleton<HostLaunchService>();
|
||||
services.AddSingleton<StartupAttemptRegistry>();
|
||||
services.AddSingleton<ILaunchPhase, CleanupDeploymentsPhase>();
|
||||
services.AddSingleton<ILaunchPhase, ExistingHostProbePhase>();
|
||||
services.AddSingleton<ILaunchPhase, ApplyPendingUpdatePhase>();
|
||||
services.AddSingleton<ILaunchPhase, OobeGatePhase>();
|
||||
services.AddSingleton<ILaunchPhase, LaunchHostPhase>();
|
||||
services.AddSingleton<ILaunchPhase, MonitorStartupPhase>();
|
||||
@@ -47,7 +45,6 @@ internal static class LauncherServiceRegistration
|
||||
context,
|
||||
services.GetRequiredService<DeploymentLocator>(),
|
||||
services.GetRequiredService<OobeStateService>(),
|
||||
services.GetRequiredService<IUpdateEngine>(),
|
||||
startupAttemptRegistry,
|
||||
coordinatorServer,
|
||||
services.GetRequiredService<LaunchPipeline>());
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
namespace LanMountainDesktop.Launcher.Startup;
|
||||
|
||||
internal sealed class ApplyPendingUpdatePhase : ILaunchPhase
|
||||
{
|
||||
public string Name => nameof(ApplyPendingUpdatePhase);
|
||||
|
||||
public async Task<LaunchPhaseResult> 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);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Update;
|
||||
|
||||
internal sealed class DeploymentActivator(DeploymentLocator deploymentLocator)
|
||||
{
|
||||
public void Activate(string fromDeployment, string toDeployment)
|
||||
{
|
||||
var toCurrent = Path.Combine(toDeployment, ".current");
|
||||
var fromCurrent = Path.Combine(fromDeployment, ".current");
|
||||
var fromDestroy = Path.Combine(fromDeployment, ".destroy");
|
||||
var toDestroy = Path.Combine(toDeployment, ".destroy");
|
||||
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);
|
||||
}
|
||||
|
||||
File.WriteAllText(fromDestroy, string.Empty);
|
||||
if (File.Exists(toPartial))
|
||||
{
|
||||
File.Delete(toPartial);
|
||||
}
|
||||
}
|
||||
|
||||
public RollbackAttemptResult TryRollbackOnFailure(SnapshotMetadata snapshot)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(snapshot.TargetDirectory) && Directory.Exists(snapshot.TargetDirectory))
|
||||
{
|
||||
Directory.Delete(snapshot.TargetDirectory, true);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(snapshot.SourceDirectory) || !Directory.Exists(snapshot.SourceDirectory))
|
||||
{
|
||||
return new RollbackAttemptResult(false, "Source deployment is missing.");
|
||||
}
|
||||
|
||||
var destroyMarker = Path.Combine(snapshot.SourceDirectory, ".destroy");
|
||||
if (File.Exists(destroyMarker))
|
||||
{
|
||||
File.Delete(destroyMarker);
|
||||
}
|
||||
|
||||
var currentMarker = Path.Combine(snapshot.SourceDirectory, ".current");
|
||||
if (!File.Exists(currentMarker))
|
||||
{
|
||||
File.WriteAllText(currentMarker, string.Empty);
|
||||
}
|
||||
|
||||
return new RollbackAttemptResult(true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new RollbackAttemptResult(false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public void RetainDeploymentsForRollback()
|
||||
{
|
||||
deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record RollbackAttemptResult(bool Success, string? ErrorMessage);
|
||||
@@ -1,18 +0,0 @@
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Update;
|
||||
|
||||
internal interface IUpdateEngine
|
||||
{
|
||||
LauncherResult CheckPendingUpdate();
|
||||
|
||||
Task<LauncherResult> DownloadAsync(string manifestUrl, string signatureUrl, string archiveUrl, CancellationToken cancellationToken);
|
||||
|
||||
Task<LauncherResult> ApplyPendingUpdateAsync();
|
||||
|
||||
LauncherResult RollbackLatest();
|
||||
|
||||
void CleanupDestroyedDeployments();
|
||||
|
||||
void CleanupIncomingArtifacts();
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Update;
|
||||
|
||||
public interface IUpdateProgressReporter
|
||||
{
|
||||
void ReportProgress(InstallProgressReport report);
|
||||
void ReportComplete(InstallCompleteReport report);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
namespace LanMountainDesktop.Launcher.Update;
|
||||
|
||||
internal sealed class IncomingArtifactsCleaner(UpdateEnginePaths paths)
|
||||
{
|
||||
public void Cleanup()
|
||||
{
|
||||
foreach (var path in new[]
|
||||
{
|
||||
paths.FileMapPath,
|
||||
paths.SignaturePath,
|
||||
paths.ArchivePath,
|
||||
paths.PlondsFileMapPath,
|
||||
paths.PlondsSignaturePath,
|
||||
paths.PlondsUpdateMetadataPath,
|
||||
paths.InstallCheckpointPath
|
||||
})
|
||||
{
|
||||
TryDeleteFile(path);
|
||||
}
|
||||
|
||||
TryDeleteDirectory(paths.PlondsObjectsRoot);
|
||||
}
|
||||
|
||||
private static void TryDeleteFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
Directory.Delete(path, true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Update;
|
||||
|
||||
internal sealed class InstallCheckpointStore(UpdateEnginePaths paths)
|
||||
{
|
||||
public InstallCheckpoint? Load()
|
||||
{
|
||||
if (!File.Exists(paths.InstallCheckpointPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var text = File.ReadAllText(paths.InstallCheckpointPath);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize(text, AppJsonContext.Default.InstallCheckpoint);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(InstallCheckpoint checkpoint)
|
||||
{
|
||||
File.WriteAllText(paths.InstallCheckpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint));
|
||||
}
|
||||
|
||||
public void Delete()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(paths.InstallCheckpointPath))
|
||||
{
|
||||
File.Delete(paths.InstallCheckpointPath);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<LauncherResult> 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);
|
||||
}
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
@@ -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."
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,416 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Update;
|
||||
|
||||
internal static class PlondsManifestParser
|
||||
{
|
||||
public static List<PlondsFileEntry> CollectFileEntries(PlondsFileMap fileMap)
|
||||
{
|
||||
var files = new List<PlondsFileEntry>();
|
||||
if (fileMap.Files is { Count: > 0 })
|
||||
{
|
||||
files.AddRange(fileMap.Files);
|
||||
}
|
||||
|
||||
if (fileMap.Components is null)
|
||||
{
|
||||
return files;
|
||||
}
|
||||
|
||||
foreach (var component in fileMap.Components)
|
||||
{
|
||||
if (component.Files is { Count: > 0 })
|
||||
{
|
||||
files.AddRange(component.Files);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
public static void PopulateFromRawJson(string fileMapJson, PlondsFileMap fileMap, ICollection<PlondsFileEntry> files)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileMapJson))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(fileMapJson);
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
fileMap.FromVersion ??= ReadStringIgnoreCase(root, "fromversion");
|
||||
fileMap.ToVersion ??= ReadStringIgnoreCase(root, "toversion");
|
||||
fileMap.Version ??= ReadStringIgnoreCase(root, "version");
|
||||
fileMap.Platform ??= ReadStringIgnoreCase(root, "platform");
|
||||
fileMap.Arch ??= ReadStringIgnoreCase(root, "arch");
|
||||
fileMap.DistributionId ??= ReadStringIgnoreCase(root, "distributionid");
|
||||
PopulateMetadata(root, fileMap.Metadata);
|
||||
|
||||
if (TryGetPropertyIgnoreCase(root, "files", out var rootFilesNode))
|
||||
{
|
||||
ParseFilesNode(rootFilesNode, null, files);
|
||||
}
|
||||
|
||||
if (TryGetPropertyIgnoreCase(root, "components", out var componentsNode))
|
||||
{
|
||||
ParseComponentsNode(componentsNode, files);
|
||||
}
|
||||
}
|
||||
|
||||
public static PlondsUpdateMetadata? LoadMetadata(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var text = File.ReadAllText(path);
|
||||
return string.IsNullOrWhiteSpace(text)
|
||||
? null
|
||||
: JsonSerializer.Deserialize(text, AppJsonContext.Default.PlondsUpdateMetadata);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static string? ResolveSourceVersion(PlondsFileMap fileMap, PlondsUpdateMetadata? metadata)
|
||||
{
|
||||
return FirstNonEmpty(
|
||||
metadata?.FromVersion,
|
||||
fileMap.FromVersion,
|
||||
TryGetMetadataValue(fileMap.Metadata, "fromVersion"),
|
||||
TryGetMetadataValue(fileMap.Metadata, "sourceVersion"));
|
||||
}
|
||||
|
||||
public static string? ResolveTargetVersion(PlondsFileMap fileMap, PlondsUpdateMetadata? metadata)
|
||||
{
|
||||
return FirstNonEmpty(
|
||||
metadata?.ToVersion,
|
||||
fileMap.ToVersion,
|
||||
fileMap.Version,
|
||||
TryGetMetadataValue(fileMap.Metadata, "toVersion"),
|
||||
TryGetMetadataValue(fileMap.Metadata, "targetVersion"));
|
||||
}
|
||||
|
||||
public static bool TryGetExpectedSha512(PlondsFileEntry file, out byte[] expected)
|
||||
{
|
||||
expected = [];
|
||||
if (file.Sha512Bytes is { Length: > 0 })
|
||||
{
|
||||
expected = file.Sha512Bytes;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (file.Hash is not null)
|
||||
{
|
||||
if (file.Hash.Bytes is { Length: > 0 })
|
||||
{
|
||||
expected = file.Hash.Bytes;
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((string.IsNullOrWhiteSpace(file.Hash.Algorithm) ||
|
||||
file.Hash.Algorithm.Contains("sha512", StringComparison.OrdinalIgnoreCase)) &&
|
||||
UpdateHash.TryParseHashBytes(file.Hash.Value, out expected))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (UpdateHash.TryParseHashBytes(file.Sha512, out expected))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return UpdateHash.TryParseHashBytes(file.Sha512Base64, out expected);
|
||||
}
|
||||
|
||||
public static bool TryGetExpectedObjectSha512(PlondsFileEntry file, out byte[] expected)
|
||||
{
|
||||
expected = [];
|
||||
if (file.Hash is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.Hash.Bytes is { Length: > 0 })
|
||||
{
|
||||
expected = file.Hash.Bytes;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(file.Hash.Algorithm) &&
|
||||
!file.Hash.Algorithm.Contains("sha512", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return UpdateHash.TryParseHashBytes(file.Hash.Value, out expected);
|
||||
}
|
||||
|
||||
private static void ParseComponentsNode(JsonElement componentsNode, ICollection<PlondsFileEntry> files)
|
||||
{
|
||||
if (componentsNode.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var component in componentsNode.EnumerateObject())
|
||||
{
|
||||
if (component.Value.ValueKind == JsonValueKind.Object &&
|
||||
TryGetPropertyIgnoreCase(component.Value, "files", out var componentFilesNode))
|
||||
{
|
||||
ParseFilesNode(componentFilesNode, component.Name, files);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (componentsNode.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var component in componentsNode.EnumerateArray())
|
||||
{
|
||||
if (component.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var componentName = ReadStringIgnoreCase(component, "name");
|
||||
if (TryGetPropertyIgnoreCase(component, "files", out var componentFilesNode))
|
||||
{
|
||||
ParseFilesNode(componentFilesNode, componentName, files);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ParseFilesNode(JsonElement filesNode, string? componentName, ICollection<PlondsFileEntry> files)
|
||||
{
|
||||
if (filesNode.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var fileEntry in filesNode.EnumerateObject())
|
||||
{
|
||||
if (fileEntry.Value.ValueKind == JsonValueKind.Object &&
|
||||
TryCreateFileEntry(fileEntry.Name, componentName, fileEntry.Value, out var parsed))
|
||||
{
|
||||
files.Add(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (filesNode.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var fileEntry in filesNode.EnumerateArray())
|
||||
{
|
||||
if (fileEntry.ValueKind == JsonValueKind.Object &&
|
||||
TryCreateFileEntry(ReadStringIgnoreCase(fileEntry, "path"), componentName, fileEntry, out var parsed))
|
||||
{
|
||||
files.Add(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryCreateFileEntry(string? fallbackPath, string? componentName, JsonElement node, out PlondsFileEntry entry)
|
||||
{
|
||||
entry = new PlondsFileEntry();
|
||||
var path = ReadStringIgnoreCase(node, "path");
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
path = fallbackPath;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var archiveSha512 = ReadByteArrayIgnoreCase(node, "archivesha512");
|
||||
var archiveSha512Text = ReadStringIgnoreCase(node, "archivesha512");
|
||||
entry = new PlondsFileEntry
|
||||
{
|
||||
Path = path,
|
||||
Action = FirstNonEmpty(ReadStringIgnoreCase(node, "action"), "replace"),
|
||||
Url = ReadStringIgnoreCase(node, "archivedownloadurl") ?? ReadStringIgnoreCase(node, "downloadurl") ?? ReadStringIgnoreCase(node, "url"),
|
||||
ObjectUrl = ReadStringIgnoreCase(node, "objecturl"),
|
||||
ObjectPath = ReadStringIgnoreCase(node, "objectpath") ?? ReadStringIgnoreCase(node, "archivepath"),
|
||||
ObjectKey = ReadStringIgnoreCase(node, "objectkey"),
|
||||
ArchivePath = ReadStringIgnoreCase(node, "archivepath"),
|
||||
Sha256 = ReadStringIgnoreCase(node, "sha256") ?? ReadStringIgnoreCase(node, "filesha256"),
|
||||
Sha512 = ReadStringIgnoreCase(node, "filesha512") ?? ReadStringIgnoreCase(node, "sha512"),
|
||||
Sha512Bytes = ReadByteArrayIgnoreCase(node, "filesha512") ?? ReadByteArrayIgnoreCase(node, "sha512"),
|
||||
Metadata = BuildMetadata(node, componentName)
|
||||
};
|
||||
|
||||
if (archiveSha512 is { Length: > 0 } || !string.IsNullOrWhiteSpace(archiveSha512Text))
|
||||
{
|
||||
entry.Hash = new PlondsHashDescriptor
|
||||
{
|
||||
Algorithm = "sha512",
|
||||
Bytes = archiveSha512,
|
||||
Value = archiveSha512Text ?? (archiveSha512 is { Length: > 0 }
|
||||
? Convert.ToHexString(archiveSha512).ToLowerInvariant()
|
||||
: null)
|
||||
};
|
||||
}
|
||||
else if (TryGetPropertyIgnoreCase(node, "hash", out var hashNode) && hashNode.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
entry.Hash = new PlondsHashDescriptor
|
||||
{
|
||||
Algorithm = ReadStringIgnoreCase(hashNode, "algorithm"),
|
||||
Value = ReadStringIgnoreCase(hashNode, "value"),
|
||||
Bytes = ReadByteArrayIgnoreCase(hashNode, "bytes")
|
||||
};
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildMetadata(JsonElement node, string? componentName)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (!string.IsNullOrWhiteSpace(componentName))
|
||||
{
|
||||
metadata["component"] = componentName;
|
||||
}
|
||||
|
||||
PopulateMetadata(node, metadata);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static void PopulateMetadata(JsonElement node, Dictionary<string, string> metadata)
|
||||
{
|
||||
if (!TryGetPropertyIgnoreCase(node, "metadata", out var metadataNode) ||
|
||||
metadataNode.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var property in metadataNode.EnumerateObject())
|
||||
{
|
||||
if (property.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = property.Value.ValueKind == JsonValueKind.String
|
||||
? property.Value.GetString()
|
||||
: property.Value.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
metadata[property.Name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetPropertyIgnoreCase(JsonElement node, string propertyName, out JsonElement value)
|
||||
{
|
||||
if (node.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in node.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = property.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string? ReadStringIgnoreCase(JsonElement node, string propertyName)
|
||||
{
|
||||
if (!TryGetPropertyIgnoreCase(node, propertyName, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null
|
||||
? null
|
||||
: value.ToString();
|
||||
}
|
||||
|
||||
private static byte[]? ReadByteArrayIgnoreCase(JsonElement node, string propertyName)
|
||||
{
|
||||
return TryGetPropertyIgnoreCase(node, propertyName, out var value)
|
||||
? ParseByteArrayValue(value)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static byte[]? ParseByteArrayValue(JsonElement value)
|
||||
{
|
||||
if (value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return UpdateHash.TryParseHashBytes(value.GetString(), out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
if (value.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bytes = new byte[value.GetArrayLength()];
|
||||
var index = 0;
|
||||
foreach (var element in value.EnumerateArray())
|
||||
{
|
||||
if (!element.TryGetInt32(out var number) || number < byte.MinValue || number > byte.MaxValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
bytes[index++] = (byte)number;
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static string? TryGetMetadataValue(Dictionary<string, string>? metadata, string key)
|
||||
{
|
||||
if (metadata is null || metadata.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var pair in metadata)
|
||||
{
|
||||
if (string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.IsNullOrWhiteSpace(pair.Value))
|
||||
{
|
||||
return pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? FirstNonEmpty(params string?[] values)
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
using System.IO.Compression;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Update;
|
||||
|
||||
internal sealed class PlondsPayloadResolver(UpdateEnginePaths paths)
|
||||
{
|
||||
public string ResolveObjectPath(PlondsFileEntry file)
|
||||
{
|
||||
var candidates = new List<string>();
|
||||
AddPathCandidates(candidates, file.ObjectPath);
|
||||
AddPathCandidates(candidates, file.ObjectKey);
|
||||
AddPathCandidates(candidates, file.ArchivePath);
|
||||
AddPathCandidates(candidates, file.ObjectUrl);
|
||||
AddPathCandidates(candidates, file.Url);
|
||||
|
||||
if (PlondsManifestParser.TryGetExpectedObjectSha512(file, out var expectedSha512) ||
|
||||
PlondsManifestParser.TryGetExpectedSha512(file, out expectedSha512))
|
||||
{
|
||||
var hashHex = Convert.ToHexString(expectedSha512).ToLowerInvariant();
|
||||
AddPathCandidates(candidates, Path.Combine(UpdateEnginePaths.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(UpdateEnginePaths.PlondsObjectsDirectoryName, $"{hashHex}.gz"));
|
||||
}
|
||||
|
||||
foreach (var relativePath in candidates.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var fullPath = Path.GetFullPath(Path.Combine(paths.IncomingRoot, relativePath));
|
||||
if (!fullPath.StartsWith(Path.GetFullPath(paths.IncomingRoot), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (File.Exists(fullPath))
|
||||
{
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Unable to resolve object payload for '{file.Path}'.");
|
||||
}
|
||||
|
||||
public static byte[]? TryInflateGzip(byte[] payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var input = new MemoryStream(payload, writable: false);
|
||||
using var gzip = new GZipStream(input, CompressionMode.Decompress);
|
||||
using var output = new MemoryStream();
|
||||
gzip.CopyTo(output);
|
||||
return output.ToArray();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddPathCandidates(ICollection<string> candidates, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = value.Trim();
|
||||
if (Uri.TryCreate(normalized, UriKind.Absolute, out var absoluteUri))
|
||||
{
|
||||
normalized = Uri.UnescapeDataString(absoluteUri.AbsolutePath);
|
||||
}
|
||||
|
||||
normalized = normalized.TrimStart('/', '\\');
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
normalized = normalized.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
|
||||
candidates.Add(normalized);
|
||||
|
||||
if (!normalized.StartsWith($"{UpdateEnginePaths.PlondsObjectsDirectoryName}{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
candidates.Add(Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, normalized));
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(normalized);
|
||||
if (!string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
candidates.Add(Path.Combine(UpdateEnginePaths.PlondsObjectsDirectoryName, fileName));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using ContractsUpdate = LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Update;
|
||||
|
||||
internal sealed class PlondsUpdateApplier(
|
||||
DeploymentLocator deploymentLocator,
|
||||
UpdateEnginePaths paths,
|
||||
UpdateSignatureVerifier signatureVerifier,
|
||||
IUpdateProgressReporter progressReporter,
|
||||
UpdateSnapshotStore snapshotStore,
|
||||
InstallCheckpointStore checkpointStore,
|
||||
DeploymentActivator deploymentActivator,
|
||||
IncomingArtifactsCleaner incomingCleaner,
|
||||
PlondsPayloadResolver payloadResolver)
|
||||
{
|
||||
public async Task<LauncherResult> 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);
|
||||
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.PlondsFileMapPath).ConfigureAwait(false);
|
||||
var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.PlondsFileMap) ?? new PlondsFileMap();
|
||||
var fileEntries = PlondsManifestParser.CollectFileEntries(fileMap);
|
||||
if (fileEntries.Count == 0)
|
||||
{
|
||||
PlondsManifestParser.PopulateFromRawJson(fileMapText, fileMap, fileEntries);
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
var plondsMetadata = PlondsManifestParser.LoadMetadata(paths.PlondsUpdateMetadataPath);
|
||||
var currentDeployment = deploymentLocator.FindCurrentDeploymentDirectory();
|
||||
var currentVersion = deploymentLocator.GetCurrentVersion();
|
||||
var sourceVersion = string.IsNullOrWhiteSpace(currentVersion) ? "0.0.0" : currentVersion;
|
||||
var expectedSourceVersion = PlondsManifestParser.ResolveSourceVersion(fileMap, plondsMetadata);
|
||||
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}.");
|
||||
}
|
||||
|
||||
var targetVersion = PlondsManifestParser.ResolveTargetVersion(fileMap, plondsMetadata);
|
||||
if (string.IsNullOrWhiteSpace(targetVersion))
|
||||
{
|
||||
targetVersion = sourceVersion;
|
||||
}
|
||||
|
||||
var isInitialDeployment = string.IsNullOrWhiteSpace(currentDeployment);
|
||||
var existingCheckpoint = checkpointStore.Load();
|
||||
var canResume = existingCheckpoint is not null
|
||||
&& string.Equals(existingCheckpoint.SourceVersion, sourceVersion, 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, sourceVersion, targetVersion, currentDeployment, targetDeployment);
|
||||
var snapshotPath = snapshotStore.CreateSnapshotPath(snapshot.SnapshotId);
|
||||
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));
|
||||
Directory.CreateDirectory(targetDeployment);
|
||||
File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty);
|
||||
}
|
||||
|
||||
checkpointStore.Save(checkpoint);
|
||||
ApplyFiles(fileEntries, currentDeployment, targetDeployment, checkpoint);
|
||||
VerifyFiles(fileEntries, targetDeployment, checkpoint);
|
||||
|
||||
if (isInitialDeployment)
|
||||
{
|
||||
File.WriteAllText(Path.Combine(targetDeployment, ".current"), string.Empty);
|
||||
var partialMarker = Path.Combine(targetDeployment, ".partial");
|
||||
if (File.Exists(partialMarker))
|
||||
{
|
||||
File.Delete(partialMarker);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.InstallStage.ActivateDeployment, "Activating deployment...", 85, null, fileEntries.Count, fileEntries.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, fileEntries.Count, fileEntries.Count));
|
||||
progressReporter.ReportComplete(new ContractsUpdate.InstallCompleteReport(true, sourceVersion, targetVersion, null, false));
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.apply",
|
||||
Code = "ok",
|
||||
Message = $"Updated to {targetVersion}.",
|
||||
CurrentVersion = sourceVersion,
|
||||
TargetVersion = targetVersion
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HandleFailure(ex, isInitialDeployment, targetDeployment, snapshot, snapshotPath, sourceVersion, targetVersion);
|
||||
}
|
||||
finally
|
||||
{
|
||||
checkpointStore.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFiles(IReadOnlyList<PlondsFileEntry> fileEntries, string? currentDeployment, string targetDeployment, InstallCheckpoint checkpoint)
|
||||
{
|
||||
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.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));
|
||||
}
|
||||
}
|
||||
|
||||
private void VerifyFiles(IReadOnlyList<PlondsFileEntry> fileEntries, string targetDeployment, InstallCheckpoint checkpoint)
|
||||
{
|
||||
progressReporter.ReportProgress(new ContractsUpdate.InstallProgressReport(ContractsUpdate.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));
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFileEntry(PlondsFileEntry 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;
|
||||
}
|
||||
|
||||
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(action, "reuse", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
CopyReusedFile(file, currentDeployment, normalizedPath, targetPath);
|
||||
return;
|
||||
}
|
||||
|
||||
var objectPath = payloadResolver.ResolveObjectPath(file);
|
||||
var objectBytes = File.ReadAllBytes(objectPath);
|
||||
var restoredBytes = PlondsPayloadResolver.TryInflateGzip(objectBytes) ?? objectBytes;
|
||||
File.WriteAllBytes(targetPath, restoredBytes);
|
||||
ApplyUnixFileModeIfPresent(targetPath, file);
|
||||
}
|
||||
|
||||
private static void CopyReusedFile(PlondsFileEntry 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.");
|
||||
}
|
||||
|
||||
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);
|
||||
ApplyUnixFileModeIfPresent(targetPath, file);
|
||||
}
|
||||
|
||||
private static void VerifyFileEntry(PlondsFileEntry file, string targetDeployment)
|
||||
{
|
||||
var action = string.IsNullOrWhiteSpace(file.Action) ? "replace" : file.Action!;
|
||||
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 (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}'.");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(file.Sha256))
|
||||
{
|
||||
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}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private LauncherResult HandleFailure(
|
||||
Exception ex,
|
||||
bool isInitialDeployment,
|
||||
string targetDeployment,
|
||||
SnapshotMetadata 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
|
||||
{
|
||||
Success = false,
|
||||
Stage = "update.apply",
|
||||
Code = "initial_deploy_failed",
|
||||
Message = "Failed to apply initial PLONDS deployment.",
|
||||
ErrorMessage = ex.Message,
|
||||
CurrentVersion = "0.0.0",
|
||||
TargetVersion = targetVersion
|
||||
};
|
||||
}
|
||||
|
||||
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, sourceVersion, 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 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) =>
|
||||
new()
|
||||
{
|
||||
SnapshotId = canResume ? existingCheckpoint!.SnapshotId : Guid.NewGuid().ToString("N"),
|
||||
SourceVersion = sourceVersion,
|
||||
TargetVersion = targetVersion,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
SourceDirectory = currentDeployment ?? string.Empty,
|
||||
TargetDirectory = targetDeployment,
|
||||
Status = "pending"
|
||||
};
|
||||
|
||||
private static InstallCheckpoint BuildCheckpoint(
|
||||
SnapshotMetadata snapshot,
|
||||
string sourceVersion,
|
||||
string targetVersion,
|
||||
string? currentDeployment,
|
||||
string targetDeployment,
|
||||
bool isInitialDeployment) =>
|
||||
new()
|
||||
{
|
||||
SnapshotId = snapshot.SnapshotId,
|
||||
SourceVersion = sourceVersion,
|
||||
TargetVersion = targetVersion,
|
||||
SourceDirectory = currentDeployment,
|
||||
TargetDirectory = targetDeployment,
|
||||
IsInitialDeployment = isInitialDeployment
|
||||
};
|
||||
|
||||
private static void ApplyUnixFileModeIfPresent(string targetPath, PlondsFileEntry file)
|
||||
{
|
||||
if (OperatingSystem.IsWindows() ||
|
||||
!file.Metadata.TryGetValue("unixFileMode", out var rawMode) ||
|
||||
string.IsNullOrWhiteSpace(rawMode))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var modeValue = Convert.ToInt32(rawMode.Trim(), 8);
|
||||
File.SetUnixFileMode(targetPath, (UnixFileMode)modeValue);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
Directory.Delete(path, true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Update;
|
||||
|
||||
internal sealed class RollbackStrategy(
|
||||
DeploymentLocator deploymentLocator,
|
||||
UpdateSnapshotStore snapshotStore,
|
||||
DeploymentActivator deploymentActivator)
|
||||
{
|
||||
public LauncherResult RollbackLatest()
|
||||
{
|
||||
var latest = snapshotStore.LoadLatest();
|
||||
if (latest is null)
|
||||
{
|
||||
return UpdateEngineResults.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.");
|
||||
}
|
||||
|
||||
if (!Directory.Exists(snapshot.SourceDirectory))
|
||||
{
|
||||
return UpdateEngineResults.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.");
|
||||
}
|
||||
|
||||
deploymentActivator.Activate(currentDeployment, snapshot.SourceDirectory);
|
||||
snapshot.Status = "manual_rollback";
|
||||
snapshotStore.Save(snapshotPath, snapshot);
|
||||
|
||||
return new LauncherResult
|
||||
{
|
||||
Success = true,
|
||||
Stage = "update.rollback",
|
||||
Code = "ok",
|
||||
Message = $"Rolled back to {snapshot.SourceVersion}.",
|
||||
RolledBackTo = snapshot.SourceVersion
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<LauncherResult> 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<LauncherResult> 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
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
using ContractsUpdate = LanMountainDesktop.Shared.Contracts.Update;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Update;
|
||||
|
||||
internal sealed class UpdateEnginePaths
|
||||
{
|
||||
public const string UpdateDirectoryName = "update";
|
||||
public const string IncomingDirectoryName = "incoming";
|
||||
public const string SnapshotsDirectoryName = "snapshots";
|
||||
public const string SignedFileMapName = "files.json";
|
||||
public const string SignatureFileName = "files.json.sig";
|
||||
public const string ArchiveFileName = "update.zip";
|
||||
public const string PlondsFileMapName = "plonds-filemap.json";
|
||||
public const string PlondsSignatureFileName = "plonds-filemap.sig";
|
||||
public const string PlondsUpdateMetadataName = "plonds-update.json";
|
||||
public const string PlondsObjectsDirectoryName = "objects";
|
||||
public const string PublicKeyFileName = "public-key.pem";
|
||||
|
||||
public UpdateEnginePaths(string appRoot)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
public string AppRoot { get; }
|
||||
|
||||
public string LauncherRoot { get; }
|
||||
|
||||
public string IncomingRoot { get; }
|
||||
|
||||
public string SnapshotsRoot { get; }
|
||||
|
||||
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 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 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");
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Update;
|
||||
|
||||
internal static class UpdateHash
|
||||
{
|
||||
public static string ComputeSha256Hex(string filePath)
|
||||
{
|
||||
using var stream = File.OpenRead(filePath);
|
||||
var hash = SHA256.HashData(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static byte[] ComputeSha512(string filePath)
|
||||
{
|
||||
using var stream = File.OpenRead(filePath);
|
||||
return SHA512.HashData(stream);
|
||||
}
|
||||
|
||||
public static bool TryParseHashBytes(string? rawHash, out byte[] bytes)
|
||||
{
|
||||
bytes = [];
|
||||
if (string.IsNullOrWhiteSpace(rawHash))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = rawHash.Trim();
|
||||
var separator = normalized.IndexOf(':');
|
||||
if (separator >= 0 && separator < normalized.Length - 1)
|
||||
{
|
||||
normalized = normalized[(separator + 1)..].Trim();
|
||||
}
|
||||
|
||||
var compact = normalized.Replace("-", string.Empty);
|
||||
if (compact.Length > 0 && compact.Length % 2 == 0 && IsHexString(compact))
|
||||
{
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromHexString(compact);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(normalized);
|
||||
return bytes.Length > 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static string NormalizeHashText(string hash)
|
||||
{
|
||||
var normalized = hash.Trim();
|
||||
var separator = normalized.IndexOf(':');
|
||||
if (separator >= 0 && separator < normalized.Length - 1)
|
||||
{
|
||||
normalized = normalized[(separator + 1)..];
|
||||
}
|
||||
|
||||
return normalized.Replace("-", string.Empty).Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool IsHexString(string value)
|
||||
{
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (!Uri.IsHexDigit(ch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
namespace LanMountainDesktop.Launcher.Update;
|
||||
|
||||
internal static class UpdatePathGuard
|
||||
{
|
||||
public static string NormalizeRelativePath(string path)
|
||||
{
|
||||
var normalized = path.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar);
|
||||
return normalized.TrimStart(Path.DirectorySeparatorChar);
|
||||
}
|
||||
|
||||
public static void EnsurePathWithinRoot(string targetPath, string rootPath)
|
||||
{
|
||||
var fullTarget = Path.GetFullPath(targetPath);
|
||||
var fullRoot = Path.GetFullPath(rootPath);
|
||||
if (!fullTarget.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Path traversal detected: {targetPath}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Update;
|
||||
|
||||
internal sealed class UpdateSignatureVerifier(UpdateEnginePaths paths)
|
||||
{
|
||||
public (bool Success, string Message) Verify(string payloadPath, string signaturePath, string signatureName)
|
||||
{
|
||||
if (!File.Exists(signaturePath))
|
||||
{
|
||||
return (false, $"Missing {signatureName}.");
|
||||
}
|
||||
|
||||
if (!File.Exists(paths.PublicKeyPath))
|
||||
{
|
||||
return (false, $"Missing public key: {paths.PublicKeyPath}");
|
||||
}
|
||||
|
||||
var payloadBytes = File.ReadAllBytes(payloadPath);
|
||||
var signatureBase64 = File.ReadAllText(signaturePath).Trim();
|
||||
if (string.IsNullOrWhiteSpace(signatureBase64))
|
||||
{
|
||||
return (false, "Signature is empty.");
|
||||
}
|
||||
|
||||
byte[] signature;
|
||||
try
|
||||
{
|
||||
signature = Convert.FromBase64String(signatureBase64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return (false, "Signature is not valid base64.");
|
||||
}
|
||||
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(File.ReadAllText(paths.PublicKeyPath));
|
||||
var isValid = rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
return isValid ? (true, "ok") : (false, "Signature verification failed.");
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Update;
|
||||
|
||||
internal sealed class UpdateSnapshotStore(UpdateEnginePaths paths)
|
||||
{
|
||||
public string CreateSnapshotPath(string snapshotId) => paths.GetSnapshotPath(snapshotId);
|
||||
|
||||
public void Save(string path, SnapshotMetadata snapshot)
|
||||
{
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata));
|
||||
}
|
||||
|
||||
public (string Path, SnapshotMetadata Snapshot)? LoadLatest()
|
||||
{
|
||||
if (!Directory.Exists(paths.SnapshotsRoot))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var snapshotPath = Directory
|
||||
.EnumerateFiles(paths.SnapshotsRoot, "*.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderByDescending(File.GetCreationTimeUtc)
|
||||
.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(snapshotPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var snapshot = JsonSerializer.Deserialize(File.ReadAllText(snapshotPath), AppJsonContext.Default.SnapshotMetadata);
|
||||
return snapshot is null ? null : (snapshotPath, snapshot);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ using LanMountainDesktop.Launcher.Resources;
|
||||
namespace LanMountainDesktop.Launcher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 更新进度窗口 - 用于 apply-update 命令模式下显示更新/插件升级进度
|
||||
/// 更新进度窗口 - 用于预览模式显示更新/插件升级进度
|
||||
/// </summary>
|
||||
public partial class UpdateWindow : Window
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user